1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use pfx_game::{Config, Driver, Event, Exit, Painter, Warmup};
5use pfx_gpu::pace::Pacer;
6use pfx_gpu::screens::{SystemSource, detect};
7use pfx_gpu::wgpu;
8use pfx_gpu::window::Size;
9use pfx_gpu::{Gpu, OffscreenTarget};
10use pfx_input::{KeyLabels, LayoutSource, Recording};
11use pfx_live::renderer::Renderer;
12use pfx_load::scene::{Camera, Scene};
13use pfx_play::{Game, Options, PlayHooks, PlaySession, Rigs, SceneGame, Tunables};
14use pfx_trace::Trace;
15
16use crate::audio::Audio;
17use crate::editor::{Editor, Play};
18use crate::input::Layout;
19use crate::inspect::Change;
20
21pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
22pub const SHOWN: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
23pub const TRACE_SAMPLES: u32 = 256;
24
25pub fn stamp_of(seconds: u64) -> String {
26 let days = (seconds / 86_400) as i64;
27 let rest = seconds % 86_400;
28 let shifted = days + 719_468;
29 let era = shifted.div_euclid(146_097);
30 let day_of_era = shifted.rem_euclid(146_097);
31 let year_of_era =
32 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
33 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
34 let month_part = (5 * day_of_year + 2) / 153;
35 let day = day_of_year - (153 * month_part + 2) / 5 + 1;
36 let month = if month_part < 10 {
37 month_part + 3
38 } else {
39 month_part - 9
40 };
41 let year = year_of_era + era * 400 + i64::from(month <= 2);
42 format!(
43 "{year:04}{month:02}{day:02}-{:02}{:02}{:02}",
44 rest / 3_600,
45 rest % 3_600 / 60,
46 rest % 60
47 )
48}
49
50fn stamp() -> String {
51 let seconds = SystemTime::now()
52 .duration_since(UNIX_EPOCH)
53 .map(|elapsed| elapsed.as_secs())
54 .unwrap_or_default();
55 stamp_of(seconds)
56}
57
58pub fn write_recording(
59 folder: &Path,
60 scene: &str,
61 stamp: &str,
62 recording: &Recording,
63) -> Result<PathBuf, String> {
64 let text = serde_json::to_string(recording).map_err(|error| error.to_string())?;
65 std::fs::create_dir_all(folder).map_err(|error| format!("{}: {error}", folder.display()))?;
66 let mut path = folder.join(format!("{scene}-{stamp}.json"));
67 let mut again = 1;
68 while path.exists() {
69 again += 1;
70 path = folder.join(format!("{scene}-{stamp}-{again}.json"));
71 }
72 std::fs::write(&path, text).map_err(|error| format!("{}: {error}", path.display()))?;
73 Ok(path)
74}
75
76pub struct Surface {
77 pub image: OffscreenTarget,
78 pub shown: wgpu::TextureView,
79}
80
81pub fn surface(gpu: &Gpu, size: [u32; 2]) -> Surface {
82 let [width, height] = [size[0].max(1), size[1].max(1)];
83 let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
84 label: Some("pfx edit play and trace"),
85 size: wgpu::Extent3d {
86 width,
87 height,
88 depth_or_array_layers: 1,
89 },
90 mip_level_count: 1,
91 sample_count: 1,
92 dimension: wgpu::TextureDimension::D2,
93 format: FORMAT,
94 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
95 | wgpu::TextureUsages::TEXTURE_BINDING
96 | wgpu::TextureUsages::COPY_SRC
97 | wgpu::TextureUsages::COPY_DST,
98 view_formats: &[SHOWN],
99 });
100 let view = texture.create_view(&Default::default());
101 let shown = texture.create_view(&wgpu::TextureViewDescriptor {
102 format: Some(SHOWN),
103 ..Default::default()
104 });
105 Surface {
106 image: OffscreenTarget {
107 texture,
108 view,
109 format: FORMAT,
110 width,
111 height,
112 },
113 shown,
114 }
115}
116
117pub fn display(linear: f32) -> u8 {
118 let mapped = 1.0 - (-linear.max(0.0)).exp();
119 let encoded = pfx_materials::encode_channel(mapped);
120 (encoded.clamp(0.0, 1.0) * 255.0).round() as u8
121}
122
123pub fn tonemap(color: &[u8]) -> Vec<u8> {
124 color
125 .chunks_exact(16)
126 .flat_map(|pixel| {
127 let channel =
128 |at: usize| f32::from_le_bytes(pixel[at..at + 4].try_into().unwrap_or([0; 4]));
129 [
130 display(channel(0)),
131 display(channel(4)),
132 display(channel(8)),
133 255,
134 ]
135 })
136 .collect()
137}
138
139struct Preview {
140 trace: Trace,
141 pacer: Pacer,
142 samples: u32,
143 of: (Camera, u64),
144}
145
146struct Playing {
147 driver: Driver,
148 painter: Painter,
149 warmup: Option<Warmup>,
150 clock_ns: u64,
151 drawn: bool,
152}
153
154pub struct Stage<'a> {
155 pub config: &'a Config,
156 pub flat: bool,
157 pub layout: Option<Layout>,
158 pub labels: KeyLabels,
159}
160
161pub struct View {
162 pub gpu: Gpu,
163 pub target: Surface,
164 texture: Option<egui::TextureId>,
165 play: Option<Playing>,
166 hooks: Option<Box<dyn PlayHooks>>,
167 trace: Option<Preview>,
168 stepped: bool,
169 audio: Audio,
170}
171
172impl View {
173 pub fn open(gpu: Gpu, size: [u32; 2], audio: Audio) -> View {
174 View {
175 target: surface(&gpu, size),
176 gpu,
177 texture: None,
178 play: None,
179 hooks: None,
180 trace: None,
181 stepped: false,
182 audio,
183 }
184 }
185
186 pub fn size(&self) -> [u32; 2] {
187 [self.target.image.width, self.target.image.height]
188 }
189
190 pub fn aspect(&self) -> f32 {
191 self.target.image.width as f32 / self.target.image.height.max(1) as f32
192 }
193
194 pub fn playing(&self) -> bool {
195 self.play.is_some()
196 }
197
198 pub fn running(&self) -> bool {
199 self.play
200 .as_ref()
201 .is_some_and(|play| !play.driver.session().paused())
202 }
203
204 pub fn shown(&self) -> bool {
205 self.play.as_ref().is_some_and(|play| play.drawn)
206 }
207
208 pub fn warming(&self) -> bool {
209 self.play.as_ref().is_some_and(|play| play.warmup.is_some())
210 }
211
212 pub fn renderer(&self) -> Option<&Renderer> {
213 self.play.as_ref().map(|play| play.painter.renderer())
214 }
215
216 pub fn driver(&mut self) -> Option<&mut Driver> {
217 self.play.as_mut().map(|play| &mut play.driver)
218 }
219
220 pub fn texture(&mut self, egui: &mut egui_wgpu::Renderer) -> egui::TextureId {
221 let device = &self.gpu.device;
222 let shown = &self.target.shown;
223 *self.texture.get_or_insert_with(|| {
224 egui.register_native_texture(device, shown, wgpu::FilterMode::Linear)
225 })
226 }
227
228 pub fn resize(
229 &mut self,
230 egui: &mut egui_wgpu::Renderer,
231 size: [u32; 2],
232 ) -> Result<bool, String> {
233 let size = [size[0].max(1), size[1].max(1)];
234 if size == self.size() {
235 return Ok(false);
236 }
237 self.target = surface(&self.gpu, size);
238 if let Some(id) = self.texture {
239 egui.update_egui_texture_from_wgpu_texture(
240 &self.gpu.device,
241 &self.target.shown,
242 wgpu::FilterMode::Linear,
243 id,
244 );
245 }
246 if let Some(play) = &mut self.play {
247 play.driver.feed(Event::Resized(Size {
248 width: size[0],
249 height: size[1],
250 }));
251 self.stepped = true;
252 }
253 self.trace = None;
254 Ok(true)
255 }
256
257 pub fn forget_trace(&mut self) {
258 self.trace = None;
259 }
260
261 pub fn trace_samples(&self) -> u32 {
262 self.trace.as_ref().map_or(0, |preview| preview.samples)
263 }
264
265 pub fn tracing(&self) -> bool {
266 self.trace
267 .as_ref()
268 .is_none_or(|preview| preview.samples < TRACE_SAMPLES)
269 }
270
271 pub fn sync(&mut self, editor: &mut Editor) {
272 if editor.muted != self.audio.muted() {
273 self.audio.set_muted(editor.muted);
274 }
275 editor.sound = self.audio.line();
276 editor.sound_available = self.audio.available();
277 if !editor.trace {
278 self.trace = None;
279 }
280 editor.trace_samples = self.trace_samples();
281 }
282
283 fn play_frame(&mut self, seconds: f32) -> Result<bool, String> {
284 let running = self.running();
285 let stepped = std::mem::take(&mut self.stepped);
286 let Some(play) = &mut self.play else {
287 return Ok(false);
288 };
289 if running {
290 let start = play.clock_ns;
291 play.clock_ns =
292 start.saturating_add((f64::from(seconds.max(0.0)) * 1e9).round() as u64);
293 if let Some(warmup) = &mut play.warmup {
294 if !warmup.pass(&mut play.painter, &mut play.driver, FORMAT, play.clock_ns)? {
295 return Ok(false);
296 }
297 play.warmup = None;
298 play.driver.start_clock(start);
299 }
300 play.driver.update(play.clock_ns);
301 self.audio.advance(play.driver.session_mut());
302 } else if !stepped {
303 return Ok(false);
304 }
305 if play.warmup.is_some() || play.driver.exit().is_some() {
306 return Ok(false);
307 }
308 play.painter
309 .draw(&mut play.driver, &self.target.image.view, FORMAT)?;
310 play.drawn = true;
311 Ok(true)
312 }
313
314 pub fn play(&mut self, editor: &mut Editor, seconds: f32) -> bool {
315 let drew = match self.play_frame(seconds) {
316 Ok(drew) => drew,
317 Err(error) => {
318 editor.log.error(format!("play: {error}; play stopped"));
319 self.stop_play(editor, false);
320 return true;
321 }
322 };
323 let exit = self
324 .play
325 .as_ref()
326 .and_then(|play| play.driver.exit().cloned());
327 match exit {
328 Some(Exit::Failed(error)) => {
329 editor
330 .log
331 .error(format!("play: the game failed: {error}; play stopped"));
332 self.stop_play(editor, false);
333 true
334 }
335 Some(Exit::Quit(code)) => {
336 editor
337 .log
338 .info(format!("play: the game quit with code {code}"));
339 self.stop_play(editor, false);
340 true
341 }
342 None => drew,
343 }
344 }
345
346 pub fn trace_frame(&mut self, scene: &Scene, of: (Camera, u64)) -> Result<bool, String> {
347 if self.trace.as_ref().is_some_and(|preview| preview.of != of) {
348 self.trace = None;
349 }
350 if self.trace.is_none() {
351 let mut scene = scene.clone();
352 scene.camera = Some(of.0);
353 let [width, height] = self.size();
354 let staged = pfx_trace::stage::scene(&scene, self.aspect())?;
355 let trace = staged
356 .trace(&self.gpu, width, height)
357 .map_err(|error| format!("{error:?}"))?;
358 self.trace = Some(Preview {
359 trace,
360 pacer: Pacer::default(),
361 samples: 0,
362 of,
363 });
364 }
365 let Some(preview) = &mut self.trace else {
366 return Ok(false);
367 };
368 if preview.samples >= TRACE_SAMPLES {
369 return Ok(false);
370 }
371 preview
372 .trace
373 .sample_paced(&self.gpu, 1, preview.samples, &mut preview.pacer, |_| ())?;
374 preview.samples += 1;
375 let color = preview.trace.readback(&self.gpu)?.color;
376 self.gpu
377 .upload_rgba8(&self.target.image, &tonemap(&color))?;
378 Ok(true)
379 }
380
381 pub fn start_play(
382 &mut self,
383 editor: &mut Editor,
384 game: Option<Box<dyn Game>>,
385 stage: Stage<'_>,
386 ) -> Result<(), String> {
387 if self.play.is_some() {
388 return Ok(());
389 }
390 let edited = editor.scene().clone();
391 let empty = Scene::empty();
392 let scene = if stage.flat { &empty } else { &edited };
393 let [width, height] = self.size();
394 let window = Size { width, height };
395 let mut painter = Painter::new(
396 self.gpu.clone(),
397 (!stage.flat).then_some(&edited),
398 stage.config,
399 window,
400 )?;
401 painter.wait_for_frames(true);
402 if !stage.flat {
403 painter.renderer_mut().wait_sky()?;
404 }
405 let hooks: Box<dyn PlayHooks> = match self.hooks.take() {
406 Some(hooks) => hooks,
407 None => Box::new(pfx_play::NoHooks),
408 };
409 let (audio_rate, audio_channels) = self.audio.begin(&mut editor.log);
410 let own = game.is_some();
411 let game = game.unwrap_or_else(|| Box::new(SceneGame));
412 let first = game
413 .settings()
414 .cloned()
415 .unwrap_or_else(|| stage.config.settings.clone());
416 let session = PlaySession::play_hooked(
417 scene,
418 painter.staged(),
419 game,
420 Options {
421 seed: stage.config.seed,
422 audio_rate,
423 audio_channels,
424 record: true,
425 tunables: stage.flat.then(Tunables::default),
426 rigs: stage.flat.then(Rigs::default),
427 ..Options::default()
428 },
429 hooks,
430 )
431 .map_err(|error| error.to_string());
432 let mut session = match session {
433 Ok(session) => session,
434 Err(error) => {
435 self.audio.abandon();
436 return Err(error);
437 }
438 };
439 session.set_frame_stats(painter.renderer().frame_stats());
440 let settings = session.game().settings().cloned().unwrap_or(first);
441 let device = stage
442 .config
443 .device
444 .unwrap_or_else(|| detect(&SystemSource).device);
445 let mut driver = Driver::new(
446 session,
447 settings,
448 stage.config.screen.clone(),
449 device,
450 window,
451 None,
452 );
453 *driver.labels_mut() = stage.labels;
454 driver.set_layout(
455 stage
456 .layout
457 .map(|layout| Box::new(layout) as Box<dyn LayoutSource>),
458 );
459 self.play = Some(Playing {
460 driver,
461 painter,
462 warmup: Some(Warmup::new(stage.config.warm_budget)),
463 clock_ns: 0,
464 drawn: false,
465 });
466 self.stepped = false;
467 editor.play = Play::Playing;
468 editor.log.info(match (own, stage.flat) {
469 (true, true) => "play: the game's own logic runs on an empty world; no file changes",
470 (true, false) => {
471 "play: the game's own logic runs on the scene; its files stay as they are"
472 }
473 (false, true) => "play: the empty world runs as a game; no file changes",
474 (false, false) => "play: the scene runs as a game; its files stay as they are",
475 });
476 Ok(())
477 }
478
479 pub fn session(&mut self) -> Option<&mut PlaySession> {
480 self.play.as_mut().map(|play| play.driver.session_mut())
481 }
482
483 pub fn pause(&mut self, editor: &mut Editor) {
484 if let Some(play) = &mut self.play {
485 let session = play.driver.session_mut();
486 session.pause();
487 self.audio.pause(session);
488 editor.play = Play::Paused;
489 }
490 }
491
492 pub fn resume(&mut self, editor: &mut Editor) {
493 if let Some(play) = &mut self.play {
494 play.driver.session_mut().resume();
495 self.audio.resume();
496 editor.play = Play::Playing;
497 }
498 }
499
500 pub fn step(&mut self, editor: &mut Editor) {
501 if let Some(play) = &mut self.play
502 && play.warmup.is_none()
503 && play.driver.step()
504 {
505 editor
506 .log
507 .info(format!("step: tick {}", play.driver.ticks()));
508 self.audio.step(play.driver.session_mut());
509 self.stepped = true;
510 }
511 }
512
513 pub fn save_recording(&mut self, editor: &mut Editor) {
514 let Some(play) = &self.play else {
515 return;
516 };
517 let Some(recording) = play.driver.session().recording() else {
518 editor
519 .log
520 .warn("record: this play session has no input recording");
521 return;
522 };
523 let scene = editor
524 .name()
525 .split('.')
526 .next()
527 .unwrap_or_default()
528 .to_string();
529 let folder = editor.project_root().join("tmp/pfx/recordings");
530 match write_recording(&folder, &scene, &stamp(), recording) {
531 Ok(path) => editor.log.info(format!(
532 "record: {} ticks of input saved to {}",
533 recording.len(),
534 path.display()
535 )),
536 Err(error) => editor.log.error(format!("record: {error}")),
537 }
538 }
539
540 pub fn stop_play(&mut self, editor: &mut Editor, keep: bool) {
541 let Some(mut play) = self.play.take() else {
542 return;
543 };
544 self.audio.stop(play.driver.session_mut());
545 let stopped = play.driver.into_session().stop();
546 for error in &stopped.errors {
547 editor.log.scene_error(error);
548 }
549 self.hooks = Some(stopped.hooks);
550 let held = stopped.pending.len();
551 let note = match (keep, held) {
552 (_, 0) => String::new(),
553 (true, _) => format!("; keeping {held} play edits"),
554 (false, _) => format!("; {held} play edits discarded"),
555 };
556 editor.log.info(format!(
557 "stop after {} ticks: back to the edited scene{note}",
558 stopped.ticks
559 ));
560 editor.stopped(keep.then_some(&stopped.pending));
561 }
562
563 pub fn world_edit(&mut self, editor: &mut Editor, change: &Change) {
564 let Some(play) = &mut self.play else {
565 return;
566 };
567 if editor.play_edit(play.driver.session_mut(), change) {
568 self.stepped = true;
569 }
570 }
571
572 pub fn pixels(&self) -> Result<Vec<u8>, String> {
573 self.gpu.readback_bytes(&self.target.image)
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn a_stamp_is_the_utc_date_and_time_of_the_second() {
583 assert_eq!(stamp_of(0), "19700101-000000");
584 assert_eq!(stamp_of(951_782_400), "20000229-000000");
585 assert_eq!(stamp_of(1_800_000_000), "20270115-080000");
586 assert_eq!(
587 stamp_of(1_800_000_000 + 86_399 - 8 * 3_600),
588 "20270115-235959"
589 );
590 }
591
592 #[test]
593 fn a_recording_is_written_as_json_pfx_bench_reads_and_never_over_another() {
594 use pfx_input::{InputEvent, Key};
595 let folder = Path::new(env!("CARGO_MANIFEST_DIR"))
596 .join("../../tmp/editor-tests")
597 .join("recordings");
598 let _ = std::fs::remove_dir_all(&folder);
599 let mut input = pfx_input::Input::new(pfx_input::Actions::new(Vec::new()).unwrap(), 16_667);
600 input.start_recording();
601 input.feed(InputEvent::Key {
602 key: Key::D,
603 pressed: true,
604 });
605 input.step();
606 input.step();
607 let recording = input.stop_recording().unwrap();
608 assert_eq!(recording.len(), 2);
609 let first = write_recording(&folder, "room", "20270115-080000", &recording).unwrap();
610 let second = write_recording(&folder, "room", "20270115-080000", &recording).unwrap();
611 assert_eq!(first, folder.join("room-20270115-080000.json"));
612 assert_eq!(second, folder.join("room-20270115-080000-2.json"));
613 let read: Recording =
614 serde_json::from_str(&std::fs::read_to_string(&first).unwrap()).unwrap();
615 assert_eq!(read, recording);
616 let _ = std::fs::remove_dir_all(&folder);
617 }
618
619 #[test]
620 fn the_preview_tonemap_maps_black_to_black_and_never_clips_a_bright_pixel_to_zero() {
621 assert_eq!(display(0.0), 0);
622 assert_eq!(display(-1.0), 0);
623 assert!(display(0.18) > 100 && display(0.18) < 140);
624 assert_eq!(display(1e9), 255);
625 let pixel: Vec<u8> = [0.0f32, 0.18, 4.0, 1.0]
626 .iter()
627 .flat_map(|value| value.to_le_bytes())
628 .collect();
629 let shown = tonemap(&pixel);
630 assert_eq!(shown.len(), 4);
631 assert_eq!(shown[0], 0);
632 assert_eq!(shown[3], 255);
633 assert!(shown[2] > shown[1]);
634 }
635}