Skip to main content

unlit3dx_examples/
render.rs

1//! The demo's render bridge: an asset server over entities in the render
2//! sub-world.
3//!
4//! `unlit3dx` leaves the render bridge to its callers; this module is the
5//! demo's own, built on the runtime's core and on `unlit3d`'s generic
6//! `MeshSource` registry. It is a template for a caller's bridge: register a
7//! pipeline family and allocate through the mesh source, and the world is the
8//! same registry either way.
9//!
10//! The server helpers follow one rule: the world is the registry. A mesh or
11//! a material is allocated inside the render world and left there as the
12//! [`GpuMesh`] or [`GpuMaterial`] component of its own asset entity — the
13//! GPU handles inside carry `Rc`, so they cannot cross a thread, but the
14//! entity can. A simulation world builds through a job that names the assets
15//! by entity, or asks for an entity it does not have yet with a
16//! [`Pending`](unlit3dx::prelude::Pending): the answer comes back under the
17//! key the request was asked under, and the next job names it.
18
19use glam::{Affine3A, Vec3};
20use unlit_ecs::{Bundle, DynamicBundle, Entity, Scope, World};
21use unlit_wgpu::pipeline::LitOptions;
22use unlit3d::bounds::FrustumPlanes;
23use unlit3d::components::{
24    Camera, GpuMaterial, GpuMesh, GpuRenderPipeline, InstanceVisibility, Transform,
25};
26use unlit3d::culling::is_culled;
27use unlit3d::lit::{LitMeshDesc, LitPipelineKey};
28use unlit3d::mesh_source::MeshSource;
29use unlit3d::source::Source;
30
31use unlit_ecs::bundle;
32use unlit3dx::job::Job;
33
34/// A mesh description that owns its vertex data, so it can cross a thread
35/// inside a job; [`LitMeshDesc`] is what the render world finally sees.
36#[derive(Clone, Debug, Default)]
37pub struct OwnedLitMeshDesc {
38    /// Per-vertex positions, in the mesh's local space.
39    pub positions: Vec<[f32; 3]>,
40    /// Per-vertex UVs, for a variant that reads them.
41    pub uvs: Option<Vec<[f32; 2]>>,
42    /// Per-vertex linear RGBA colors, for a variant that reads them.
43    pub colors: Option<Vec<[u8; 4]>>,
44    /// Triangle indices, for an indexed draw.
45    pub indices: Option<Vec<u32>>,
46}
47
48impl OwnedLitMeshDesc {
49    /// The borrowed view the allocation API takes.
50    #[must_use]
51    fn borrowed(&self) -> LitMeshDesc<'_> {
52        LitMeshDesc {
53            positions: &self.positions,
54            uvs: self.uvs.as_deref(),
55            colors: self.colors.as_deref(),
56            indices: self.indices.as_deref(),
57            ..Default::default()
58        }
59    }
60}
61
62/// An entity of the render sub-world.
63///
64/// Every world numbers its entities from zero, so an id from the world that
65/// sends a job names something unrelated in the world that runs it: the
66/// render entities a scene passes around are wrapped in this, and a bare
67/// [`Entity`] in the same code is one of another world's. The wrap costs
68/// nothing at run time; it is what keeps a gameplay entity from reaching a
69/// render query by mistake.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub struct RenderEntity(pub Entity);
72
73/// The render world's mesh source and the pipeline key its server jobs
74/// use.
75///
76/// The key is the one installed in the render world: a caller that spawns
77/// an [`LitPipelineKey`] component chooses the options. Without one the
78/// standard unlit options are built from the mesh source's device, so a
79/// caller that only wants the built-in look never has to touch a device.
80///
81/// For a pipeline of the caller's own, register a family and allocate
82/// through `unlit3d`'s [`MeshSource`] directly; the world is the same
83/// registry either way.
84pub(crate) fn mesh_source(world: &World) -> Option<(RenderEntity, LitPipelineKey)> {
85    let mut sources = world.root_scope().query::<(&Source, Entity)>();
86    let entity = sources
87        .iter()
88        .find(|(source, _)| source.as_ref::<MeshSource>().is_ok())
89        .map(|(_, entity)| entity)?;
90    let key = world
91        .root_scope()
92        .query::<&LitPipelineKey>()
93        .iter()
94        .next()
95        .cloned()
96        .unwrap_or_else(|| {
97            LitPipelineKey::new(LitOptions {
98                unlit: true,
99                ..LitOptions::standard()
100            })
101        });
102    Some((RenderEntity(entity), key))
103}
104
105/// Allocate a mesh in the render world, answering its asset entity.
106///
107/// This is the mesh allocation a job that builds several things in one step
108/// makes for itself. Panics when the world carries no `MeshSource`.
109/// several things in one step. Panics when the world carries no
110/// `MeshSource`.
111pub(crate) fn allocate_mesh(world: &mut World, desc: &OwnedLitMeshDesc) -> RenderEntity {
112    let Some((source_entity, key)) = mesh_source(world) else {
113        panic!("the render world carries no MeshSource");
114    };
115    let root = world.root_scope();
116    let mesh = root
117        .query_one::<&mut Source>(source_entity.0)
118        .map(|mut source| {
119            source
120                .get()
121                .as_mut::<MeshSource>()
122                .expect("the source carries a MeshSource")
123                .allocate_lit_mesh(&root, &key.options, desc.borrowed())
124        })
125        .expect("the mesh source entity exists");
126    // The asset hangs under the source that draws it, so the world holds
127    // one subtree per source rather than asset entities at its root.
128    RenderEntity(
129        world
130            .scope_mut(source_entity.0)
131            .expect("the mesh source entity exists")
132            .spawn(bundle![mesh]),
133    )
134}
135
136/// Allocate a material over the RGBA image `pixels`, answering its asset
137/// entity.
138///
139/// This is the material a job that builds several things in one step
140/// allocates for itself.
141///
142/// # Panics
143/// Panics when `pixels` does not hold exactly `size.0 * size.1` pixels, or
144/// when the world carries no `MeshSource`.
145pub(crate) fn allocate_rgba_material(
146    world: &mut World,
147    size: (u32, u32),
148    pixels: &[[u8; 4]],
149    descriptor: wgpu::SamplerDescriptor<'static>,
150) -> RenderEntity {
151    assert_eq!(
152        pixels.len(),
153        size.0 as usize * size.1 as usize,
154        "the pixels do not fill the texture"
155    );
156    allocate_material(world, descriptor, |source, world| {
157        rgba_texture(&source.device(world), &source.queue(world), size, pixels)
158    })
159}
160
161/// Allocate one material in the render world from a texture built on its
162/// device, and answer its asset entity.
163///
164/// It builds a texture, registers it with a default view and a sampler,
165/// then allocates the material. Panics inside the render world when it
166/// carries no `MeshSource`.
167fn allocate_material(
168    world: &mut World,
169    descriptor: wgpu::SamplerDescriptor<'static>,
170    build_texture: impl FnOnce(&MeshSource, &Scope<'_>) -> wgpu::Texture,
171) -> RenderEntity {
172    let Some((source_entity, key)) = mesh_source(world) else {
173        panic!("the render world carries no MeshSource");
174    };
175    let root = world.root_scope();
176    let material = root
177        .query_one::<&mut Source>(source_entity.0)
178        .map(|mut source| {
179            let source = source
180                .get()
181                .as_mut::<MeshSource>()
182                .expect("the source carries a MeshSource");
183            let texture = build_texture(source, &root);
184            let view = source.register_texture_and_default_view(&root, texture).1;
185            let sampler = source.register_sampler(&root, Some(descriptor));
186            source.allocate_lit_material(&root, &key.options, view, sampler)
187        })
188        .expect("the mesh source entity exists");
189    RenderEntity(
190        world
191            .scope_mut(source_entity.0)
192            .expect("the mesh source entity exists")
193            .spawn(bundle![material]),
194    )
195}
196
197/// Spawn one rendered instance from asset entities, answering the new
198/// entity.
199///
200/// This is the instance a job that builds several things in one step spawns
201/// for itself. Panics when the world carries no `MeshSource`, or when an
202/// asset entity is gone.
203pub(crate) fn instance_of(
204    world: &mut World,
205    mesh: RenderEntity,
206    material: RenderEntity,
207    transform: Transform,
208) -> RenderEntity {
209    let Some((source_entity, key)) = mesh_source(world) else {
210        panic!("the render world carries no MeshSource");
211    };
212    let mesh = world
213        .root_scope()
214        .query_one::<&GpuMesh>(mesh.0)
215        .expect("the mesh asset entity exists")
216        .get()
217        .clone();
218    let material = world
219        .root_scope()
220        .query_one::<&GpuMaterial>(material.0)
221        .expect("the material asset entity exists")
222        .get()
223        .clone();
224    // The instance hangs under the source that draws it: a source draws
225    // the subtree it owns, so an instance spawned beside it is never seen.
226    RenderEntity(
227        world
228            .scope_mut(source_entity.0)
229            .expect("the mesh source entity exists")
230            .spawn(Rendered {
231                transform,
232                mesh,
233                material,
234                pipeline: GpuRenderPipeline::new(key),
235            }),
236    )
237}
238
239/// One thing the render world draws: a mesh, the material bound to it, the
240/// pipeline that draws it and where it stands.
241///
242/// A job names the assets it built and the transform to place them at, and the
243/// four components are what that one instance is; spawning them apart would let
244/// a mesh reach the world without the material or the pipeline that make it
245/// visible.
246struct Rendered {
247    /// Where the instance stands in the scene.
248    transform: Transform,
249    /// The mesh allocated for it.
250    mesh: GpuMesh,
251    /// The material bound to that mesh.
252    material: GpuMaterial,
253    /// The pipeline family the mesh's vertex layout was compiled for.
254    pipeline: GpuRenderPipeline,
255}
256
257impl Bundle for Rendered {
258    fn into_bundle(self) -> DynamicBundle {
259        bundle![
260            self.transform,
261            self.mesh,
262            self.material,
263            self.pipeline,
264            InstanceVisibility::default(),
265        ]
266    }
267}
268
269/// Spawn one rendered instance that starts hidden, until its body is ready.
270pub(crate) fn hidden_instance_of(
271    world: &mut World,
272    mesh: RenderEntity,
273    material: RenderEntity,
274    transform: Transform,
275) -> RenderEntity {
276    let instance = instance_of(world, mesh, material, transform);
277    let Some((source, _)) = mesh_source(world) else {
278        return instance;
279    };
280    let Ok(scope) = world.scope(source.0) else {
281        return instance;
282    };
283    let _ = scope
284        .query_one::<&mut InstanceVisibility>(instance.0)
285        .map(|mut visibility| visibility.get().visible = false);
286    instance
287}
288
289/// One ball and the render instance that stands for it.
290///
291/// Every answer between the two worlds carries one: the render world knows the
292/// instance, the gameplay world knows the ball, and a named pair says which is
293/// which at both ends. Two bare entities in a tuple would compile the same way
294/// round either way.
295#[derive(Clone, Copy, Debug)]
296pub struct BallInstance {
297    /// The ball, an entity of the gameplay world.
298    pub ball: Entity,
299    /// The render instance that stands for it, an entity of the render world.
300    pub instance: RenderEntity,
301}
302
303/// Retire the instances the camera cannot see: despawn each one and answer
304/// with the ones that went, each naming the ball it stood for.
305///
306/// The render world owns the camera and the instances, so the visibility test
307/// happens here: the gameplay world only names the live instances and reacts
308/// to the answer by despawning their bodies. An instance that is already gone
309/// is skipped.
310///
311/// What it answers is a list rather than a stream of its own: a sweep is one
312/// request naming the whole scene, and the caller is waiting for exactly one
313/// answer, so the answer is the request's own.
314pub(crate) fn sweep(instances: Vec<BallInstance>) -> impl FnOnce(&mut World) -> Vec<BallInstance> {
315    move |world: &mut World| {
316        let frustum = {
317            let mut cameras = world.root_scope().query::<&Camera>();
318            let Some(camera) = cameras.iter().find(|camera| camera.active) else {
319                return Vec::new();
320            };
321            FrustumPlanes::from_clip_from_world(camera.clip_from_world())
322        };
323        let Some((source, _)) = mesh_source(world) else {
324            return Vec::new();
325        };
326        let mut outside = instances;
327        {
328            let Ok(scope) = world.scope(source.0) else {
329                return Vec::new();
330            };
331            // The list is reused as the outside set: every instance that is
332            // still in view is dropped from it.
333            outside.retain(|answer| {
334                let Ok(mut mesh) = scope.query_one::<&GpuMesh>(answer.instance.0) else {
335                    return false;
336                };
337                let model = scope
338                    .query_one::<&Transform>(answer.instance.0)
339                    .map_or(Affine3A::IDENTITY, |mut transform| {
340                        transform.get().compute_matrix()
341                    });
342                is_culled(mesh.get().aabb, &model, &frustum)
343            });
344        }
345        if outside.is_empty() {
346            return outside;
347        }
348        if let Ok(mut scope) = world.scope_mut(source.0) {
349            for answer in &outside {
350                let _ = scope.despawn(answer.instance.0);
351            }
352        }
353        outside
354    }
355}
356
357/// A job that despawns the instances it names.
358pub(crate) fn despawn_instances_job(instances: Vec<RenderEntity>) -> Job {
359    Box::new(move |world: &mut World| {
360        let Some((source, _)) = mesh_source(world) else {
361            return;
362        };
363        let Ok(mut scope) = world.scope_mut(source.0) else {
364            return;
365        };
366        for instance in instances {
367            let _ = scope.despawn(instance.0);
368        }
369    })
370}
371
372/// Show one instance: point it at a mesh and material, place it, and make it
373/// visible.
374///
375/// A frame's show is a request the gameplay world asked for, so it answers by
376/// the ball it was asked for rather than publishing into a stream: the caller
377/// names what it asked for and learns whether it happened. An instance that is
378/// gone, or assets that never landed, answers `false`, which is the difference
379/// between a world that has not answered and one that answered no.
380pub(crate) fn show(
381    world: &mut World,
382    instance: RenderEntity,
383    mesh: RenderEntity,
384    material: RenderEntity,
385    transform: &Transform,
386) -> bool {
387    let Some((source, _)) = mesh_source(world) else {
388        return false;
389    };
390    let Ok(scope) = world.scope(source.0) else {
391        return false;
392    };
393    let Ok(mesh) = scope
394        .query_one::<&GpuMesh>(mesh.0)
395        .map(|mut mesh| mesh.get().clone())
396    else {
397        return false;
398    };
399    let Ok(material) = scope
400        .query_one::<&GpuMaterial>(material.0)
401        .map(|mut material| material.get().clone())
402    else {
403        return false;
404    };
405    scope
406        .query_one::<(
407            &mut GpuMesh,
408            &mut GpuMaterial,
409            &mut Transform,
410            &mut InstanceVisibility,
411        )>(instance.0)
412        .map(|mut row| {
413            let (instance_mesh, instance_material, instance_transform, visibility) = row.get();
414            *instance_mesh = mesh;
415            *instance_material = material;
416            instance_transform.clone_from(transform);
417            visibility.visible = true;
418            true
419        })
420        .unwrap_or(false)
421}
422
423/// A texture of `size.0` by `size.1` RGBA pixels.
424///
425/// # Panics
426/// Panics when `pixels` does not hold exactly `size.0 * size.1` pixels.
427#[must_use]
428pub fn rgba_texture(
429    device: &wgpu::Device,
430    queue: &wgpu::Queue,
431    size: (u32, u32),
432    pixels: &[[u8; 4]],
433) -> wgpu::Texture {
434    assert_eq!(
435        pixels.len(),
436        size.0 as usize * size.1 as usize,
437        "the pixels do not fill the texture"
438    );
439    let texture = device.create_texture(&wgpu::TextureDescriptor {
440        label: Some("rgba_texture"),
441        size: wgpu::Extent3d {
442            width: size.0,
443            height: size.1,
444            depth_or_array_layers: 1,
445        },
446        mip_level_count: 1,
447        sample_count: 1,
448        dimension: wgpu::TextureDimension::D2,
449        format: wgpu::TextureFormat::Rgba8UnormSrgb,
450        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
451        view_formats: &[],
452    });
453    queue.write_texture(
454        texture.as_image_copy(),
455        pixels.as_flattened(),
456        wgpu::TexelCopyBufferLayout {
457            offset: 0,
458            bytes_per_row: Some(size.0 * core::mem::size_of::<[u8; 4]>() as u32),
459            rows_per_image: Some(size.1),
460        },
461        wgpu::Extent3d {
462            width: size.0,
463            height: size.1,
464            depth_or_array_layers: 1,
465        },
466    );
467    texture
468}
469
470/// The opaque white every generated mesh vertex carries; materials tint
471/// via textures, so the geometry itself stays colorless.
472pub const WHITE_COLOR: [u8; 4] = [255, 255, 255, 255];
473
474/// The sampler most textured materials want: linear filtering keeps flat
475/// colors clean under minification.
476#[must_use]
477pub fn linear_sampler() -> wgpu::SamplerDescriptor<'static> {
478    wgpu::SamplerDescriptor {
479        mag_filter: wgpu::FilterMode::Linear,
480        min_filter: wgpu::FilterMode::Linear,
481        ..Default::default()
482    }
483}
484
485/// A camera at `eye` looking at `target`, with a reverse-z infinite
486/// perspective of `fov_y_radians` fitted to `size`.
487#[must_use]
488pub fn camera_looking_at(
489    size: (u32, u32),
490    eye: Vec3,
491    target: Vec3,
492    fov_y_radians: f32,
493    near: f32,
494) -> Camera {
495    let aspect = size.0 as f32 / size.1.max(1) as f32;
496    Camera {
497        view_from_world: glam::camera::rh::view::look_at_mat4(eye, target, Vec3::Y),
498        clip_from_view: glam::camera::rh::proj::directx::perspective_infinite_reverse(
499            fov_y_radians,
500            aspect,
501            near,
502        ),
503        active: true,
504    }
505}
506
507/// A lat-long sphere: positions, uvs, opaque white colors and indices.
508///
509/// The triangles are wound CCW as seen from outside, matching the
510/// back-face culling of the unlit pipeline: stacks grow downward and
511/// slices counterclockwise, so each quad reads (top, top-next, bottom)
512/// and (top-next, bottom-next, bottom) against the outward direction.
513///
514/// # Panics
515///
516/// Panics when `slices` or `stacks` is zero: the sphere would divide by
517/// zero and carry NaN positions.
518#[must_use]
519pub fn uv_sphere(radius: f32, slices: usize, stacks: usize) -> OwnedLitMeshDesc {
520    assert!(slices > 0 && stacks > 0, "a sphere needs slices and stacks");
521    let count = (slices + 1) * (stacks + 1);
522    let mut positions = Vec::with_capacity(count);
523    let mut uvs = Vec::with_capacity(count);
524    let mut colors = Vec::with_capacity(count);
525    for stack in 0..=stacks {
526        let v = stack as f32 / stacks as f32;
527        let phi = v * core::f32::consts::PI;
528        let (sin_phi, cos_phi) = phi.sin_cos();
529        for slice in 0..=slices {
530            let u = slice as f32 / slices as f32;
531            let theta = u * core::f32::consts::TAU;
532            let (sin_theta, cos_theta) = theta.sin_cos();
533            positions.push([
534                radius * sin_phi * cos_theta,
535                radius * cos_phi,
536                radius * sin_phi * sin_theta,
537            ]);
538            uvs.push([u, v]);
539            colors.push(WHITE_COLOR);
540        }
541    }
542    let mut indices = Vec::with_capacity(slices * stacks * 6);
543    for stack in 0..stacks {
544        for slice in 0..slices {
545            let row = (stack * (slices + 1) + slice) as u32;
546            let next = row + slices as u32 + 1;
547            indices.extend_from_slice(&[row, row + 1, next, row + 1, next + 1, next]);
548        }
549    }
550    OwnedLitMeshDesc {
551        positions,
552        uvs: Some(uvs),
553        colors: Some(colors),
554        indices: Some(indices),
555    }
556}
557
558/// The unit right and up axes for each cuboid face, with the face's
559/// outward normal, so the six faces share one loop.
560const FACES: [([f32; 3], [f32; 3], [f32; 3]); 6] = [
561    ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
562    ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
563    ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]),
564    ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]),
565    ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
566    ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
567];
568
569/// An axis-aligned cuboid from its half extents: 24 vertices (four per
570/// face, so each face has clean uvs) and 36 indices, wound CCW as seen
571/// from outside.
572#[must_use]
573pub fn cuboid_mesh(half: Vec3) -> OwnedLitMeshDesc {
574    let mut positions = Vec::with_capacity(24);
575    let mut uvs = Vec::with_capacity(24);
576    let mut colors = Vec::with_capacity(24);
577    let mut indices = Vec::with_capacity(36);
578    for (normal, up, right) in FACES {
579        // The face spans the half extents projected onto its right and
580        // up axes; the normal is a unit axis, so its projection is one
581        // component.
582        let extent_right = right[0] * half.x + right[1] * half.y + right[2] * half.z;
583        let extent_up = up[0] * half.x + up[1] * half.y + up[2] * half.z;
584        let base = positions.len() as u32;
585        let face = |du: f32, dv: f32| -> [f32; 3] {
586            [
587                normal[0] * half.x + right[0] * du * extent_right + up[0] * dv * extent_up,
588                normal[1] * half.y + right[1] * du * extent_right + up[1] * dv * extent_up,
589                normal[2] * half.z + right[2] * du * extent_right + up[2] * dv * extent_up,
590            ]
591        };
592        let corners = [
593            face(-1.0, -1.0),
594            face(1.0, -1.0),
595            face(1.0, 1.0),
596            face(-1.0, 1.0),
597        ];
598        let corner_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
599        for (position, uv) in corners.into_iter().zip(corner_uvs) {
600            positions.push(position);
601            uvs.push(uv);
602            colors.push(WHITE_COLOR);
603        }
604        // Corners sit at (-1, -1) (1, -1) (1, 1) (-1, 1) in the face's
605        // (right, up) plane. When right crosses up along the normal that
606        // plain order reads CCW from outside; a face whose axes cross
607        // against the normal reads CW and takes the reversed order.
608        let plain = Vec3::from(right)
609            .cross(Vec3::from(up))
610            .dot(Vec3::from(normal))
611            > 0.0;
612        if plain {
613            indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
614        } else {
615            indices.extend_from_slice(&[base + 2, base + 1, base, base, base + 3, base + 2]);
616        }
617    }
618    OwnedLitMeshDesc {
619        positions,
620        uvs: Some(uvs),
621        colors: Some(colors),
622        indices: Some(indices),
623    }
624}