1#[cfg(not(target_arch = "wasm32"))]
2use std::sync::Arc;
3
4use glam::Vec2;
5use web_time::Instant;
6use wgpu::{CommandBuffer, CommandEncoder, Texture, TextureFormat, TextureView};
7#[cfg(not(target_arch = "wasm32"))]
8use winit::window::Window;
9
10use loam_render::device::GpuContext;
11use loam_render::present::Presenter;
12#[cfg(not(target_arch = "wasm32"))]
13use loam_runtime::host::HostConfig;
14use loam_runtime::host::HostError;
15use loam_runtime::{Eye, Publication, PublishError, Records, Session, Stores};
16use loam_time::{frame_trace, FixedTimestep};
17
18use super::app::{CaptureControl, FrameHook, InputHook, Posts, Redraw, SessionApp};
19use super::cursor::CursorCapture;
20#[cfg(feature = "egui")]
21use super::debug_layer::DebugLayer;
22use super::input::InputMap;
23
24pub struct Target<'a> {
25 pub view: &'a TextureView,
26 pub texture: &'a Texture,
27 pub format: TextureFormat,
28 pub size: (u32, u32),
29}
30
31pub(crate) fn failed(error: impl std::fmt::Display) -> HostError {
32 HostError::Host(error.to_string())
33}
34
35#[derive(Clone, Copy, Default, PartialEq)]
36struct Shown {
37 content: u64,
38 eye: Eye,
39 size: (u32, u32),
40 background: wgpu::Color,
41}
42
43fn redraws(policy: Redraw, drawn: Option<&Shown>, next: &Shown, forced: bool) -> bool {
44 policy == Redraw::EveryFrame || forced || drawn != Some(next)
45}
46
47struct Inner<A: Stores> {
48 session: Session<A>,
49 records: Records,
50 timestep: Option<FixedTimestep>,
51 callbacks: Vec<CommandBuffer>,
52 input: InputMap,
53 cursor: CursorCapture,
54 app: SessionApp<A>,
55 posts: Posts,
56 no_views_reported: bool,
57 shown: Shown,
58 drawn: Option<Shown>,
59 #[cfg(test)]
60 presented: Option<Eye>,
61 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
62 capture: crate::capture::Capture,
63}
64
65pub(crate) struct Frame<A: Stores> {
66 presenter: Option<Presenter>,
67 #[cfg(feature = "egui")]
68 layer: Option<DebugLayer>,
69 inner: Inner<A>,
70 trace: Option<frame_trace::Scope>,
71}
72
73impl<A: Stores> Frame<A> {
74 pub(crate) fn new(session: Session<A>, app: SessionApp<A>) -> Result<Self, HostError> {
75 let sim = session.config();
76 sim.check().map_err(failed)?;
77 let timestep = sim
78 .dt()
79 .map(|_| FixedTimestep::new(sim.fixed_hz).with_max_catch_up(sim.max_ticks_per_frame));
80 Ok(Self {
81 presenter: None,
82 #[cfg(feature = "egui")]
83 layer: None,
84 inner: Inner {
85 session,
86 records: Records::default(),
87 timestep,
88 callbacks: Vec::new(),
89 input: InputMap::default(),
90 cursor: CursorCapture::new(),
91 app,
92 posts: Posts::default(),
93 no_views_reported: false,
94 shown: Shown::default(),
95 drawn: None,
96 #[cfg(test)]
97 presented: None,
98 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
99 capture: crate::capture::Capture::new(),
100 },
101 trace: None,
102 })
103 }
104
105 pub(crate) fn redraw(&self) -> Redraw {
106 self.inner.app.redraw
107 }
108
109 pub(crate) fn invalidate(&mut self) {
110 self.inner.drawn = None;
111 }
112
113 #[cfg(not(target_arch = "wasm32"))]
114 pub(crate) fn config(&self) -> &HostConfig {
115 &self.inner.app.config
116 }
117
118 pub(crate) fn app_mut(&mut self) -> &mut SessionApp<A> {
119 &mut self.inner.app
120 }
121
122 #[cfg(any(all(target_arch = "wasm32", feature = "measure"), test))]
123 pub(crate) fn phase_error(&self) -> Option<loam_runtime::PhaseError> {
124 self.inner.session.phase_error()
125 }
126
127 #[cfg(any(target_arch = "wasm32", test))]
128 pub(crate) fn posts(&self) -> impl Iterator<Item = (&'static str, &[f32])> {
129 self.inner.posts.iter()
130 }
131
132 #[cfg(not(target_arch = "wasm32"))]
133 pub(crate) fn input_mut(&mut self) -> &mut InputMap {
134 &mut self.inner.input
135 }
136
137 pub(crate) fn alt(&mut self, index: usize, pressed: bool) {
138 self.inner.cursor.alt(index, pressed);
139 }
140
141 pub(crate) fn focus(&mut self, focused: bool) {
142 self.inner.cursor.focus(focused);
143 if !focused {
144 self.inner.input.release_all();
145 }
146 }
147
148 pub(crate) fn cursor_applied(&mut self, locked: bool) {
149 self.inner.cursor.applied(locked);
150 self.inner.input.set_cursor_locked(locked);
151 }
152
153 pub(crate) fn cursor_locked(&self) -> bool {
154 self.inner.cursor.locked()
155 }
156
157 pub(crate) fn take_cursor_request(&mut self) -> Option<bool> {
158 self.inner.cursor.take_request()
159 }
160
161 #[cfg(target_arch = "wasm32")]
162 pub(crate) fn apply_message(
163 &mut self,
164 message: &crate::wasm::input_queue::InputMessage,
165 consumed: bool,
166 ) {
167 super::input::apply(
168 &mut self.inner.input,
169 &self.inner.app.config.bindings,
170 message,
171 consumed,
172 );
173 }
174
175 #[cfg(target_arch = "wasm32")]
176 pub(crate) fn observe_message(&mut self, message: &crate::wasm::input_queue::InputMessage) {
177 use crate::wasm::input_queue::InputMessage;
178
179 match message {
180 InputMessage::Key {
181 code,
182 pressed,
183 repeat,
184 ..
185 } if !repeat || !pressed => match code.as_str() {
186 "AltLeft" => self.alt(0, *pressed),
187 "AltRight" => self.alt(1, *pressed),
188 _ => {}
189 },
190 InputMessage::Focus(focused) => self.focus(*focused),
191 InputMessage::PointerLockChanged { locked, released } => {
192 self.cursor_applied(*locked);
193 if *released {
194 self.inner.cursor.released();
195 }
196 }
197 _ => {}
198 }
199 }
200
201 #[cfg(not(target_arch = "wasm32"))]
202 pub(crate) fn action(&mut self, key: loam_runtime::Key, pressed: bool, consumed: bool) {
203 self.inner
204 .input
205 .host_action(&self.inner.app.config.bindings, key, pressed, consumed);
206 }
207
208 #[cfg(feature = "egui")]
209 pub(crate) fn layer(&self) -> Option<&DebugLayer> {
210 self.layer.as_ref()
211 }
212
213 pub(crate) fn reset_clock(&mut self, now: Instant) {
214 if let Some(timestep) = self.inner.timestep.as_mut() {
215 timestep.reset_clock(now);
216 }
217 self.inner.app.pacer.reset();
218 }
219
220 pub(crate) fn attach(
221 &mut self,
222 gpu: &GpuContext,
223 format: TextureFormat,
224 #[cfg(not(target_arch = "wasm32"))] window: Option<Arc<Window>>,
225 size: (u32, u32),
226 scale: f32,
227 ) -> Result<(), HostError> {
228 let mut presenter = Presenter::new(format).map_err(failed)?;
229 for pass in self.inner.app.passes.drain(..) {
230 presenter.register_pass(pass).map_err(failed)?;
231 }
232 #[cfg(all(not(feature = "egui"), not(target_arch = "wasm32")))]
233 let _ = window;
234 #[cfg(feature = "egui")]
235 if self.inner.app.debug_layer {
236 let layer = match self.layer.clone() {
237 Some(layer) => layer,
238 #[cfg(not(target_arch = "wasm32"))]
239 None => match window {
240 Some(window) => DebugLayer::on_window(gpu, format, window, size),
241 None => DebugLayer::offscreen(gpu, format, size, scale),
242 },
243 #[cfg(target_arch = "wasm32")]
244 None => DebugLayer::offscreen(gpu, format, size, scale),
245 };
246 presenter.register_pass(layer.pass()).map_err(failed)?;
247 self.layer = Some(layer);
248 }
249 presenter.attach(gpu).map_err(failed)?;
250 self.presenter = Some(presenter);
251 self.resize(size.0, size.1, scale);
252 Ok(())
253 }
254
255 pub(crate) fn recover(&mut self, gpu: &GpuContext) -> Result<(), HostError> {
256 self.invalidate();
257 if let Some(presenter) = self.presenter.as_mut() {
258 presenter.attach(gpu).map_err(failed)?;
259 }
260 Ok(())
261 }
262
263 pub(crate) fn resize(&mut self, width: u32, height: u32, scale: f32) {
264 self.invalidate();
265 self.inner.input.resize(width, height, scale);
266 #[cfg(feature = "egui")]
267 if let Some(layer) = self.layer.as_ref() {
268 layer.resize(width, height, scale);
269 }
270 }
271
272 pub(crate) fn step(
273 &mut self,
274 gpu: &GpuContext,
275 target: &Target<'_>,
276 now: Instant,
277 finish: impl FnMut(&mut CommandEncoder),
278 ) -> Result<(), HostError> {
279 if self.update(gpu, target.size, now)? {
280 self.draw(gpu, target, now, finish)?;
281 }
282 Ok(())
283 }
284
285 pub(crate) fn update(
286 &mut self,
287 gpu: &GpuContext,
288 size: (u32, u32),
289 now: Instant,
290 ) -> Result<bool, HostError> {
291 self.inner.posts.clear();
292 self.end_trace();
293 let outcome = self.updated(gpu, size, now);
294 if let Err(error) = &outcome {
295 tracing::error!("frame failed: {error}");
296 }
297 outcome
298 }
299
300 fn updated(
301 &mut self,
302 gpu: &GpuContext,
303 size: (u32, u32),
304 now: Instant,
305 ) -> Result<bool, HostError> {
306 gpu.device.poll(wgpu::PollType::Poll).map_err(failed)?;
307 if let Some(error) = gpu.take_uncaptured_error() {
308 return Err(failed(error));
309 }
310 let Some(presenter) = self.presenter.as_mut() else {
311 return Ok(false);
312 };
313 if size.0 == 0 || size.1 == 0 {
314 return Ok(false);
315 }
316 frame_trace::begin_frame();
317 self.trace = Some(frame_trace::scope("frame"));
318 let drawn = self.inner.update(
319 gpu,
320 size,
321 now,
322 presenter,
323 #[cfg(feature = "egui")]
324 self.layer.as_ref(),
325 );
326 if !matches!(drawn, Ok(true)) {
327 self.end_trace();
328 }
329 drawn
330 }
331
332 pub(crate) fn draw(
333 &mut self,
334 gpu: &GpuContext,
335 target: &Target<'_>,
336 now: Instant,
337 finish: impl FnMut(&mut CommandEncoder),
338 ) -> Result<(), HostError> {
339 let outcome = match self.presenter.as_mut() {
340 Some(presenter) => self.inner.draw(
341 gpu,
342 target,
343 now,
344 finish,
345 presenter,
346 #[cfg(feature = "egui")]
347 self.layer.as_ref(),
348 ),
349 None => Ok(()),
350 };
351 self.end_trace();
352 if let Err(error) = &outcome {
353 tracing::error!("frame failed: {error}");
354 }
355 outcome
356 }
357
358 fn end_trace(&mut self) {
359 if self.trace.is_some() {
360 self.trace = None;
361 frame_trace::end_frame();
362 }
363 }
364}
365
366impl<A: Stores> Inner<A> {
367 fn advance(
368 &mut self,
369 now: Instant,
370 size: (u32, u32),
371 #[cfg(feature = "egui")] ui: Option<&loam_egui::egui::Context>,
372 ) -> Result<(), HostError> {
373 let controls = self.app.console.take_controls();
374 if let Some(fps) = controls.target_fps {
375 self.app.pacer.set_target_fps(fps);
376 }
377 if controls.vsync.is_some() {
378 self.app.vsync = controls.vsync;
379 }
380 let Inner {
381 session,
382 input,
383 timestep,
384 app,
385 ..
386 } = self;
387 let aspect = input
388 .display_aspect()
389 .unwrap_or(size.0 as f32 / size.1 as f32);
390 session.views_mut().root_mut().eye.aspect = aspect;
391 let gathered = input.take();
392 let sender = app.commands.sender();
393 let was_faulted = session.faulted_phase().is_some();
394 if app
395 .fault_recovery
396 .is_some_and(|action| was_faulted && gathered.pressed(action))
397 {
398 sender.reset();
399 }
400 if let Some(hook) = app.input.as_mut() {
401 hook(&InputHook {
402 session,
403 input: &gathered,
404 #[cfg(feature = "egui")]
405 ui,
406 size,
407 sender: &sender,
408 });
409 }
410 let result = (|| {
411 {
412 let _dispatch = frame_trace::scope("dispatch");
413 app.boundary(session, gathered)?;
414 }
415 session.views_mut().root_mut().eye.aspect = aspect;
416 if let Some(timestep) = timestep.as_mut() {
417 if was_faulted && session.faulted_phase().is_none() {
418 timestep.reset_clock(now);
419 }
420 let _simulation = frame_trace::scope("simulation");
421 for _ in timestep.advance(now) {
422 session.tick()?;
423 }
424 }
425 session.views_mut().root_mut().eye.aspect = aspect;
426 Ok(())
427 })();
428 input.reclaim(session.take_input());
429 result
430 }
431
432 fn prepare(
433 &mut self,
434 now: Instant,
435 size: (u32, u32),
436 #[cfg(feature = "egui")] ui: Option<&loam_egui::egui::Context>,
437 ) -> Result<(Publication, Eye), HostError> {
438 self.advance(
439 now,
440 size,
441 #[cfg(feature = "egui")]
442 ui,
443 )?;
444 {
445 let _publication = frame_trace::scope("publication");
446 let alpha = self.timestep.as_ref().map_or(1.0, FixedTimestep::alpha);
447 self.records
448 .publish_at(&mut self.session, alpha)
449 .map_err(|error| match error {
450 PublishError::Borrowed => {
451 HostError::Host("publication buffer is borrowed".into())
452 }
453 PublishError::Phase(error) => HostError::Phase(error),
454 })?;
455 }
456 self.session.views_mut().root_mut().eye.aspect = self
457 .input
458 .display_aspect()
459 .unwrap_or(size.0 as f32 / size.1 as f32);
460 let root = self.session.views().root();
461 let eye = self
462 .session
463 .views()
464 .get(root)
465 .map(|view| view.eye)
466 .ok_or_else(|| HostError::Host("the root view is missing".into()))?;
467 let records = self
468 .records
469 .lend()
470 .ok_or_else(|| HostError::Host("the publication buffer is unavailable".into()))?;
471 if records.views.is_empty() && !self.no_views_reported {
472 self.no_views_reported = true;
473 tracing::warn!("the session published no views, so nothing is drawn");
474 self.app
475 .console
476 .note("the session published no views, so nothing is drawn".to_string());
477 }
478 Ok((records, eye))
479 }
480
481 fn update(
482 &mut self,
483 gpu: &GpuContext,
484 size: (u32, u32),
485 now: Instant,
486 presenter: &mut Presenter,
487 #[cfg(feature = "egui")] layer: Option<&DebugLayer>,
488 ) -> Result<bool, HostError> {
489 #[cfg(feature = "egui")]
490 let context = layer.map(DebugLayer::begin);
491 let faulted_before = self.session.faulted_phase();
492 let scene_before = self.session.scene();
493 let prepared = self.prepare(
494 now,
495 size,
496 #[cfg(feature = "egui")]
497 context.as_ref(),
498 );
499 let mut requested = false;
500 let terminal = match prepared {
501 Ok((records, eye)) => {
502 {
503 let sender = self.app.commands.sender();
504 let (hook, captures, cursor, background, posts) = (
505 self.app.frame.as_mut(),
506 &mut self.app.captures,
507 &mut self.cursor,
508 &mut self.app.background,
509 &mut self.posts,
510 );
511 if let Some(hook) = hook {
512 hook(&mut FrameHook {
513 session: &self.session,
514 published: &records,
515 alpha: records.alpha,
516 sections: presenter.sections(),
517 #[cfg(feature = "egui")]
518 ui: context.as_ref(),
519 size,
520 sender: &sender,
521 capture: CaptureControl::new(captures),
522 cursor,
523 background,
524 posts,
525 redraw: &mut requested,
526 });
527 }
528 }
529 let _presentation = frame_trace::scope("presentation");
530 #[cfg(test)]
531 {
532 self.presented = Some(eye);
533 }
534 presenter.upload(
535 &gpu.device,
536 &gpu.queue,
537 &eye,
538 Vec2::new(size.0 as f32, size.1 as f32),
539 &records.views,
540 );
541 self.shown.content = records.content();
542 self.shown.eye = eye;
543 self.records.release(records);
544 None
545 }
546 Err(error) if self.session.faulted_phase().is_some() => {
547 if faulted_before.is_none() || self.session.scene() != scene_before {
548 tracing::error!("session frame failed: {error}");
549 self.cursor.suspend();
550 }
551 if let Some(timestep) = self.timestep.as_mut() {
552 timestep.reset_clock(now);
553 }
554 None
555 }
556 Err(error) => Some(error),
557 };
558 #[cfg(feature = "egui")]
559 if let Some(context) = context.as_ref() {
560 loam_egui::ConsoleUi::ui(self.app.console.ui_mut(), context);
561 }
562 if let Some(driver) = self.app.script.as_mut() {
563 driver.advance_console(&mut self.app.console);
564 }
565 let dispatched = self.app.console.dispatch_pending();
566 #[cfg(feature = "egui")]
567 if let Some(layer) = layer {
568 layer.finish();
569 }
570 if let Some(error) = terminal {
571 return Err(error);
572 }
573 #[cfg(feature = "egui")]
574 let layered = layer.is_some();
575 #[cfg(not(feature = "egui"))]
576 let layered = false;
577 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
578 let capturing = !self.app.captures.is_empty() || self.capture.should_capture(now);
579 #[cfg(not(all(feature = "capture", not(target_arch = "wasm32"))))]
580 let capturing = false;
581 self.shown.size = size;
582 self.shown.background = self.app.background;
583 let draw = redraws(
584 self.app.redraw,
585 self.drawn.as_ref(),
586 &self.shown,
587 requested || dispatched || layered || capturing,
588 );
589 if draw {
591 self.drawn = None;
592 }
593 Ok(draw)
594 }
595
596 fn draw(
597 &mut self,
598 gpu: &GpuContext,
599 target: &Target<'_>,
600 now: Instant,
601 mut finish: impl FnMut(&mut CommandEncoder),
602 presenter: &mut Presenter,
603 #[cfg(feature = "egui")] layer: Option<&DebugLayer>,
604 ) -> Result<(), HostError> {
605 #[cfg(not(all(feature = "capture", not(target_arch = "wasm32"))))]
606 let _ = now;
607 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
608 let (wants_pre, wants_post) = self.capture_intent(now);
609 let mut encoder = gpu
610 .device
611 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
612 label: Some("loam-app::session"),
613 });
614 presenter
615 .record_scene(
616 &gpu.device,
617 &mut encoder,
618 target.view,
619 target.size,
620 self.app.background,
621 )
622 .map_err(failed)?;
623 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
624 let pre = if wants_pre {
625 finish(&mut encoder);
626 self.record_capture(&gpu.device, &mut encoder, target)
627 } else {
628 None
629 };
630 presenter
631 .record_overlays(&mut encoder, target.view, target.size)
632 .map_err(failed)?;
633 finish(&mut encoder);
634 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
635 let post = wants_post
636 .then(|| self.record_capture(&gpu.device, &mut encoder, target))
637 .flatten();
638 #[cfg(feature = "egui")]
639 if let Some(layer) = layer {
640 layer.take_callbacks(&mut self.callbacks);
641 }
642 gpu.queue
643 .submit(self.callbacks.drain(..).chain(Some(encoder.finish())));
644 presenter.after_submit();
645 crate::trace::record_presentation(presenter.sections(), presenter.uploads());
646 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
647 self.consume_capture(&gpu.device, now, pre, post);
648 self.drawn = Some(self.shown);
649 Ok(())
650 }
651
652 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
653 fn capture_intent(&mut self, now: Instant) -> (bool, bool) {
654 if !self.app.captures.is_empty() {
655 let requests = std::mem::take(&mut self.app.captures);
656 for line in self.capture.apply_requests(requests) {
657 tracing::info!("{line}");
658 }
659 }
660 if !self.capture.should_capture(now) {
661 return (false, false);
662 }
663 (self.capture.wants_pre(), self.capture.wants_post())
664 }
665
666 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
667 fn record_capture(
668 &self,
669 device: &wgpu::Device,
670 encoder: &mut CommandEncoder,
671 target: &Target<'_>,
672 ) -> Option<crate::capture::TextureReadback> {
673 match crate::capture::record_texture_rgba(
674 device,
675 encoder,
676 target.texture,
677 target.size.0,
678 target.size.1,
679 target.format,
680 ) {
681 Ok(readback) => Some(readback),
682 Err(error) => {
683 tracing::error!("capture: copy failed: {error:#}");
684 None
685 }
686 }
687 }
688
689 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
690 fn consume_capture(
691 &mut self,
692 device: &wgpu::Device,
693 now: Instant,
694 pre: Option<crate::capture::TextureReadback>,
695 post: Option<crate::capture::TextureReadback>,
696 ) {
697 for (is_pre, readback) in [(true, pre), (false, post)] {
698 let Some(readback) = readback else {
699 continue;
700 };
701 match readback.read(device) {
702 Ok(image) => {
703 if let Err(error) = self.capture.consume_frame(
704 is_pre,
705 image.rgba,
706 image.width,
707 image.height,
708 now,
709 ) {
710 tracing::error!("capture: write failed: {error:#}");
711 }
712 }
713 Err(error) => tracing::error!("capture: readback failed: {error:#}"),
714 }
715 }
716 self.capture.advance_frame(now);
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
723 use std::sync::{Arc, Mutex};
724 use std::time::Duration;
725
726 use glam::Vec3;
727 use loam_math::EuclideanR3;
728 use loam_render::device::FeatureRequest;
729 use loam_render::pass::{FrameFormat, FramePass, FrameTarget, PassStage};
730 use loam_runtime::{
731 ActionId, Bindings, Command, DomainBuilder, HostConfig, Identity3, Key, LogCapacity,
732 Material, Phase, Pose, PreparedGeometry, SimConfig, SpawnBundle, Tick, ViewSpec,
733 };
734 use wgpu::{
735 BackendOptions, Backends, Extent3d, Instance, InstanceDescriptor, NoopBackendOptions,
736 TextureDescriptor, TextureDimension, TextureUsages, TextureViewDescriptor,
737 };
738 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
739 use wgpu::{
740 Color, ColorTargetState, ColorWrites, FragmentState, LoadOp, MultisampleState, Operations,
741 PrimitiveState, RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline,
742 RenderPipelineDescriptor, ShaderModuleDescriptor, ShaderSource, StoreOp, VertexState,
743 };
744
745 use super::*;
746 use crate::args::Args;
747 use crate::session::CursorPolicy;
748
749 const FORMAT: TextureFormat = TextureFormat::Rgba8UnormSrgb;
750 const SIZE: (u32, u32) = (64, 48);
751 loam_runtime::stores! {
752 #[derive(Default)]
753 pub struct Bare {}
754 }
755
756 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
757 const CAPTURE_FPS: u16 = 60;
758
759 struct Probe {
760 recorded: Arc<AtomicU32>,
761 rebuilt: Arc<AtomicU32>,
762 }
763
764 impl FramePass for Probe {
765 fn name(&self) -> &'static str {
766 "probe"
767 }
768
769 fn stage(&self) -> PassStage {
770 PassStage::Scene
771 }
772
773 fn record(
774 &mut self,
775 _encoder: &mut CommandEncoder,
776 _target: &FrameTarget<'_>,
777 ) -> anyhow::Result<()> {
778 self.recorded.fetch_add(1, Ordering::Relaxed);
779 Ok(())
780 }
781
782 fn attach(&mut self, _gpu: &GpuContext, _frame: FrameFormat) -> anyhow::Result<()> {
783 self.rebuilt.fetch_add(1, Ordering::Relaxed);
784 Ok(())
785 }
786 }
787
788 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
789 struct Paint {
790 name: &'static str,
791 color: Color,
792 overlay: bool,
793 pipeline: Option<RenderPipeline>,
794 }
795
796 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
797 impl FramePass for Paint {
798 fn name(&self) -> &'static str {
799 self.name
800 }
801
802 fn stage(&self) -> PassStage {
803 if self.overlay {
804 PassStage::Overlay
805 } else {
806 PassStage::Scene
807 }
808 }
809
810 fn record(
811 &mut self,
812 encoder: &mut CommandEncoder,
813 target: &FrameTarget<'_>,
814 ) -> anyhow::Result<()> {
815 let Some(pipeline) = self.pipeline.as_ref() else {
816 anyhow::bail!("paint recorded before attach");
817 };
818 let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
819 label: Some(self.name),
820 color_attachments: &[Some(RenderPassColorAttachment {
821 view: target.color,
822 depth_slice: None,
823 resolve_target: None,
824 ops: Operations {
825 load: LoadOp::Load,
826 store: StoreOp::Store,
827 },
828 })],
829 depth_stencil_attachment: None,
830 timestamp_writes: None,
831 occlusion_query_set: None,
832 });
833 pass.set_pipeline(pipeline);
834 pass.draw(0..3, 0..1);
835 Ok(())
836 }
837
838 fn attach(&mut self, gpu: &GpuContext, frame: FrameFormat) -> anyhow::Result<()> {
839 let Color { r, g, b, a } = self.color;
840 let source = format!(
841 r#"
842@vertex
843fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {{
844 var corners = array<vec2<f32>, 3>(
845 vec2<f32>(-1.0, -1.0),
846 vec2<f32>(3.0, -1.0),
847 vec2<f32>(-1.0, 3.0),
848 );
849 return vec4<f32>(corners[index], 0.0, 1.0);
850}}
851
852@fragment
853fn fragment() -> @location(0) vec4<f32> {{
854 return vec4<f32>({r:?}, {g:?}, {b:?}, {a:?});
855}}
856"#
857 );
858 let shader = gpu.device.create_shader_module(ShaderModuleDescriptor {
859 label: Some(self.name),
860 source: ShaderSource::Wgsl(source.into()),
861 });
862 self.pipeline = Some(
863 gpu.device
864 .create_render_pipeline(&RenderPipelineDescriptor {
865 label: Some(self.name),
866 layout: None,
867 vertex: VertexState {
868 module: &shader,
869 entry_point: Some("vertex"),
870 compilation_options: Default::default(),
871 buffers: &[],
872 },
873 primitive: PrimitiveState::default(),
874 depth_stencil: None,
875 multisample: MultisampleState::default(),
876 fragment: Some(FragmentState {
877 module: &shader,
878 entry_point: Some("fragment"),
879 compilation_options: Default::default(),
880 targets: &[Some(ColorTargetState {
881 format: frame.color,
882 blend: None,
883 write_mask: ColorWrites::ALL,
884 })],
885 }),
886 multiview: None,
887 cache: None,
888 }),
889 );
890 Ok(())
891 }
892 }
893
894 fn noop_gpu() -> GpuContext {
895 let instance = Instance::new(&InstanceDescriptor {
896 backends: Backends::NOOP,
897 backend_options: BackendOptions {
898 noop: NoopBackendOptions { enable: true },
899 ..Default::default()
900 },
901 ..Default::default()
902 });
903 pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
904 .expect("the noop backend always yields a context")
905 }
906
907 fn offscreen(gpu: &GpuContext) -> Texture {
908 gpu.device.create_texture(&TextureDescriptor {
909 label: Some("session frame probe"),
910 size: Extent3d {
911 width: SIZE.0,
912 height: SIZE.1,
913 depth_or_array_layers: 1,
914 },
915 mip_level_count: 1,
916 sample_count: 1,
917 dimension: TextureDimension::D2,
918 format: FORMAT,
919 usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC,
920 view_formats: &[],
921 })
922 }
923
924 fn bare(app: SessionApp<Bare>) -> Frame<Bare> {
925 Frame::new(Session::new(Bare::default(), SimConfig::default()), app)
926 .expect("the frame accepted the simulation config")
927 }
928
929 fn host<A: Stores>(name: &'static str) -> SessionApp<A> {
930 quiet(SessionApp::with_args(
931 HostConfig::new(name, Bindings::new()),
932 Args::default(),
933 ))
934 }
935
936 fn quiet<A: Stores>(app: SessionApp<A>) -> SessionApp<A> {
937 app.debug_layer(false)
938 }
939
940 fn run_one<A: Stores>(frame: &mut Frame<A>, gpu: &GpuContext, texture: &Texture) {
941 run_at(frame, gpu, texture, Instant::now());
942 }
943
944 fn run_at<A: Stores>(frame: &mut Frame<A>, gpu: &GpuContext, texture: &Texture, now: Instant) {
945 let view = texture.create_view(&TextureViewDescriptor::default());
946 let target = Target {
947 view: &view,
948 texture,
949 format: FORMAT,
950 size: SIZE,
951 };
952 frame
953 .step(gpu, &target, now, |_| {})
954 .expect("the frame stepped");
955 }
956
957 fn world(moves: bool) -> Session<Bare> {
958 let mut session = Session::new(Bare::default(), SimConfig::default());
959 let r3 = session
960 .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
961 let geometry = session.prepare(PreparedGeometry::Lines3 {
962 segments: vec![[[0.0; 3], [1.0, 0.0, 0.0]]],
963 });
964 let material = session.add_material(Material::lines([1.0; 4], 1.0));
965 let root = session.views().root();
966 let body = session
967 .dispatch(|dispatch| {
968 let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::Z * 4.0)))?;
969 dispatch
970 .domains
971 .typed(r3)?
972 .add_view(ViewSpec::new(root, eye, Identity3))?;
973 dispatch.spawn(
974 SpawnBundle::new()
975 .at(r3, Pose::at(Vec3::ZERO))
976 .instance(loam_runtime::Instance::new(geometry, material)),
977 )
978 })
979 .expect("the world spawned");
980 session.system(
981 Phase::Simulation,
982 "drift",
983 move |ctx: loam_runtime::Ctx<'_, Bare>| -> Result<(), loam_runtime::DomainError> {
984 if moves {
985 let x = (ctx.step.tick.0 + 1) as f32;
986 ctx.domains
987 .typed(r3)?
988 .set_pose(body, Pose::at(Vec3::X * x))?;
989 }
990 Ok(())
991 },
992 );
993 session
994 }
995
996 fn on_change(
997 session: Session<Bare>,
998 app: SessionApp<Bare>,
999 gpu: &GpuContext,
1000 ) -> (Frame<Bare>, Arc<AtomicU32>) {
1001 let recorded = Arc::new(AtomicU32::new(0));
1002 let app = app.redraw(Redraw::OnChange).pass(Box::new(Probe {
1003 recorded: recorded.clone(),
1004 rebuilt: Arc::new(AtomicU32::new(0)),
1005 }));
1006 let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1007 frame
1008 .attach(gpu, FORMAT, None, SIZE, 1.0)
1009 .expect("attached");
1010 (frame, recorded)
1011 }
1012
1013 #[test]
1014 fn a_still_world_under_on_change_skips_the_draw_after_the_first_frame() {
1015 let gpu = noop_gpu();
1016 let texture = offscreen(&gpu);
1017 let hooked = Arc::new(AtomicU32::new(0));
1018 let hooks = hooked.clone();
1019 let app = host("still").on_frame(move |_| {
1020 hooks.fetch_add(1, Ordering::Relaxed);
1021 });
1022 let (mut frame, recorded) = on_change(world(false), app, &gpu);
1023 let start = Instant::now();
1024 for millis in [0, 20, 25] {
1025 run_at(
1026 &mut frame,
1027 &gpu,
1028 &texture,
1029 start + Duration::from_millis(millis),
1030 );
1031 }
1032 assert_eq!(recorded.load(Ordering::Relaxed), 1);
1033 assert_eq!(hooked.load(Ordering::Relaxed), 3);
1034 }
1035
1036 #[test]
1037 fn a_frame_whose_publish_patched_a_moving_body_is_drawn() {
1038 let gpu = noop_gpu();
1039 let texture = offscreen(&gpu);
1040 let (mut frame, recorded) = on_change(world(true), host("moving"), &gpu);
1041 let start = Instant::now();
1042 for millis in [0, 20, 25] {
1043 run_at(
1044 &mut frame,
1045 &gpu,
1046 &texture,
1047 start + Duration::from_millis(millis),
1048 );
1049 }
1050 assert_eq!(recorded.load(Ordering::Relaxed), 3);
1051 }
1052
1053 #[test]
1054 fn a_scale_change_redraws_a_frame_whose_content_is_unchanged() {
1055 let gpu = noop_gpu();
1056 let texture = offscreen(&gpu);
1057 let (mut frame, recorded) = on_change(world(false), host("scale"), &gpu);
1058 let start = Instant::now();
1059 run_at(&mut frame, &gpu, &texture, start);
1060 run_at(
1061 &mut frame,
1062 &gpu,
1063 &texture,
1064 start + Duration::from_millis(20),
1065 );
1066 assert_eq!(recorded.load(Ordering::Relaxed), 1);
1067 frame.resize(SIZE.0, SIZE.1, 2.0);
1068 run_at(
1069 &mut frame,
1070 &gpu,
1071 &texture,
1072 start + Duration::from_millis(25),
1073 );
1074 assert_eq!(recorded.load(Ordering::Relaxed), 2);
1075 }
1076
1077 #[test]
1078 fn incompatible_dynamic_shader_reaches_the_host_with_pass_and_cause() {
1079 let source = r#"
1080struct Fragment {
1081 @location(0) color: vec4<f32>,
1082 @builtin(frag_depth) depth: f32,
1083}
1084@group(0) @binding(0) var<storage, read> wrong: array<u32>;
1085@group(0) @binding(1) var<storage, read> bodies: array<u32>;
1086@vertex fn vs_fullscreen(@builtin(vertex_index) vertex: u32) -> @builtin(position) vec4<f32> {
1087 return vec4<f32>(f32(vertex), 0.0, 0.0, 1.0);
1088}
1089@fragment fn fs_main() -> @location(0) vec4<f32> {
1090 return vec4<f32>(0.0);
1091}
1092@fragment fn fs_depth() -> Fragment {
1093 return Fragment(vec4<f32>(0.0), 0.5);
1094}
1095"#;
1096 let gpu = noop_gpu();
1097 let app =
1098 host("shader error").pass(Box::new(loam_render::HyperslicePass::new(source.into())));
1099 let mut frame = bare(app);
1100 let error = frame
1101 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1102 .expect_err("incompatible shader");
1103 let message = format!("{error:?}");
1104 assert!(message.contains("pass `hyperslice` failed during attach"));
1105 assert!(message.contains("WGSL binding 0:0 must use Uniform"));
1106 }
1107
1108 #[test]
1109 fn a_registered_pass_records_again_after_the_device_is_recovered() {
1110 let gpu = noop_gpu();
1111 let texture = offscreen(&gpu);
1112 let recorded = Arc::new(AtomicU32::new(0));
1113 let rebuilt = Arc::new(AtomicU32::new(0));
1114 let app = host("passes").pass(Box::new(Probe {
1115 recorded: recorded.clone(),
1116 rebuilt: rebuilt.clone(),
1117 }));
1118 let mut frame = bare(app);
1119 frame
1120 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1121 .expect("attached");
1122 run_one(&mut frame, &gpu, &texture);
1123 assert_eq!(recorded.load(Ordering::Relaxed), 1);
1124 assert_eq!(rebuilt.load(Ordering::Relaxed), 1);
1125
1126 frame.recover(&gpu).expect("recovered");
1127 run_one(&mut frame, &gpu, &texture);
1128 assert_eq!(
1129 rebuilt.load(Ordering::Relaxed),
1130 2,
1131 "recovery never rebuilt the application's pass on the new device"
1132 );
1133 assert_eq!(
1134 recorded.load(Ordering::Relaxed),
1135 2,
1136 "the recovered presenter no longer records the application's pass"
1137 );
1138 }
1139
1140 #[test]
1141 fn the_frame_callback_reads_the_sections_the_previous_frame_recorded() {
1142 let gpu = noop_gpu();
1143 let texture = offscreen(&gpu);
1144 let seen: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
1145 let reported = seen.clone();
1146 let app = host("sections")
1147 .pass(Box::new(Probe {
1148 recorded: Arc::new(AtomicU32::new(0)),
1149 rebuilt: Arc::new(AtomicU32::new(0)),
1150 }))
1151 .on_frame(move |hook| {
1152 let mut names = reported.lock().unwrap_or_else(|error| error.into_inner());
1153 names.clear();
1154 names.extend(hook.sections.iter().map(|section| section.name));
1155 });
1156 let mut frame = bare(app);
1157 frame
1158 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1159 .expect("attached");
1160
1161 run_one(&mut frame, &gpu, &texture);
1162 run_one(&mut frame, &gpu, &texture);
1163
1164 let names = seen.lock().unwrap_or_else(|error| error.into_inner());
1165 assert!(
1166 names.contains(&"probe") && names.contains(&"present-draw"),
1167 "the callback ran after the presenter cleared its sections: {names:?}"
1168 );
1169 }
1170
1171 #[test]
1172 fn an_input_command_updates_the_eye_before_publication() {
1173 let gpu = noop_gpu();
1174 let texture = offscreen(&gpu);
1175 let published: Arc<Mutex<Option<Eye>>> = Arc::new(Mutex::new(None));
1176 let recorded = published.clone();
1177 let mut step = 0.0_f32;
1178 let app = host::<Bare>("camera")
1179 .on_input(move |hook| {
1180 step += 1.0;
1181 let eye = Eye::looking_at([step, 2.0, 3.0], [0.0; 3], [0.0, 1.0, 0.0]);
1182 hook.sender.app_fn("camera", move |dispatch| {
1183 let aspect = dispatch.views.root_mut().eye.aspect;
1184 dispatch.views.root_mut().eye = Eye { aspect, ..eye };
1185 });
1186 })
1187 .on_frame(move |hook| {
1188 let root = hook.session.views().root();
1189 let eye = hook.session.views().get(root).map(|view| view.eye);
1190 *recorded.lock().unwrap_or_else(|error| error.into_inner()) = eye;
1191 });
1192 let mut frame = bare(app);
1193 frame
1194 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1195 .expect("attached");
1196
1197 run_one(&mut frame, &gpu, &texture);
1198 run_one(&mut frame, &gpu, &texture);
1199
1200 let published = published
1201 .lock()
1202 .unwrap_or_else(|error| error.into_inner())
1203 .expect("the frame hook read the published eye");
1204 assert_eq!(
1205 frame.inner.presented,
1206 Some(published),
1207 "the presenter did not use the eye committed before publication"
1208 );
1209 }
1210
1211 #[test]
1212 fn a_post_from_one_frame_is_not_flushed_again_on_the_next() {
1213 let gpu = noop_gpu();
1214 let texture = offscreen(&gpu);
1215 let mut frames = 0_u32;
1216 let app = host::<Bare>("posts").on_frame(move |hook| {
1217 frames += 1;
1218 if frames == 1 {
1219 hook.post("rect", &[1.0, 2.0, 3.0, 4.0]);
1220 hook.post("ready", &[]);
1221 }
1222 });
1223 let mut frame = bare(app);
1224 frame
1225 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1226 .expect("attached");
1227
1228 run_one(&mut frame, &gpu, &texture);
1229 let posted: Vec<(&str, Vec<f32>)> = frame
1230 .posts()
1231 .map(|(topic, values)| (topic, values.to_vec()))
1232 .collect();
1233 assert_eq!(
1234 posted,
1235 [("rect", vec![1.0, 2.0, 3.0, 4.0]), ("ready", Vec::new())]
1236 );
1237
1238 run_one(&mut frame, &gpu, &texture);
1239 assert_eq!(frame.posts().count(), 0);
1240 }
1241
1242 #[test]
1243 fn a_publication_with_no_views_is_reported_once_per_frame() {
1244 let gpu = noop_gpu();
1245 let texture = offscreen(&gpu);
1246 let app = host::<Bare>("no views");
1247 let mut session = Session::new(Bare::default(), SimConfig::default());
1248 session.register_domain(DomainBuilder::new("r3", EuclideanR3));
1249 let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1250 frame
1251 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1252 .expect("attached");
1253 let start = Instant::now();
1254 for millis in 0..3 {
1255 run_at(
1256 &mut frame,
1257 &gpu,
1258 &texture,
1259 start + Duration::from_millis(millis),
1260 );
1261 }
1262
1263 let lines: Vec<String> = frame
1264 .app_mut()
1265 .console_mut()
1266 .ui()
1267 .history()
1268 .iter()
1269 .map(|line| line.text.clone())
1270 .collect();
1271 assert_eq!(
1272 lines
1273 .iter()
1274 .filter(|line| line.contains("published no views"))
1275 .count(),
1276 1,
1277 "the viewless publication was not reported exactly once: {lines:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn a_frame_between_ticks_draws_the_body_at_the_elapsed_fraction_of_the_tick() {
1283 let gpu = noop_gpu();
1284 let texture = offscreen(&gpu);
1285 let mut session = Session::new(Bare::default(), SimConfig::default());
1286 let r3 = session
1287 .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
1288 let geometry = session.prepare(PreparedGeometry::Lines3 {
1289 segments: vec![[[0.1, 0.0, 0.0], [-0.1, 0.0, 0.0]]],
1290 });
1291 let material = session.add_material(Material::flat([1.0; 4]));
1292 let root = session.views().root();
1293 let body = session
1294 .dispatch(|dispatch| {
1295 let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::Z * 4.0)))?;
1296 dispatch
1297 .domains
1298 .typed(r3)?
1299 .add_view(ViewSpec::new(root, eye, Identity3))?;
1300 dispatch.spawn(
1301 SpawnBundle::new()
1302 .at(r3, Pose::at(Vec3::ZERO))
1303 .instance(loam_runtime::Instance::new(geometry, material)),
1304 )
1305 })
1306 .expect("the body spawned");
1307 session.system(
1308 Phase::Simulation,
1309 "move",
1310 move |ctx: loam_runtime::Ctx<'_, Bare>| {
1311 if ctx.step.tick == Tick(0) {
1312 ctx.domains.typed(r3)?.set_pose(body, Pose::at(Vec3::X))?;
1313 }
1314 Ok(())
1315 },
1316 );
1317 let mut frame = Frame::new(session, host("alpha")).expect("the frame accepted the config");
1318 frame
1319 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1320 .expect("attached");
1321 let start = Instant::now();
1322 let dt = Duration::from_nanos(1_000_000_000 / u64::from(SimConfig::default().fixed_hz));
1323 run_at(&mut frame, &gpu, &texture, start);
1324 run_at(&mut frame, &gpu, &texture, start + dt * 3 / 2);
1325
1326 let publication = frame.inner.records.lend().expect("the buffer is free");
1327 let x = publication.views[0].records.instances.rows()[0]
1328 .pose
1329 .coordinates[0];
1330 assert!((x - 0.5).abs() < 1e-3, "the body was drawn at x = {x}");
1331 }
1332
1333 #[test]
1334 fn a_zero_rate_config_pauses_the_frame_and_a_config_without_catch_up_is_refused() {
1335 let paused =
1336 loam_runtime::SimConfig::new(0, 2).expect("a zero rate is documented as paused");
1337 let app = host::<Bare>("paused");
1338 let session = Session::new(Bare::default(), paused);
1339 assert!(Frame::new(session, app).is_ok());
1340 assert!(matches!(
1341 loam_runtime::SimConfig::new(60, 0),
1342 Err(loam_runtime::SimConfigError::NoCatchUp)
1343 ));
1344 }
1345
1346 #[test]
1347 fn a_publication_phase_eye_write_keeps_the_frame_aspect() {
1348 let gpu = noop_gpu();
1349 let texture = offscreen(&gpu);
1350 let app = host::<Bare>("aspect");
1351 let mut session = Session::new(Bare::default(), SimConfig::default());
1352 session.register_domain(DomainBuilder::new("r3", EuclideanR3));
1353 session.system(
1354 Phase::Publication,
1355 "camera",
1356 |ctx: loam_runtime::Ctx<'_, Bare>| {
1357 ctx.views.root_mut().eye =
1358 Eye::looking_at([0.0, 2.0, 5.0], [0.0; 3], [0.0, 1.0, 0.0]);
1359 Ok(())
1360 },
1361 );
1362 let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1363 frame
1364 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1365 .expect("attached");
1366 run_at(&mut frame, &gpu, &texture, Instant::now());
1367
1368 let root = frame.inner.session.views().root();
1369 let aspect = frame.inner.session.views().get(root).unwrap().eye.aspect;
1370 assert!(
1371 (aspect - SIZE.0 as f32 / SIZE.1 as f32).abs() <= 1e-6,
1372 "a Publication-phase eye write published aspect {aspect}"
1373 );
1374 }
1375
1376 #[test]
1377 fn installed_reset_key_recovers_the_fault_and_restores_cursor_capture_once() {
1378 const RESET: ActionId = ActionId(0);
1379
1380 let gpu = noop_gpu();
1381 let texture = offscreen(&gpu);
1382 let recorded = Arc::new(AtomicU32::new(0));
1383 let filled = Arc::new(AtomicU32::new(0));
1384 let fills = filled.clone();
1385 let app = quiet(SessionApp::<Bare>::with_args(
1386 HostConfig::new(
1387 "fault recovery",
1388 Bindings::new().key(Key::Letter('r'), RESET),
1389 ),
1390 Args::default(),
1391 ))
1392 .recover_on_fault(RESET)
1393 .pass(Box::new(Probe {
1394 recorded: recorded.clone(),
1395 rebuilt: Arc::new(AtomicU32::new(0)),
1396 }))
1397 .on_frame(move |hook| {
1398 fills.fetch_add(1, Ordering::Relaxed);
1399 hook.capture_cursor(true, CursorPolicy::Toggle);
1400 });
1401 let mut session = Session::new(Bare::default(), SimConfig::default());
1402 let r3 = session
1403 .register_domain(DomainBuilder::new("r3", EuclideanR3).tracked(LogCapacity::default()));
1404 let root = session.views().root();
1405 session
1406 .dispatch(|dispatch| {
1407 let eye = dispatch.spawn(SpawnBundle::new().at(r3, Pose::at(Vec3::ZERO)))?;
1408 dispatch
1409 .domains
1410 .typed(r3)?
1411 .add_view(ViewSpec::new(root, eye, Identity3))?;
1412 Ok::<_, loam_runtime::Rejection>(())
1413 })
1414 .expect("view");
1415 let armed = Arc::new(AtomicBool::new(false));
1416 let trip = armed.clone();
1417 session.system(
1418 Phase::Publication,
1419 "trip",
1420 move |_ctx: loam_runtime::Ctx<'_, Bare>| {
1421 if trip.swap(false, Ordering::Relaxed) {
1422 return Err(loam_runtime::DomainError::ChartBoundary);
1423 }
1424 Ok(())
1425 },
1426 );
1427 session.system(
1428 Phase::Dispatch,
1429 "authored reset",
1430 |ctx: loam_runtime::Ctx<'_, Bare>| {
1431 if ctx.input.pressed(RESET) {
1432 ctx.commands.submit(Command::Reset);
1433 }
1434 Ok(())
1435 },
1436 );
1437 session.set_initial().expect("initial state");
1438 let epoch = session.scene().epoch();
1439 let mut frame = Frame::new(session, app).expect("the frame accepted the simulation config");
1440 frame
1441 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1442 .expect("attached");
1443 let start = Instant::now();
1444 let expected_sections = ["scene-pass", "triangles", "present-draw", "probe"];
1445
1446 run_at(&mut frame, &gpu, &texture, start);
1447 assert_eq!(
1448 frame
1449 .presenter
1450 .as_ref()
1451 .expect("the presenter attached")
1452 .sections()
1453 .iter()
1454 .map(|section| section.name)
1455 .collect::<Vec<_>>(),
1456 expected_sections
1457 );
1458 assert_eq!(frame.take_cursor_request(), Some(true));
1459 frame.cursor_applied(true);
1460 armed.store(true, Ordering::Relaxed);
1461 run_at(&mut frame, &gpu, &texture, start + Duration::from_millis(1));
1462 assert_eq!(
1463 frame
1464 .presenter
1465 .as_ref()
1466 .expect("the presenter attached")
1467 .sections()
1468 .iter()
1469 .map(|section| section.name)
1470 .collect::<Vec<_>>(),
1471 expected_sections
1472 );
1473 assert_eq!(
1474 frame.phase_error().map(|error| error.phase),
1475 Some(Phase::Publication)
1476 );
1477 assert_eq!(filled.load(Ordering::Relaxed), 1);
1478 assert_eq!(recorded.load(Ordering::Relaxed), 2);
1479 assert_eq!(frame.take_cursor_request(), Some(false));
1480 frame.cursor_applied(false);
1481 for millis in 2_u64..=4 {
1482 run_at(
1483 &mut frame,
1484 &gpu,
1485 &texture,
1486 start + Duration::from_millis(millis),
1487 );
1488 assert_eq!(
1489 frame
1490 .presenter
1491 .as_ref()
1492 .expect("the presenter attached")
1493 .sections()
1494 .iter()
1495 .map(|section| section.name)
1496 .collect::<Vec<_>>(),
1497 expected_sections
1498 );
1499 }
1500 assert_eq!(recorded.load(Ordering::Relaxed), 5);
1501
1502 frame.action(Key::Letter('r'), true, false);
1503 run_at(&mut frame, &gpu, &texture, start + Duration::from_secs(60));
1504 assert_eq!(recorded.load(Ordering::Relaxed), 6);
1505 assert_eq!(filled.load(Ordering::Relaxed), 2);
1506
1507 assert_eq!(frame.phase_error(), None);
1508 assert_eq!(frame.inner.session.scene().epoch(), epoch.advance());
1509 assert_eq!(frame.inner.session.current_tick(), Tick(0));
1510 assert_eq!(filled.load(Ordering::Relaxed), 2);
1511 assert_eq!(frame.take_cursor_request(), Some(true));
1512 run_at(
1513 &mut frame,
1514 &gpu,
1515 &texture,
1516 start + Duration::from_secs(60) + Duration::from_millis(1),
1517 );
1518 assert_eq!(frame.inner.session.scene().epoch(), epoch.advance());
1519 }
1520
1521 #[test]
1522 fn a_scripted_line_reaches_its_verb_on_the_frame_the_script_names() {
1523 let gpu = noop_gpu();
1524 let texture = offscreen(&gpu);
1525 let directory = tempfile::tempdir().expect("temp dir");
1526 let path = directory.path().join("demo.script");
1527 std::fs::write(&path, "0 mark first\n2 mark second\n").expect("wrote the script");
1528
1529 let marked: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1530 let recorded = marked.clone();
1531 let args = Args::from_argv([format!("--script={}", path.display())]);
1532 let app = quiet(SessionApp::<Bare>::with_args(
1533 HostConfig::new("script", Bindings::new()),
1534 args,
1535 ))
1536 .command("mark", "record a marker", move |args, _submit, _out| {
1537 recorded
1538 .lock()
1539 .unwrap_or_else(|error| error.into_inner())
1540 .push(args.join(" "));
1541 Ok(())
1542 });
1543 let mut frame = bare(app);
1544 frame
1545 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1546 .expect("attached");
1547
1548 let mut after_each = Vec::new();
1549 for _ in 0..3 {
1550 run_one(&mut frame, &gpu, &texture);
1551 after_each.push(
1552 marked
1553 .lock()
1554 .unwrap_or_else(|error| error.into_inner())
1555 .len(),
1556 );
1557 }
1558
1559 assert_eq!(
1560 after_each,
1561 [1, 1, 2],
1562 "the host drove the script off its own frame index"
1563 );
1564 assert_eq!(
1565 *marked.lock().unwrap_or_else(|error| error.into_inner()),
1566 ["first", "second"]
1567 );
1568 }
1569
1570 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1571 #[test]
1572 #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1573 fn a_capture_from_the_session_host_writes_the_targets_pixels_gpu_probe() {
1574 let instance = Instance::default();
1575 let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1576 .expect("a wgpu adapter");
1577 let texture = offscreen(&gpu);
1578 let directory = tempfile::tempdir().expect("temp dir");
1579 let app = host("capture")
1580 .capture(crate::capture::CaptureRequest::OneShot {
1581 stage: crate::capture::CaptureStage::Post,
1582 dir: Some(directory.path().to_path_buf()),
1583 name: Some("session".into()),
1584 })
1585 .expect("capture supported");
1586 let mut frame = bare(app);
1587 frame
1588 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1589 .expect("attached");
1590 run_one(&mut frame, &gpu, &texture);
1591 drop(frame);
1592
1593 let written = directory.path().join("session_post.png");
1594 let decoded = ::image::open(&written).expect("the capture wrote a png");
1595 assert_eq!(
1596 (decoded.width(), decoded.height()),
1597 SIZE,
1598 "the capture read a different rectangle than the presenter's target"
1599 );
1600 }
1601
1602 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1603 #[test]
1604 #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1605 fn pre_capture_excludes_the_overlay_that_post_capture_includes_gpu_probe() {
1606 let instance = Instance::default();
1607 let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1608 .expect("a wgpu adapter");
1609 let texture = offscreen(&gpu);
1610 let directory = tempfile::tempdir().expect("temp dir");
1611 let app = host("capture stages")
1612 .pass(Box::new(Paint {
1613 name: "red scene",
1614 color: Color {
1615 r: 1.0,
1616 g: 0.0,
1617 b: 0.0,
1618 a: 1.0,
1619 },
1620 overlay: false,
1621 pipeline: None,
1622 }))
1623 .pass(Box::new(Paint {
1624 name: "green overlay",
1625 color: Color {
1626 r: 0.0,
1627 g: 1.0,
1628 b: 0.0,
1629 a: 1.0,
1630 },
1631 overlay: true,
1632 pipeline: None,
1633 }))
1634 .capture(crate::capture::CaptureRequest::OneShot {
1635 stage: crate::capture::CaptureStage::Both,
1636 dir: Some(directory.path().to_path_buf()),
1637 name: Some("stages".into()),
1638 })
1639 .expect("capture supported");
1640 let mut frame = bare(app);
1641 frame
1642 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1643 .expect("attached");
1644 run_one(&mut frame, &gpu, &texture);
1645
1646 let pre = ::image::open(directory.path().join("stages_pre.png"))
1647 .expect("the pre capture")
1648 .to_rgba8();
1649 let post = ::image::open(directory.path().join("stages_post.png"))
1650 .expect("the post capture")
1651 .to_rgba8();
1652 assert_eq!(pre.get_pixel(0, 0).0, [255, 0, 0, 255]);
1653 assert_eq!(post.get_pixel(0, 0).0, [0, 255, 0, 255]);
1654 }
1655
1656 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1657 #[test]
1658 #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1659 fn a_hook_that_stops_its_capture_finishes_the_file_gpu_probe() {
1660 let instance = Instance::default();
1661 let gpu = pollster::block_on(GpuContext::new(instance, FeatureRequest::default(), None))
1662 .expect("a wgpu adapter");
1663 let texture = offscreen(&gpu);
1664 let directory = tempfile::tempdir().expect("temp dir");
1665 let into = directory.path().to_path_buf();
1666 let mut frames = 0_u32;
1667 let app = host::<Bare>("capture").on_frame(move |hook| {
1668 frames += 1;
1669 if frames == 1 {
1670 hook.capture
1671 .start(crate::capture::CaptureRequest::StartSequence {
1672 format: crate::capture::CaptureFormat::Apng,
1673 stage: crate::capture::CaptureStage::Post,
1674 dir: Some(into.clone()),
1675 name: Some("session".into()),
1676 fps: Some(CAPTURE_FPS),
1677 scale: None,
1678 palette: crate::capture::PaletteMode::default(),
1679 })
1680 .expect("capture supported");
1681 }
1682 if frames == 3 {
1683 hook.capture.stop().expect("capture supported");
1684 }
1685 });
1686 let mut frame = bare(app);
1687 frame
1688 .attach(&gpu, FORMAT, None, SIZE, 1.0)
1689 .expect("attached");
1690 for _ in 0..4 {
1691 run_one(&mut frame, &gpu, &texture);
1692 std::thread::sleep(Duration::from_millis(1000 / u64::from(CAPTURE_FPS) + 4));
1693 }
1694 drop(frame);
1695
1696 let written = directory.path().join("session.apng");
1697 let bytes = std::fs::read(&written).expect("the stopped capture finished its file");
1698 assert_eq!(
1699 animation_frames(&bytes),
1700 Some(2),
1701 "the file holds the frames drawn between the start and the stop"
1702 );
1703 }
1704
1705 #[cfg(all(feature = "capture", not(target_arch = "wasm32")))]
1706 fn animation_frames(png: &[u8]) -> Option<u32> {
1707 let mut at = 8;
1708 while at + 12 <= png.len() {
1709 let length = u32::from_be_bytes(png[at..at + 4].try_into().ok()?) as usize;
1710 if &png[at + 4..at + 8] == b"acTL" {
1711 return Some(u32::from_be_bytes(png[at + 8..at + 12].try_into().ok()?));
1712 }
1713 at += 12 + length;
1714 }
1715 None
1716 }
1717}