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
18const COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
20
21#[derive(Debug, thiserror::Error)]
23pub enum Error {
24 #[error("{0}")]
26 Config(String),
27 #[error("{0}")]
29 Gpu(String),
30 #[error(transparent)]
32 Json(#[from] serde_json::Error),
33 #[error(transparent)]
35 Gltf(#[from] gltf::Error),
36 #[error(transparent)]
38 Image(#[from] image::ImageError),
39 #[error("{0}")]
41 Animation(String),
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct Frame {
47 pub rgba: Vec<u8>,
49 pub width: u32,
51 pub height: u32,
53}
54
55pub 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
122pub 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
154pub 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 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 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#[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 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 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 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
326fn 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#[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
398pub 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
435fn 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
452fn 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
489fn with_mesh_source<R>(
491 world: &World,
492 source_entity: Entity,
493 f: impl FnOnce(&mut MeshSource, &Scope<'_>) -> R,
494) -> R {
495 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
510fn 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
537fn 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
564fn 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}