Skip to main content

nightshade/ecs/world/
commands.rs

1use crate::ecs::material::components::MaterialRef;
2use crate::ecs::material::resources::{material_registry_insert, material_registry_remove_unused};
3use crate::ecs::world::{Entity, NAME, World};
4#[cfg(feature = "physics")]
5use crate::plugins::physics::resources::{
6    physics_world_add_collider, physics_world_add_rigid_body,
7};
8use crate::render::generational_registry::registry_entry_by_name;
9use crate::render::mesh_cache::{mesh_cache_insert, mesh_cache_remove_unused};
10use crate::render::procedural_meshes::{
11    create_cone_mesh, create_cube_mesh, create_cylinder_mesh, create_plane_mesh,
12    create_sphere_mesh, create_subdivided_plane_mesh, create_torus_mesh,
13};
14use crate::render::wgpu::texture_cache::{
15    TextureOwner, texture_cache_acquire, texture_cache_release_entity, texture_cache_remove_unused,
16};
17
18use crate::prelude::*;
19#[derive(Debug, Clone)]
20pub enum EcsCommand {
21    DespawnRecursive {
22        entity: Entity,
23    },
24    ReloadMaterial {
25        name: String,
26        material: Box<crate::render::material::Material>,
27    },
28}
29
30#[derive(Debug, Clone)]
31pub enum RenderCommand {
32    UploadUiImageLayer {
33        layer: u32,
34        rgba_data: Vec<u8>,
35        width: u32,
36        height: u32,
37    },
38    LoadHdrSkybox {
39        hdr_data: Vec<u8>,
40    },
41    LoadHdrSkyboxFromPath {
42        path: std::path::PathBuf,
43    },
44    CaptureScreenshot {
45        path: Option<std::path::PathBuf>,
46        /// When set, the saved PNG is downscaled (preserving aspect) so
47        /// the longer side is at most this many pixels. Used for prefab
48        /// thumbnails where a full-resolution screenshot is wasteful.
49        max_dimension: Option<u32>,
50    },
51    ReloadTexture {
52        name: String,
53        rgba_data: Vec<u8>,
54        width: u32,
55        height: u32,
56    },
57    /// Upload a 3D color grading lookup table. `data` is RGBA8 for a
58    /// 16x16x16 table with red varying fastest then green then blue
59    /// (4096 texels, 16384 bytes). Set `color_lut_weight` on the color
60    /// grading settings to blend it in.
61    SetColorLut {
62        data: Vec<u8>,
63    },
64}
65
66#[derive(Default)]
67pub struct CommandQueues {
68    pub ecs: Vec<EcsCommand>,
69    pub render: Vec<RenderCommand>,
70}
71
72/// Spawn `count` entities in the `core` archetype with the supplied component mask.
73pub fn spawn_entities(world: &mut World, core_mask: u64, count: usize) -> Vec<Entity> {
74    world.ecs.spawn_entities(CORE, core_mask, count)
75}
76
77/// Spawn `count` entities in the retained-UI archetype with the supplied component mask.
78pub fn spawn_entities_ui(world: &mut World, ui_mask: u64, count: usize) -> Vec<Entity> {
79    world.ecs.spawn_entities(UI, ui_mask, count)
80}
81
82/// Queue an [`EcsCommand`] to be drained by `process_commands_system` later in the frame schedule.
83pub fn queue_ecs_command(world: &mut World, command: EcsCommand) {
84    world
85        .res_mut::<crate::ecs::world::commands::CommandQueues>()
86        .ecs
87        .push(command);
88}
89
90/// Queue a [`RenderCommand`] to be drained by the renderer at frame setup.
91pub fn queue_render_command(world: &mut World, command: RenderCommand) {
92    world
93        .res_mut::<crate::ecs::world::commands::CommandQueues>()
94        .render
95        .push(command);
96}
97
98/// Lock the OS cursor to the window (relative mouse mode). Used for first-person controls.
99pub fn set_cursor_locked(world: &mut World, locked: bool) {
100    if let Some(window_handle) = &world.res::<crate::ecs::window::resources::Window>().handle {
101        if locked {
102            if window_handle
103                .set_cursor_grab(winit::window::CursorGrabMode::Locked)
104                .is_err()
105            {
106                let _ = window_handle.set_cursor_grab(winit::window::CursorGrabMode::Confined);
107            }
108        } else {
109            let _ = window_handle.set_cursor_grab(winit::window::CursorGrabMode::None);
110        }
111    }
112    #[cfg(not(target_arch = "wasm32"))]
113    {
114        world
115            .res_mut::<crate::ecs::window::resources::Window>()
116            .cursor_locked = locked;
117    }
118}
119
120/// Reconcile [`Window::cursor_locked`] with the browser's actual pointer-lock
121/// state. On the web the browser, not the app, owns the lock: pressing Escape
122/// exits it and the keydown is never delivered, so [`set_cursor_locked`] cannot
123/// be the source of truth. Run this once per frame before app systems so a
124/// user-initiated release is visible to game logic.
125#[cfg(target_arch = "wasm32")]
126pub fn sync_cursor_lock_state(world: &mut World) {
127    let locked = web_sys::window()
128        .and_then(|window| window.document())
129        .and_then(|document| document.pointer_lock_element())
130        .is_some();
131    world
132        .res_mut::<crate::ecs::window::resources::Window>()
133        .cursor_locked = locked;
134}
135
136/// Show or hide the OS cursor.
137pub fn set_cursor_visible(world: &mut World, visible: bool) {
138    if let Some(window_handle) = &world.res::<crate::ecs::window::resources::Window>().handle {
139        window_handle.set_cursor_visible(visible);
140    }
141}
142
143/// Set the global time scale that the frame's delta time is multiplied by:
144/// `0.5` for slow motion, `2.0` for fast forward, `1.0` for real time. Negative
145/// values clamp to zero. Systems that read the scaled delta time follow it;
146/// unscaled work reads `raw_delta_time` instead.
147pub fn set_time_scale(world: &mut World, scale: f32) {
148    world.res_mut::<crate::ecs::time::Time>().time_speed = scale.max(0.0);
149}
150
151/// The current global time scale.
152pub fn time_scale(world: &World) -> f32 {
153    world.res::<crate::ecs::time::Time>().time_speed
154}
155
156/// Pause game time, so the scaled delta time reports zero until resumed. The
157/// time scale is preserved, so resuming returns to the previous speed.
158pub fn pause(world: &mut World) {
159    world.res_mut::<crate::ecs::time::Time>().paused = true;
160}
161
162/// Resume game time after a pause.
163pub fn unpause(world: &mut World) {
164    world.res_mut::<crate::ecs::time::Time>().paused = false;
165}
166
167/// Set whether game time is paused.
168pub fn set_paused(world: &mut World, paused: bool) {
169    world.res_mut::<crate::ecs::time::Time>().paused = paused;
170}
171
172/// Whether game time is currently paused.
173pub fn is_paused(world: &World) -> bool {
174    world.res::<crate::ecs::time::Time>().paused
175}
176
177/// Insert a material into the registry, ref-count it, and attach it to `entity` via [`MaterialRef`].
178pub fn register_material(
179    world: &mut World,
180    entity: Entity,
181    name: String,
182    material: crate::render::material::Material,
183) {
184    material_registry_insert(
185        &mut world
186            .res_mut::<crate::ecs::asset_state::AssetState>()
187            .material_registry,
188        name.clone(),
189        material,
190    );
191    if let Some(&index) = world
192        .res::<crate::ecs::asset_state::AssetState>()
193        .material_registry
194        .registry
195        .name_to_index
196        .get(&name)
197    {
198        registry_add_reference(
199            &mut world
200                .res_mut::<crate::ecs::asset_state::AssetState>()
201                .material_registry
202                .registry,
203            index,
204        );
205    }
206    world.set(entity, MaterialRef::new(name));
207    world
208        .res_mut::<crate::render::mesh_state::MeshRenderState>()
209        .mark_entity_added(render_entity(entity));
210}
211
212#[cfg(feature = "physics")]
213pub fn spawn_physics_body(world: &mut World, entity: Entity) {
214    let rigid_body_comp = world
215        .get::<crate::plugins::physics::components::RigidBodyComponent>(entity)
216        .cloned();
217    let collider_comp = world
218        .get::<crate::plugins::physics::components::ColliderComponent>(entity)
219        .cloned();
220
221    if let Some(rigid_body_comp) = rigid_body_comp {
222        let rapier_body = rigid_body_comp.to_rapier_rigid_body();
223        let rapier_handle =
224            physics_world_add_rigid_body(world.plugin_resource_mut::<PhysicsWorld>(), rapier_body);
225
226        if let Some(collider_comp) = collider_comp {
227            let rapier_collider = collider_comp.to_rapier_collider();
228            physics_world_add_collider(
229                world.plugin_resource_mut::<PhysicsWorld>(),
230                rapier_collider,
231                rapier_handle,
232            );
233        }
234
235        if let Some(rigid_body_mut) =
236            world.get_mut::<crate::plugins::physics::components::RigidBodyComponent>(entity)
237        {
238            rigid_body_mut.handle =
239                Some(crate::plugins::physics::types::rigid_body_handle_from_rapier(rapier_handle));
240        }
241
242        world
243            .plugin_resource_mut::<PhysicsWorld>()
244            .handle_to_entity
245            .insert(rapier_handle, entity);
246    }
247}
248
249pub fn process_commands_system(world: &mut World) {
250    let commands = std::mem::take(
251        &mut world
252            .res_mut::<crate::ecs::world::commands::CommandQueues>()
253            .ecs,
254    );
255    let _span = tracing::info_span!("ecs_commands", count = commands.len()).entered();
256    for command in commands {
257        match command {
258            EcsCommand::DespawnRecursive { entity } => {
259                despawn_recursive_immediate(world, entity);
260            }
261            EcsCommand::ReloadMaterial { name, material } => {
262                reload_material_command(world, name, *material);
263            }
264        }
265    }
266}
267
268fn reload_material_command(
269    world: &mut World,
270    name: String,
271    new_material: crate::render::material::Material,
272) {
273    use crate::ecs::world::MATERIAL_REF;
274
275    let new_textures: Vec<String> = new_material.texture_names().map(str::to_string).collect();
276
277    crate::ecs::material::resources::material_registry_mutate(
278        &mut world
279            .res_mut::<crate::ecs::asset_state::AssetState>()
280            .material_registry,
281        &name,
282        |existing| *existing = new_material,
283    );
284
285    let referencing_entities: Vec<Entity> = world.ecs.worlds[CORE]
286        .query_entities(MATERIAL_REF)
287        .filter(|&entity| {
288            world
289                .get::<crate::ecs::material::components::MaterialRef>(entity)
290                .is_some_and(|mat_ref| mat_ref.name == name)
291        })
292        .collect();
293
294    for entity in referencing_entities {
295        texture_cache_acquire(
296            world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
297            TextureOwner::EntityMaterial(render_entity(entity)),
298            new_textures.clone(),
299        );
300        world
301            .res_mut::<crate::render::mesh_state::MeshRenderState>()
302            .mark_material_dirty(render_entity(entity));
303    }
304}
305
306/// Despawn a batch of entities and clean up cached references (textures, materials, meshes).
307/// Does not walk the hierarchy; use [`despawn_recursive_immediate`] or [`EcsCommand::DespawnRecursive`] for that.
308pub fn despawn_entities_with_cache_cleanup(world: &mut World, entities: &[Entity]) {
309    for &entity in entities {
310        world
311            .res_mut::<crate::ecs::entity_registry::EntityRegistry>()
312            .tags
313            .remove(&entity);
314        if let Some(guid) = world.get::<crate::ecs::primitives::Guid>(entity).copied() {
315            world
316                .res_mut::<crate::ecs::entity_registry::EntityRegistry>()
317                .guid_index
318                .remove(&guid.0);
319        }
320
321        if let Some(render_mesh) = world.get::<crate::ecs::mesh::components::RenderMesh>(entity) {
322            let mesh_name = render_mesh.name.clone();
323            if let Some(&index) = world
324                .res::<crate::ecs::asset_state::AssetState>()
325                .mesh_cache
326                .registry
327                .name_to_index
328                .get(&mesh_name)
329            {
330                registry_remove_reference(
331                    &mut world
332                        .res_mut::<crate::ecs::asset_state::AssetState>()
333                        .mesh_cache
334                        .registry,
335                    index,
336                );
337            }
338            world
339                .res_mut::<crate::render::mesh_state::MeshRenderState>()
340                .mark_entity_removed(render_entity(entity));
341        }
342
343        if let Some(instanced_mesh) =
344            world.get::<crate::ecs::mesh::components::InstancedMesh>(entity)
345        {
346            let mesh_name = instanced_mesh.mesh_name.clone();
347            if let Some(&index) = world
348                .res::<crate::ecs::asset_state::AssetState>()
349                .mesh_cache
350                .registry
351                .name_to_index
352                .get(&mesh_name)
353            {
354                registry_remove_reference(
355                    &mut world
356                        .res_mut::<crate::ecs::asset_state::AssetState>()
357                        .mesh_cache
358                        .registry,
359                    index,
360                );
361            }
362            world
363                .res_mut::<crate::render::mesh_state::MeshRenderState>()
364                .mark_instanced_meshes_changed();
365        }
366
367        texture_cache_release_entity(
368            world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
369            render_entity(entity),
370        );
371
372        if let Some(material_ref) =
373            world.get::<crate::ecs::material::components::MaterialRef>(entity)
374        {
375            let material_name = material_ref.name.clone();
376            if let Some(&index) = world
377                .res::<crate::ecs::asset_state::AssetState>()
378                .material_registry
379                .registry
380                .name_to_index
381                .get(&material_name)
382            {
383                registry_remove_reference(
384                    &mut world
385                        .res_mut::<crate::ecs::asset_state::AssetState>()
386                        .material_registry
387                        .registry,
388                    index,
389                );
390            }
391        }
392    }
393
394    world.despawn_entities(entities);
395}
396
397/// Despawn `entity` and all of its descendants immediately (no deferral).
398pub fn despawn_recursive_immediate(world: &mut World, entity: Entity) {
399    crate::ecs::transform::systems::sync_hierarchy_index(world);
400    let mut targets = world
401        .res::<freecs::dynamic::HierarchyIndex>()
402        .descendants(entity);
403    targets.insert(0, entity);
404    despawn_entities_with_cache_cleanup(world, &targets);
405}
406
407/// Spawn a default directional light positioned above the origin, with shadows enabled.
408/// Sets an entity's local transform, a default global transform, and the
409/// dirty flag. These three writes are performed together by every spawn helper.
410pub(crate) fn setup_entity_transforms(
411    world: &mut World,
412    entity: Entity,
413    local_transform: crate::ecs::world::components::LocalTransform,
414) {
415    world.set(entity, local_transform);
416    world.set(
417        entity,
418        crate::ecs::world::components::GlobalTransform::default(),
419    );
420}
421
422pub fn spawn_sun(world: &mut World) -> Entity {
423    use crate::ecs::world::components;
424
425    world.ecs.spawn_with((
426        components::Name("Sun".to_string()),
427        components::LocalTransform {
428            translation: nalgebra_glm::Vec3::new(5.0, 10.0, 5.0),
429            rotation: nalgebra_glm::quat_angle_axis(
430                std::f32::consts::FRAC_PI_4,
431                &nalgebra_glm::Vec3::new(0.0, 1.0, 0.0),
432            ) * nalgebra_glm::quat_angle_axis(
433                -std::f32::consts::FRAC_PI_6,
434                &nalgebra_glm::Vec3::new(1.0, 0.0, 0.0),
435            ),
436            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
437        },
438        components::GlobalTransform::default(),
439        components::Light {
440            light_type: components::LightType::Directional,
441            color: nalgebra_glm::Vec3::new(1.0, 0.95, 0.8),
442            intensity: 5.0,
443            range: 100.0,
444            inner_cone_angle: std::f32::consts::PI / 6.0,
445            outer_cone_angle: std::f32::consts::PI / 4.0,
446            cast_shadows: true,
447            shadow_bias: 0.0005,
448            shadow_resolution: 0,
449            shadow_distance: 0.0,
450            cookie_texture: None,
451            ..Default::default()
452        },
453    ))
454}
455
456/// Spawn a directional light positioned above the origin without shadow casting.
457pub fn spawn_sun_without_shadows(world: &mut World) -> Entity {
458    use crate::ecs::world::components;
459
460    world.ecs.spawn_with((
461        components::Name("Sun".to_string()),
462        components::LocalTransform {
463            translation: nalgebra_glm::Vec3::new(5.0, 10.0, 5.0),
464            rotation: nalgebra_glm::quat_angle_axis(
465                -std::f32::consts::FRAC_PI_4,
466                &nalgebra_glm::Vec3::new(1.0, 0.0, 0.0),
467            ),
468            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
469        },
470        components::GlobalTransform::default(),
471        components::Light {
472            light_type: components::LightType::Directional,
473            color: nalgebra_glm::Vec3::new(1.0, 0.95, 0.8),
474            intensity: 5.0,
475            range: 100.0,
476            inner_cone_angle: std::f32::consts::PI / 6.0,
477            outer_cone_angle: std::f32::consts::PI / 4.0,
478            cast_shadows: false,
479            shadow_bias: 0.0,
480            shadow_resolution: 0,
481            shadow_distance: 0.0,
482            cookie_texture: None,
483            ..Default::default()
484        },
485    ))
486}
487
488/// Spawn a positioned light entity carrying a default [`components::Light`] for the caller to configure.
489pub fn spawn_light_entity(world: &mut World, position: nalgebra_glm::Vec3, name: &str) -> Entity {
490    use crate::ecs::world::components;
491    use crate::ecs::world::{GLOBAL_TRANSFORM, LIGHT, LOCAL_TRANSFORM, NAME};
492
493    let entity = spawn_entities(world, NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LIGHT, 1)[0];
494    world.set(entity, components::Name(name.to_string()));
495    setup_entity_transforms(
496        world,
497        entity,
498        components::LocalTransform {
499            translation: position,
500            rotation: nalgebra_glm::quat_identity(),
501            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
502        },
503    );
504    entity
505}
506
507/// Spawn an entity that references `mesh_name` in the mesh cache, with default material, at `position` scaled by `scale`.
508pub fn spawn_mesh_at(
509    world: &mut World,
510    mesh_name: &str,
511    position: nalgebra_glm::Vec3,
512    scale: nalgebra_glm::Vec3,
513) -> Entity {
514    use crate::ecs::world::components;
515
516    let render_mesh = components::RenderMesh::new(mesh_name);
517
518    if !world
519        .res::<crate::ecs::asset_state::AssetState>()
520        .mesh_cache
521        .registry
522        .name_to_index
523        .contains_key(&render_mesh.name)
524    {
525        let mesh = match mesh_name {
526            "Cube" => Some(create_cube_mesh()),
527            "Sphere" => Some(create_sphere_mesh(1.0, 16)),
528            "Plane" => Some(create_plane_mesh(2.0)),
529            "SubdividedPlane" => Some(create_subdivided_plane_mesh(2.0, 20)),
530            "Torus" => Some(create_torus_mesh(1.0, 0.3, 32, 16)),
531            "Cylinder" => Some(create_cylinder_mesh(0.5, 1.0, 16)),
532            "Cone" => Some(create_cone_mesh(0.5, 1.0, 16)),
533            _ => None,
534        };
535        if let Some(mesh) = mesh {
536            mesh_cache_insert(
537                &mut world
538                    .res_mut::<crate::ecs::asset_state::AssetState>()
539                    .mesh_cache,
540                mesh_name.to_string(),
541                mesh,
542            );
543        }
544    }
545
546    if let Some(&index) = world
547        .res::<crate::ecs::asset_state::AssetState>()
548        .mesh_cache
549        .registry
550        .name_to_index
551        .get(&render_mesh.name)
552    {
553        registry_add_reference(
554            &mut world
555                .res_mut::<crate::ecs::asset_state::AssetState>()
556                .mesh_cache
557                .registry,
558            index,
559        );
560    }
561
562    if let Some(&index) = world
563        .res::<crate::ecs::asset_state::AssetState>()
564        .material_registry
565        .registry
566        .name_to_index
567        .get("Default")
568    {
569        registry_add_reference(
570            &mut world
571                .res_mut::<crate::ecs::asset_state::AssetState>()
572                .material_registry
573                .registry,
574            index,
575        );
576    }
577
578    let entity = world.ecs.spawn_with((
579        components::Name(mesh_name.to_string()),
580        components::LocalTransform {
581            translation: position,
582            scale,
583            rotation: nalgebra_glm::Quat::identity(),
584        },
585        components::GlobalTransform::default(),
586        render_mesh,
587        components::MaterialRef::new("Default"),
588        components::BoundingVolume::from_mesh_type(mesh_name),
589        components::CastsShadow,
590        crate::ecs::primitives::Visibility { visible: true },
591    ));
592    world
593        .res_mut::<crate::render::mesh_state::MeshRenderState>()
594        .mark_entity_added(render_entity(entity));
595
596    entity
597}
598
599/// Spawn a unit cube mesh at `position` with the default material.
600pub fn spawn_cube_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
601    spawn_mesh_at(
602        world,
603        "Cube",
604        position,
605        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
606    )
607}
608
609/// Spawn a unit sphere mesh at `position` with the default material.
610pub fn spawn_sphere_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
611    spawn_mesh_at(
612        world,
613        "Sphere",
614        position,
615        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
616    )
617}
618
619/// Spawn a unit cylinder mesh at `position` with the default material.
620pub fn spawn_cylinder_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
621    spawn_mesh_at(
622        world,
623        "Cylinder",
624        position,
625        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
626    )
627}
628
629/// Spawn a torus mesh at `position` with the default material.
630pub fn spawn_torus_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
631    spawn_mesh_at(
632        world,
633        "Torus",
634        position,
635        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
636    )
637}
638
639/// Spawn a cone mesh at `position` with the default material.
640pub fn spawn_cone_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
641    spawn_mesh_at(
642        world,
643        "Cone",
644        position,
645        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
646    )
647}
648
649/// Spawn a unit-sized plane mesh at `position` with the default material.
650pub fn spawn_plane_at(world: &mut World, position: nalgebra_glm::Vec3) -> Entity {
651    spawn_mesh_at(
652        world,
653        "Plane",
654        position,
655        nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
656    )
657}
658
659/// Spawn a 3D text entity at `position` with the supplied text and properties.
660pub fn spawn_3d_text_with_properties(
661    world: &mut World,
662    text: &str,
663    position: nalgebra_glm::Vec3,
664    properties: crate::ecs::text::components::TextProperties,
665) -> Entity {
666    spawn_3d_text_impl(world, text, position, properties, false)
667}
668
669/// Spawn a 3D billboard text entity that always faces the active camera.
670pub fn spawn_3d_billboard_text_with_properties(
671    world: &mut World,
672    text: &str,
673    position: nalgebra_glm::Vec3,
674    properties: crate::ecs::text::components::TextProperties,
675) -> Entity {
676    spawn_3d_text_impl(world, text, position, properties, true)
677}
678
679fn spawn_3d_text_impl(
680    world: &mut World,
681    text: &str,
682    position: nalgebra_glm::Vec3,
683    properties: crate::ecs::text::components::TextProperties,
684    billboard: bool,
685) -> Entity {
686    use crate::ecs::world::components;
687    use crate::ecs::world::{GLOBAL_TRANSFORM, LOCAL_TRANSFORM, NAME, TEXT, VISIBILITY};
688
689    let text_index = world
690        .res_mut::<crate::ecs::text::resources::TextState>()
691        .cache
692        .add_text(text);
693
694    let entity = spawn_entities(
695        world,
696        NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | TEXT | VISIBILITY,
697        1,
698    )[0];
699
700    world.set(entity, components::Name(text.to_string()));
701    world.set(
702        entity,
703        components::LocalTransform {
704            translation: position,
705            rotation: nalgebra_glm::Quat::identity(),
706            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
707        },
708    );
709    world.set(entity, components::GlobalTransform::default());
710    world.set(
711        entity,
712        components::Text {
713            text_index,
714            properties,
715            dirty: true,
716            cached_mesh: None,
717            billboard,
718        },
719    );
720    world.set(entity, components::Visibility { visible: true });
721
722    entity
723}
724
725const CLEANUP_INTERVAL_FRAMES: u64 = 300;
726
727pub fn cleanup_unused_resources_system(world: &mut World) {
728    let _span = tracing::info_span!("cleanup").entered();
729    world
730        .res_mut::<crate::ecs::world::cleanup::CleanupState>()
731        .frame_counter += 1;
732
733    if world
734        .res::<crate::ecs::world::cleanup::CleanupState>()
735        .frame_counter
736        >= CLEANUP_INTERVAL_FRAMES
737    {
738        world
739            .res_mut::<crate::ecs::world::cleanup::CleanupState>()
740            .frame_counter = 0;
741        mesh_cache_remove_unused(
742            &mut world
743                .res_mut::<crate::ecs::asset_state::AssetState>()
744                .mesh_cache,
745        );
746        material_registry_remove_unused(
747            &mut world
748                .res_mut::<crate::ecs::asset_state::AssetState>()
749                .material_registry,
750        );
751        texture_cache_remove_unused(
752            world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
753        );
754    }
755}
756
757pub fn set_material_with_textures(
758    world: &mut World,
759    entity: Entity,
760    material: crate::render::material::Material,
761) {
762    let material_ref = world
763        .get::<crate::ecs::material::components::MaterialRef>(entity)
764        .cloned();
765
766    let texture_names: Vec<String> = material.texture_names().map(str::to_string).collect();
767    texture_cache_acquire(
768        world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
769        TextureOwner::EntityMaterial(render_entity(entity)),
770        texture_names,
771    );
772
773    if let Some(ref mat_ref) = material_ref {
774        crate::ecs::material::resources::material_registry_mutate(
775            &mut world
776                .res_mut::<crate::ecs::asset_state::AssetState>()
777                .material_registry,
778            &mat_ref.name,
779            |existing_mat| *existing_mat = material,
780        );
781        world
782            .res_mut::<crate::render::mesh_state::MeshRenderState>()
783            .mark_material_dirty(render_entity(entity));
784    } else {
785        let material_name = format!("TexturedMaterial_{}", entity.id);
786        material_registry_insert(
787            &mut world
788                .res_mut::<crate::ecs::asset_state::AssetState>()
789                .material_registry,
790            material_name.clone(),
791            material,
792        );
793        if let Some(&index) = world
794            .res::<crate::ecs::asset_state::AssetState>()
795            .material_registry
796            .registry
797            .name_to_index
798            .get(&material_name)
799        {
800            registry_add_reference(
801                &mut world
802                    .res_mut::<crate::ecs::asset_state::AssetState>()
803                    .material_registry
804                    .registry,
805                index,
806            );
807        }
808        world.set(
809            entity,
810            crate::ecs::material::components::MaterialRef::new(material_name),
811        );
812    }
813}
814
815pub fn load_procedural_textures(world: &mut World) {
816    let default_sampler = crate::render::texture_data::SamplerSettings::DEFAULT;
817    let color = crate::render::texture_data::TextureUsage::Color;
818
819    let checkerboard = generate_checkerboard_texture();
820    crate::ecs::loading::queue_decoded_texture(
821        world,
822        "checkerboard".to_string(),
823        checkerboard.0,
824        checkerboard.1,
825        checkerboard.2,
826        color,
827        default_sampler,
828    );
829
830    let gradient = generate_gradient_texture();
831    crate::ecs::loading::queue_decoded_texture(
832        world,
833        "gradient".to_string(),
834        gradient.0,
835        gradient.1,
836        gradient.2,
837        color,
838        default_sampler,
839    );
840
841    let uv_test = generate_uv_test_texture();
842    crate::ecs::loading::queue_decoded_texture(
843        world,
844        "uv_test".to_string(),
845        uv_test.0,
846        uv_test.1,
847        uv_test.2,
848        color,
849        default_sampler,
850    );
851
852    for name in ["checkerboard", "gradient", "uv_test"] {
853        crate::render::wgpu::texture_cache::texture_cache_protect(
854            world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
855            name.to_string(),
856        );
857    }
858}
859
860fn generate_checkerboard_texture() -> (Vec<u8>, u32, u32) {
861    let width = 256;
862    let height = 256;
863    let checker_size = 32;
864
865    let mut pixels = Vec::new();
866    for y in 0..height {
867        for x in 0..width {
868            let checker_x = (x / checker_size) % 2;
869            let checker_y = (y / checker_size) % 2;
870            let is_white = (checker_x + checker_y) % 2 == 0;
871
872            if is_white {
873                pixels.extend_from_slice(&[255, 255, 255, 255]);
874            } else {
875                pixels.extend_from_slice(&[64, 64, 64, 255]);
876            }
877        }
878    }
879
880    (pixels, width, height)
881}
882
883fn generate_gradient_texture() -> (Vec<u8>, u32, u32) {
884    let width = 256;
885    let height = 256;
886
887    let mut pixels = Vec::new();
888    for y in 0..height {
889        for x in 0..width {
890            let r = (x * 255 / width) as u8;
891            let g = (y * 255 / height) as u8;
892            let b = 128u8;
893            pixels.extend_from_slice(&[r, g, b, 255]);
894        }
895    }
896
897    (pixels, width, height)
898}
899
900fn generate_uv_test_texture() -> (Vec<u8>, u32, u32) {
901    let width = 256;
902    let height = 256;
903
904    let mut pixels = Vec::new();
905    for y in 0..height {
906        for x in 0..width {
907            let u = x as f32 / width as f32;
908            let v = y as f32 / height as f32;
909
910            let r = (u * 255.0) as u8;
911            let g = (v * 255.0) as u8;
912            let b = ((1.0 - u) * (1.0 - v) * 255.0) as u8;
913
914            pixels.extend_from_slice(&[r, g, b, 255]);
915        }
916    }
917
918    (pixels, width, height)
919}
920
921pub fn load_hdr_skybox(world: &mut World, hdr_data: Vec<u8>) {
922    queue_render_command(world, RenderCommand::LoadHdrSkybox { hdr_data });
923}
924
925pub fn load_hdr_skybox_from_path(world: &mut World, path: std::path::PathBuf) {
926    queue_render_command(world, RenderCommand::LoadHdrSkyboxFromPath { path });
927}
928
929#[cfg(not(target_arch = "wasm32"))]
930pub fn capture_screenshot(world: &mut World) {
931    queue_render_command(
932        world,
933        RenderCommand::CaptureScreenshot {
934            path: None,
935            max_dimension: None,
936        },
937    );
938}
939
940#[cfg(not(target_arch = "wasm32"))]
941pub fn capture_screenshot_to_path(world: &mut World, path: impl Into<std::path::PathBuf>) {
942    queue_render_command(
943        world,
944        RenderCommand::CaptureScreenshot {
945            path: Some(path.into()),
946            max_dimension: None,
947        },
948    );
949}
950
951#[cfg(not(target_arch = "wasm32"))]
952pub fn capture_thumbnail_to_path(
953    world: &mut World,
954    path: impl Into<std::path::PathBuf>,
955    max_dimension: u32,
956) {
957    queue_render_command(
958        world,
959        RenderCommand::CaptureScreenshot {
960            path: Some(path.into()),
961            max_dimension: Some(max_dimension),
962        },
963    );
964}
965
966/// Spawn an entity that draws `count` instanced copies of `mesh_name` using per-instance transforms.
967pub fn spawn_instanced_mesh(
968    world: &mut World,
969    mesh_name: &str,
970    instances: Vec<crate::ecs::mesh::components::InstanceTransform>,
971) -> Entity {
972    use crate::ecs::world::components;
973    use crate::ecs::world::{
974        CASTS_SHADOW, GLOBAL_TRANSFORM, INSTANCED_MESH, LOCAL_TRANSFORM, MATERIAL_REF, NAME,
975        VISIBILITY,
976    };
977
978    let entity = spawn_entities(
979        world,
980        NAME | LOCAL_TRANSFORM
981            | GLOBAL_TRANSFORM
982            | INSTANCED_MESH
983            | MATERIAL_REF
984            | VISIBILITY
985            | CASTS_SHADOW,
986        1,
987    )[0];
988
989    world.set(entity, components::Name(format!("Instanced {}", mesh_name)));
990    world.set(
991        entity,
992        components::LocalTransform {
993            translation: nalgebra_glm::Vec3::new(0.0, 0.0, 0.0),
994            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
995            rotation: nalgebra_glm::Quat::identity(),
996        },
997    );
998    world.set(entity, components::GlobalTransform::default());
999
1000    world.set(
1001        entity,
1002        crate::ecs::mesh::components::InstancedMesh::with_instances(mesh_name, instances),
1003    );
1004
1005    if let Some(&index) = world
1006        .res::<crate::ecs::asset_state::AssetState>()
1007        .material_registry
1008        .registry
1009        .name_to_index
1010        .get("Default")
1011    {
1012        registry_add_reference(
1013            &mut world
1014                .res_mut::<crate::ecs::asset_state::AssetState>()
1015                .material_registry
1016                .registry,
1017            index,
1018        );
1019    }
1020    world.set(entity, components::MaterialRef::new("Default"));
1021
1022    world.set(entity, components::Visibility { visible: true });
1023    world.set(entity, components::CastsShadow);
1024
1025    entity
1026}
1027
1028/// Spawn an instanced-mesh entity using `mesh_name` and the supplied material.
1029pub fn spawn_instanced_mesh_with_material(
1030    world: &mut World,
1031    mesh_name: &str,
1032    instances: Vec<crate::ecs::mesh::components::InstanceTransform>,
1033    material_name: &str,
1034) -> Entity {
1035    use crate::ecs::world::components;
1036    use crate::ecs::world::{
1037        CASTS_SHADOW, GLOBAL_TRANSFORM, INSTANCED_MESH, LOCAL_TRANSFORM, MATERIAL_REF, NAME,
1038        VISIBILITY,
1039    };
1040
1041    let entity = spawn_entities(
1042        world,
1043        NAME | LOCAL_TRANSFORM
1044            | GLOBAL_TRANSFORM
1045            | INSTANCED_MESH
1046            | MATERIAL_REF
1047            | VISIBILITY
1048            | CASTS_SHADOW,
1049        1,
1050    )[0];
1051
1052    world.set(entity, components::Name(format!("Instanced {}", mesh_name)));
1053    world.set(
1054        entity,
1055        components::LocalTransform {
1056            translation: nalgebra_glm::Vec3::new(0.0, 0.0, 0.0),
1057            scale: nalgebra_glm::Vec3::new(1.0, 1.0, 1.0),
1058            rotation: nalgebra_glm::Quat::identity(),
1059        },
1060    );
1061    world.set(entity, components::GlobalTransform::default());
1062
1063    world.set(
1064        entity,
1065        crate::ecs::mesh::components::InstancedMesh::with_instances(mesh_name, instances),
1066    );
1067
1068    if let Some(&index) = world
1069        .res::<crate::ecs::asset_state::AssetState>()
1070        .material_registry
1071        .registry
1072        .name_to_index
1073        .get(material_name)
1074    {
1075        registry_add_reference(
1076            &mut world
1077                .res_mut::<crate::ecs::asset_state::AssetState>()
1078                .material_registry
1079                .registry,
1080            index,
1081        );
1082    }
1083
1084    let texture_names: Vec<String> = registry_entry_by_name(
1085        &world
1086            .res::<crate::ecs::asset_state::AssetState>()
1087            .material_registry
1088            .registry,
1089        material_name,
1090    )
1091    .map(|mat| mat.texture_names().map(str::to_string).collect())
1092    .unwrap_or_default();
1093    texture_cache_acquire(
1094        world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(),
1095        TextureOwner::EntityMaterial(render_entity(entity)),
1096        texture_names,
1097    );
1098
1099    world.set(entity, components::MaterialRef::new(material_name));
1100
1101    world.set(entity, components::Visibility { visible: true });
1102    world.set(entity, components::CastsShadow);
1103
1104    entity
1105}
1106
1107pub fn find_entity_by_name(world: &World, name: &str) -> Option<Entity> {
1108    world.ecs.worlds[CORE].query_entities(NAME).find(|&entity| {
1109        world
1110            .get::<crate::ecs::primitives::Name>(entity)
1111            .is_some_and(|n| n.0 == name)
1112    })
1113}
1114
1115/// Register `material` in the registry under `name` and attach it to `entity` via [`MaterialRef`].
1116pub fn spawn_material(
1117    world: &mut World,
1118    entity: Entity,
1119    name: String,
1120    material: crate::render::material::Material,
1121) {
1122    material_registry_insert(
1123        &mut world
1124            .res_mut::<crate::ecs::asset_state::AssetState>()
1125            .material_registry,
1126        name.clone(),
1127        material,
1128    );
1129    if let Some(&index) = world
1130        .res::<crate::ecs::asset_state::AssetState>()
1131        .material_registry
1132        .registry
1133        .name_to_index
1134        .get(&name)
1135    {
1136        registry_add_reference(
1137            &mut world
1138                .res_mut::<crate::ecs::asset_state::AssetState>()
1139                .material_registry
1140                .registry,
1141            index,
1142        );
1143    }
1144    world.set(entity, MaterialRef::new(name));
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150
1151    #[test]
1152    fn process_commands_system_drains_ecs_and_leaves_render() {
1153        let mut world = World::default();
1154        let entity = world.spawn_count(1)[0];
1155
1156        queue_ecs_command(&mut world, EcsCommand::DespawnRecursive { entity });
1157        queue_render_command(
1158            &mut world,
1159            RenderCommand::LoadHdrSkybox {
1160                hdr_data: vec![0u8; 4],
1161            },
1162        );
1163
1164        process_commands_system(&mut world);
1165
1166        assert!(
1167            world
1168                .res::<crate::ecs::world::commands::CommandQueues>()
1169                .ecs
1170                .is_empty(),
1171            "process_commands_system should drain the ecs queue"
1172        );
1173        assert_eq!(
1174            world
1175                .res::<crate::ecs::world::commands::CommandQueues>()
1176                .render
1177                .len(),
1178            1,
1179            "process_commands_system must not touch the render queue"
1180        );
1181        assert!(matches!(
1182            world
1183                .res::<crate::ecs::world::commands::CommandQueues>()
1184                .render[0],
1185            RenderCommand::LoadHdrSkybox { .. }
1186        ));
1187    }
1188}