1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use pfx_gpu::pace::Pace;
6use pfx_gpu::{Gpu, OffscreenTarget, wgpu};
7use pfx_input::Recording;
8use pfx_live::renderer::Renderer;
9use pfx_live::stats::FrameStats;
10use pfx_load::scene::Scene;
11use pfx_play::{Options, PlaySession};
12use serde_json::{Map, Value, json};
13
14use crate::args::{DEFAULT_SIZE, Plan, parse};
15use crate::game::BenchGame;
16use crate::summary::{Summary, summarize};
17
18pub const FPS: f64 = 60.0;
19
20#[derive(Clone, Debug, PartialEq)]
21pub struct Report {
22 pub summary: Summary,
23 pub stats: PathBuf,
24 pub ticks: u64,
25 pub inputs: u64,
26 pub size: (u32, u32),
27 pub camera: ([f32; 3], [f32; 3]),
28 pub line: String,
29}
30
31fn rounded(values: [f32; 3]) -> Value {
32 json!(values.map(|value| (f64::from(value) * 10_000.0).round() / 10_000.0))
33}
34
35fn target(gpu: &Gpu, width: u32, height: u32) -> OffscreenTarget {
36 let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
37 label: Some("pfx bench output"),
38 size: wgpu::Extent3d {
39 width,
40 height,
41 depth_or_array_layers: 1,
42 },
43 mip_level_count: 1,
44 sample_count: 1,
45 dimension: wgpu::TextureDimension::D2,
46 format: wgpu::TextureFormat::Rgba16Float,
47 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
48 | wgpu::TextureUsages::TEXTURE_BINDING
49 | wgpu::TextureUsages::COPY_SRC
50 | wgpu::TextureUsages::STORAGE_BINDING,
51 view_formats: &[],
52 });
53 let view = texture.create_view(&Default::default());
54 OffscreenTarget {
55 texture,
56 view,
57 format: wgpu::TextureFormat::Rgba16Float,
58 width,
59 height,
60 }
61}
62
63fn scene_name(path: &Path) -> String {
64 let name = path.file_name().map_or_else(
65 || "scene".into(),
66 |name| name.to_string_lossy().into_owned(),
67 );
68 let name = name.strip_suffix(".toml").unwrap_or(&name);
69 name.strip_suffix(".scene").unwrap_or(name).to_string()
70}
71
72fn stats_path(
73 plan: &Plan,
74 caller_tmp: impl FnOnce() -> Result<PathBuf, String>,
75) -> Result<PathBuf, String> {
76 if let Some(path) = &plan.stats {
77 return Ok(path.clone());
78 }
79 let seconds = SystemTime::now()
80 .duration_since(UNIX_EPOCH)
81 .map_or(0, |elapsed| elapsed.as_secs());
82 Ok(caller_tmp()?
83 .join("pfx")
84 .join("bench")
85 .join(format!("{}-{seconds}.jsonl", scene_name(&plan.scene))))
86}
87
88fn open_recording(path: &Path) -> Result<Recording, String> {
89 if !path.is_file() {
90 return Err(format!("{}: no such recording", path.display()));
91 }
92 let text =
93 std::fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?;
94 serde_json::from_str(&text)
95 .map_err(|error| format!("{}: not a recording: {error}", path.display()))
96}
97
98pub fn run_plan(
99 plan: &Plan,
100 caller_tmp: impl FnOnce() -> Result<PathBuf, String>,
101) -> Result<Report, String> {
102 if !plan.scene.is_file() {
103 return Err(format!("{}: no such scene file", plan.scene.display()));
104 }
105 let scene =
106 Scene::open(&plan.scene).map_err(|error| format!("{}: {error:?}", plan.scene.display()))?;
107 if let Some(name) = &plan.camera
108 && scene.object(name).is_none()
109 {
110 return Err(format!(
111 "{}: no object named {name:?} to stand the camera at",
112 plan.scene.display()
113 ));
114 }
115 let recording = plan.replay.as_deref().map(open_recording).transpose()?;
116 let (width, height) = plan.size.unwrap_or(DEFAULT_SIZE);
117 let stats_file = stats_path(plan, caller_tmp)?;
118 if let Some(parent) = stats_file.parent() {
119 std::fs::create_dir_all(parent)
120 .map_err(|error| format!("{}: {error}", parent.display()))?;
121 }
122 File::create(&stats_file).map_err(|error| format!("{}: {error}", stats_file.display()))?;
123 let stats = FrameStats::to_file(&stats_file)
124 .map_err(|error| format!("{}: {error}", stats_file.display()))?;
125
126 let gpu = pollster::block_on(Gpu::headless())?;
127 let output = target(&gpu, width, height);
128 let mut renderer = Renderer::new(gpu, width, height).map_err(|error| error.to_string())?;
129 renderer.set_frame_stats(stats.clone());
130 let staged = renderer.stage(&scene)?;
131 let steps = recording.clone();
132 let mut session = PlaySession::play_with(
133 &scene,
134 Some(&staged),
135 Box::new(BenchGame::new(plan.camera.clone())),
136 Options {
137 seed: plan.seed,
138 replay: recording,
139 ..Options::default()
140 },
141 )
142 .map_err(|error| error.to_string())?;
143 session.set_frame_stats(stats.clone());
144
145 let frames = (plan.seconds * FPS).ceil() as u64;
146 let aspect = width as f32 / height as f32;
147 let seed = plan.seed as u32;
148 let finish = staged.finish();
149 let mut pace = Pace::new();
150 for _ in 0..frames {
151 session.advance((1.0 / FPS) as f32);
152 let drawn = session
153 .present(&staged, &mut renderer, aspect, seed)
154 .map_err(|error| error.to_string())?;
155 let view = &output.view;
156 let renderer = &mut renderer;
157 let mut failed = None;
158 pace.frame(|| {
159 renderer
160 .render(&drawn.scene, &drawn.text, &drawn.effects, finish, view)
161 .unwrap_or_else(|error| {
162 failed = Some(error);
163 Vec::new()
164 })
165 });
166 if let Some(error) = failed {
167 return Err(error);
168 }
169 }
170 let ticks = session.world().ticks();
171 let camera = (session.world().camera.at, session.world().camera.look_at);
172 let inputs = steps.map_or(0, |recording| {
173 recording
174 .steps
175 .iter()
176 .take(ticks as usize)
177 .map(|events| events.len() as u64)
178 .sum()
179 });
180 session.stop();
181 stats.finish();
182
183 let text = std::fs::read_to_string(&stats_file)
184 .map_err(|error| format!("{}: {error}", stats_file.display()))?;
185 let summary = summarize(&text)?;
186 let mut fields = Map::new();
187 fields.insert("scene".into(), json!(plan.scene.to_string_lossy()));
188 fields.insert("seed".into(), json!(plan.seed));
189 fields.insert("size".into(), json!([width, height]));
190 fields.insert("ticks".into(), json!(ticks));
191 fields.insert("inputs".into(), json!(inputs));
192 fields.insert(
193 "camera".into(),
194 json!({ "at": rounded(camera.0), "look_at": rounded(camera.1) }),
195 );
196 fields.insert("stats".into(), json!(stats_file.to_string_lossy()));
197 let line = summary.json(fields).to_string();
198 Ok(Report {
199 summary,
200 stats: stats_file,
201 ticks,
202 inputs,
203 size: (width, height),
204 camera,
205 line,
206 })
207}
208
209pub fn run(
210 args: &[String],
211 caller_tmp: impl FnOnce() -> Result<PathBuf, String>,
212) -> Result<(), String> {
213 let plan = parse(args)?;
214 let report = run_plan(&plan, caller_tmp)?;
215 println!("{}", report.line);
216 Ok(())
217}