Skip to main content

pfx_bench/
run.rs

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}