Skip to main content

unlit3d_cli/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::path::Path;
4use unlit_ecs::bundle;
5
6use glam::Mat4;
7use unlit_wgpu::resources::ResourceGraph;
8use unlit3d::gltf::LitGltf;
9use unlit3d::prelude::*;
10
11pub mod cli;
12mod config;
13
14pub use config::{
15    AnimationRef, CameraConfig, Config, DocumentConfig, OutputConfig, PlacementConfig,
16};
17
18/// The colour format every render target uses.
19const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
20
21/// Everything that can stop a render or a save.
22#[derive(Debug, thiserror::Error)]
23pub enum Error {
24    /// The configuration is incomplete or inconsistent.
25    #[error("{0}")]
26    Config(String),
27    /// The GPU could not provide an adapter or a device.
28    #[error("{0}")]
29    Gpu(String),
30    /// The configuration file is not valid JSON.
31    #[error(transparent)]
32    Json(#[from] serde_json::Error),
33    /// A glTF document could not be read.
34    #[error(transparent)]
35    Gltf(#[from] gltf::Error),
36    /// The image could not be encoded.
37    #[error(transparent)]
38    Image(#[from] image::ImageError),
39    /// A document has no animation under the requested name or index.
40    #[error("{0}")]
41    Animation(String),
42}
43
44/// The pixels a render produced.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct Frame {
47    /// The RGBA8 pixels: width * height * 4 bytes, row-major, no padding.
48    pub rgba: Vec<u8>,
49    /// The image width in pixels.
50    pub width: u32,
51    /// The image height in pixels.
52    pub height: u32,
53}
54
55/// Render the configured documents into an RGBA8 frame.
56///
57/// The output size is output.size, or output.render_size when size is absent.
58/// The render size defaults to the output size. The viewport is the render size
59/// scaled by output.scale's x and y factors and centred in the output. Nothing
60/// is resampled, because the one output-sized texture is what the scene draws
61/// into.
62///
63/// # Errors
64///
65/// Fails when the configuration is incomplete or inconsistent, a document or
66/// its animation cannot be read, or the GPU cannot provide an adapter.
67pub async fn render(config: &Config) -> Result<Frame, Error> {
68    let output_size = output_size(config)?;
69    let render_size = config.output.render_size.unwrap_or(output_size);
70    if render_size.0 == 0 || render_size.1 == 0 {
71        return Err(Error::Config(
72            "the render size must have non-zero dimensions".to_owned(),
73        ));
74    }
75    if !config.output.scale.0.is_finite()
76        || !config.output.scale.1.is_finite()
77        || config.output.scale.0 <= 0.0
78        || config.output.scale.1 <= 0.0
79    {
80        return Err(Error::Config(
81            "output.scale must be a pair of positive numbers".to_owned(),
82        ));
83    }
84    if !matches!(config.output.samples, 1 | 2 | 4 | 8) {
85        return Err(Error::Config(format!(
86            "output.samples is {}, but it must be 1, 2, 4 or 8",
87            config.output.samples
88        )));
89    }
90    if config.camera.view.is_some() != config.camera.projection.is_some() {
91        return Err(Error::Config(
92            "camera.view and camera.projection must be given together: the camera holds \
93             its view and projection apart, so one matrix alone is not a camera"
94                .to_owned(),
95        ));
96    }
97
98    let (device, queue, capabilities) = request_device().await?;
99    let (world, target, renderer) = build_world(
100        config,
101        &device,
102        &queue,
103        capabilities,
104        output_size,
105        render_size,
106    )?;
107
108    world
109        .root_scope()
110        .query_one::<&mut Renderer>(renderer)
111        .map(|mut renderer| renderer.get().render(&world))
112        .expect("the renderer entity exists");
113
114    let rgba = unlit_wgpu::readback::readback_texture(&device, &queue, &target);
115    Ok(Frame {
116        rgba,
117        width: output_size.0,
118        height: output_size.1,
119    })
120}
121
122/// Ask the backend for a device and a queue.
123///
124/// # Errors
125///
126/// Fails when no adapter is available or the device cannot be created.
127pub async fn request_device() -> Result<(wgpu::Device, wgpu::Queue, DeviceCapabilities), Error> {
128    let tier = DeviceTier::from_env();
129    let instance =
130        wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
131    let adapter = instance
132        .request_adapter(&wgpu::RequestAdapterOptions {
133            compatible_surface: None,
134            ..Default::default()
135        })
136        .await
137        .map_err(|error| Error::Gpu(format!("no graphics adapter available: {error}")))?;
138    let capabilities = tier.capabilities_of(&adapter);
139    let (device, queue) = adapter
140        .request_device(&wgpu::DeviceDescriptor {
141            label: Some("unlit3d_cli"),
142            required_features: wgpu::Features::empty(),
143            required_limits: tier.limits(&adapter.limits()),
144            default_queue: wgpu::QueueDescriptor { label: None },
145            experimental_features: wgpu::ExperimentalFeatures::disabled(),
146            memory_hints: wgpu::MemoryHints::Performance,
147            trace: wgpu::Trace::Off,
148        })
149        .await
150        .map_err(|error| Error::Gpu(format!("failed to request a device: {error}")))?;
151    Ok((device, queue, capabilities))
152}
153
154/// Build the world a render draws, and the target it draws into.
155///
156/// Returns the world, the output-sized colour target, and the renderer entity.
157/// The world holds the render context, the renderer, the frame's one pass, a
158/// mesh source, the configured documents and camera, and the pass's viewport.
159///
160/// # Errors
161///
162/// Fails when a document or its animation cannot be read.
163pub fn build_world(
164    config: &Config,
165    device: &wgpu::Device,
166    queue: &wgpu::Queue,
167    capabilities: DeviceCapabilities,
168    output_size: (u32, u32),
169    render_size: (u32, u32),
170) -> Result<(World, wgpu::Texture, Entity), Error> {
171    let mut world = World::new();
172    let context = spawn_context(
173        &mut world,
174        device.clone(),
175        queue.clone(),
176        ResourceGraph::new(),
177        capabilities,
178    );
179    let renderer = world
180        .root_scope_mut()
181        .spawn(bundle![Renderer::new(context)]);
182
183    let clear = config.output.clear;
184    // The frame is one pass: it states what it clears and where it draws, and
185    // the mesh source hangs under it, which is what records the source into it.
186    let load = RenderLoadOps {
187        color: wgpu::LoadOp::Clear(wgpu::Color {
188            r: clear[0],
189            g: clear[1],
190            b: clear[2],
191            a: clear[3],
192        }),
193        depth: depth_clear(),
194        stencil: stencil_clear(),
195    };
196    let pass = world.root_scope_mut().spawn(Pass {
197        load,
198        ..Pass::new(FramePassOrder(0))
199    });
200
201    let target = bind_target(
202        &world,
203        context,
204        pass,
205        device,
206        output_size,
207        config.output.samples,
208        config.output.depth,
209    );
210
211    let mut source = MeshSource::new(&world.root_scope(), context);
212    source.register_lit_family(&world.root_scope());
213    let source_entity = world
214        .scope_mut(pass)
215        .expect("the pass entity is its own scope")
216        .spawn(bundle![Source::new(source)]);
217
218    for document in &config.documents {
219        let gltf = LitGltf::load(&document.path)?;
220        let resources = with_mesh_source(&world, source_entity, |source, world| {
221            gltf.insert_resources(source, world)
222        });
223        let nodes = {
224            let mut scope = world
225                .scope_mut(source_entity)
226                .expect("the source entity is its own scope");
227            gltf.spawn_default_scene(&mut scope, &resources)
228        };
229        if let Some(placement) = &document.placement {
230            let transform = placement.transform();
231            for node in &nodes {
232                for &entity in &node.entities {
233                    let _ = world
234                        .root_scope()
235                        .query_one::<&mut Transform>(entity)
236                        .map(|mut current| *current.get() = transform.clone());
237                }
238            }
239        }
240        if let Some(animation) = &document.animation {
241            let index = animation_index(&gltf, animation)?;
242            gltf.apply_animation(
243                &world.root_scope(),
244                unlit3d::gltf::AnimationId::new(index),
245                document.time,
246                &nodes,
247            );
248        }
249    }
250
251    // The camera sits in the source's own subtree, which is what it frames.
252    world
253        .scope_mut(source_entity)
254        .expect("the source entity is its own scope")
255        .spawn(bundle![camera(&config.camera, render_size)]);
256
257    let viewport_width = render_size.0 as f32 * config.output.scale.0;
258    let viewport_height = render_size.1 as f32 * config.output.scale.1;
259    let viewport_x = (output_size.0 as f32 - viewport_width) * 0.5;
260    let viewport_y = (output_size.1 as f32 - viewport_height) * 0.5;
261    world
262        .root_scope()
263        .query_one::<&mut FramePass>(pass)
264        .map(|mut pass| {
265            pass.get().viewport =
266                Some(ViewportRect::new(viewport_width, viewport_height).at(viewport_x, viewport_y));
267        })
268        .expect("the pass entity exists");
269
270    Ok((world, target, renderer))
271}
272
273/// Build an empty offscreen world a host can serve over MCP.
274///
275/// The world holds a render context, a renderer, a mesh source, an input state
276/// and a default camera, and an offscreen colour target registered in the graph
277/// — both as a texture a screenshot can read back and as the view the renderer
278/// draws into. Returns the world and the renderer entity.
279#[must_use]
280pub fn offscreen_world(
281    device: wgpu::Device,
282    queue: wgpu::Queue,
283    capabilities: DeviceCapabilities,
284    size: (u32, u32),
285    samples: u32,
286    depth: bool,
287) -> (World, Entity) {
288    let mut world = World::new();
289    let context = spawn_context(
290        &mut world,
291        device.clone(),
292        queue.clone(),
293        ResourceGraph::new(),
294        capabilities,
295    );
296    let renderer = world
297        .root_scope_mut()
298        .spawn(bundle![Renderer::new(context)]);
299    // The frame is one pass, drawing into the offscreen target and clearing
300    // with the defaults; the mesh source hangs under it.
301    let pass = world.root_scope_mut().spawn(Pass::new(FramePassOrder(0)));
302    let mut source = MeshSource::new(&world.root_scope(), context);
303    source.register_lit_family(&world.root_scope());
304    let source_entity = world
305        .scope_mut(pass)
306        .expect("the pass entity is its own scope")
307        .spawn(bundle![Source::new(source)]);
308    // The input is shared by every source, so it lives in the subtree every
309    // scope sees.
310    let input = InputWriter::new();
311    {
312        let shared = world.shared();
313        let mut shared_view = world.scope_mut(shared).expect("the shared scope exists");
314        shared_view.spawn(InputFeed(input.clone()));
315    }
316
317    bind_target(&world, context, pass, &device, size, samples, depth);
318    // The camera sits in the source's own subtree, which is what it frames.
319    world
320        .scope_mut(source_entity)
321        .expect("the source entity is its own scope")
322        .spawn(bundle![default_camera(size)]);
323    (world, renderer)
324}
325
326/// The camera an offscreen world starts with.
327fn default_camera(size: (u32, u32)) -> Camera {
328    let aspect = size.0.max(1) as f32 / size.1.max(1) as f32;
329    let view_from_world = glam::camera::rh::view::look_at_mat4(
330        glam::Vec3::new(0.0, 1.0, 3.0),
331        glam::Vec3::ZERO,
332        glam::Vec3::Y,
333    );
334    let clip_from_view = glam::camera::rh::proj::directx::perspective_infinite_reverse(
335        60.0f32.to_radians(),
336        aspect,
337        0.1,
338    );
339    Camera {
340        view_from_world,
341        clip_from_view,
342        active: true,
343    }
344}
345
346/// Serve an empty offscreen world over MCP stdio.
347///
348/// The world lives on a render thread of its own; the transport thread only
349/// forwards commands to it, exactly as a windowed host forwards them to its
350/// event loop. A browser has no stdio to serve, so this exists only off wasm.
351///
352/// # Errors
353///
354/// Fails when no adapter is available, when the render thread cannot start, or
355/// when the transport fails.
356#[cfg(not(target_arch = "wasm32"))]
357pub fn serve_stdio_offscreen(size: (u32, u32), samples: u32, depth: bool) -> Result<(), Error> {
358    use std::sync::mpsc;
359    let (sender, receiver) = mpsc::channel::<unlit_ecs::mcp::McpCommand>();
360    let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
361    let render = std::thread::Builder::new()
362        .name("unlit3d-cli-mcp-render".to_owned())
363        .spawn(move || {
364            let built =
365                pollster::block_on(request_device()).map(|(device, queue, capabilities)| {
366                    offscreen_world(device, queue, capabilities, size, samples, depth)
367                });
368            match built {
369                Ok((mut world, _renderer)) => {
370                    let _ = ready_tx.send(Ok(()));
371                    while let Ok(command) = receiver.recv() {
372                        if command.wants_worlds() {
373                            command.answer_worlds(vec![unlit_ecs::mcp::WorldEntry {
374                                world: unlit_ecs::mcp::WorldId::Host,
375                                name: "host",
376                            }]);
377                        } else {
378                            command.dispatch(&mut world);
379                        }
380                    }
381                }
382                Err(error) => {
383                    let _ = ready_tx.send(Err(error.to_string()));
384                }
385            }
386        })
387        .map_err(|error| Error::Gpu(error.to_string()))?;
388    match ready_rx.recv() {
389        Ok(Ok(())) => {}
390        Ok(Err(message)) => return Err(Error::Gpu(message)),
391        Err(_) => return Err(Error::Gpu("the render thread stopped".to_owned())),
392    }
393    let served = unlit_ecs::mcp::serve_stdio_with(move |command| sender.send(command).is_ok());
394    let _ = render.join();
395    served.map_err(|error| Error::Gpu(error.to_string()))
396}
397
398/// Encode a frame to path, choosing the format from the extension.
399///
400/// png and webp write the RGBA8 pixels directly; jpeg has no alpha, so the
401/// colour is converted to RGB8 first.
402///
403/// # Errors
404///
405/// Fails when the extension names no supported format or the encoder fails.
406pub fn save(path: impl AsRef<Path>, frame: &Frame) -> Result<(), Error> {
407    let path = path.as_ref();
408    let format = image::ImageFormat::from_path(path)?;
409    if format == image::ImageFormat::Jpeg {
410        let mut rgb = Vec::with_capacity(frame.rgba.len() / 4 * 3);
411        for pixel in frame.rgba.as_chunks::<4>().0 {
412            rgb.extend_from_slice(&pixel[..3]);
413        }
414        image::save_buffer_with_format(
415            path,
416            &rgb,
417            frame.width,
418            frame.height,
419            image::ExtendedColorType::Rgb8,
420            format,
421        )?;
422    } else {
423        image::save_buffer_with_format(
424            path,
425            &frame.rgba,
426            frame.width,
427            frame.height,
428            image::ExtendedColorType::Rgba8,
429            format,
430        )?;
431    }
432    Ok(())
433}
434
435/// The output image size the configuration asks for.
436fn output_size(config: &Config) -> Result<(u32, u32), Error> {
437    let size = config
438        .output
439        .size
440        .or(config.output.render_size)
441        .ok_or_else(|| {
442            Error::Config("set output.size (or output.render_size) to the image size".to_owned())
443        })?;
444    if size.0 == 0 || size.1 == 0 {
445        return Err(Error::Config(
446            "the output size must have non-zero dimensions".to_owned(),
447        ));
448    }
449    Ok(size)
450}
451
452/// Give `pass` an output-sized colour target, and its optional depth and
453/// multisample attachments, and return the colour texture.
454///
455/// The target is the library's [`PersistentTarget`], so the texture is
456/// registered in the graph in its own right — a screenshot can name it and read
457/// it back — and the view depends on it, which keeps it alive.
458fn bind_target(
459    world: &World,
460    context: RenderContext,
461    pass: Entity,
462    device: &wgpu::Device,
463    size: (u32, u32),
464    samples: u32,
465    with_depth: bool,
466) -> wgpu::Texture {
467    let target = PersistentTarget::bind(
468        world,
469        context,
470        pass,
471        device,
472        size,
473        TargetConfig {
474            format: COLOR_FORMAT,
475            samples,
476            with_depth,
477            ..TargetConfig::default()
478        },
479    )
480    .expect("the pass and the frame's resource graph exist");
481    context
482        .graph(&world.root_scope())
483        .get()
484        .get(&target.texture)
485        .cloned()
486        .expect("the target texture is a resource node")
487}
488
489/// Run f with the mesh source mounted at source_entity, borrowing both at once.
490fn with_mesh_source<R>(
491    world: &World,
492    source_entity: Entity,
493    f: impl FnOnce(&mut MeshSource, &Scope<'_>) -> R,
494) -> R {
495    // The scope is the source's own, so what its closure reads is the subtree
496    // the source draws.
497    let scope = world
498        .scope(source_entity)
499        .expect("the source entity is alive");
500    let mut source = scope
501        .query_one::<&mut Source>(source_entity)
502        .expect("the mesh source entity exists");
503    let mesh = source
504        .get()
505        .as_mut::<MeshSource>()
506        .expect("the source is a MeshSource");
507    f(mesh, &scope)
508}
509
510/// Build the camera from the configuration and the render size's aspect ratio.
511fn camera(config: &CameraConfig, render_size: (u32, u32)) -> Camera {
512    if let (Some(view), Some(projection)) = (config.view, config.projection) {
513        return Camera {
514            view_from_world: Mat4::from_cols_array(&view),
515            clip_from_view: Mat4::from_cols_array(&projection),
516            active: true,
517        };
518    }
519    let aspect = render_size.0 as f32 / render_size.1.max(1) as f32;
520    let projection = glam::camera::rh::proj::directx::perspective_infinite_reverse(
521        config.fov_y.to_radians(),
522        aspect,
523        config.z_near,
524    );
525    let view = glam::camera::rh::view::look_at_mat4(
526        glam::Vec3::from(config.eye),
527        glam::Vec3::from(config.target),
528        glam::Vec3::from(config.up),
529    );
530    Camera {
531        view_from_world: view,
532        clip_from_view: projection,
533        active: true,
534    }
535}
536
537/// Resolve the animation a document reference names.
538fn animation_index(document: &LitGltf, reference: &AnimationRef) -> Result<usize, Error> {
539    match reference {
540        AnimationRef::Index(index) => {
541            if *index < document.animation_count() {
542                Ok(*index)
543            } else {
544                Err(Error::Animation(format!(
545                    "animation index {index} is out of range: the document has {} animations",
546                    document.animation_count()
547                )))
548            }
549        }
550        AnimationRef::Name(name) => (0..document.animation_count())
551            .find(|&animation| {
552                document.animation_name(unlit3d::gltf::AnimationId::new(animation))
553                    == Some(name.as_str())
554            })
555            .ok_or_else(|| {
556                Error::Animation(format!(
557                    "the document has no animation named {name}; it has {}",
558                    animation_list(document)
559                ))
560            }),
561    }
562}
563
564/// The comma-separated animation names of a document.
565fn animation_list(document: &LitGltf) -> String {
566    let mut list = String::new();
567    for animation in 0..document.animation_count() {
568        if animation > 0 {
569            list.push_str(", ");
570        }
571        match document.animation_name(unlit3d::gltf::AnimationId::new(animation)) {
572            Some(name) => list.push_str(name),
573            None => list.push_str(&format!("#{animation}")),
574        }
575    }
576    if list.is_empty() {
577        list.push_str("none");
578    }
579    list
580}