Skip to main content

xtask/
run_wasm.rs

1//! `cargo xtask run-wasm`: build and serve the web examples.
2//!
3//! The pipeline is the standard one for a `wasm32-unknown-unknown` binary a
4//! browser runs: build for the target, hand the wasm to `wasm-bindgen` so it
5//! gets a JS loader, then put the pages and the wasm behind a static file
6//! server — WebGPU is only available in a *secure context*, and `localhost`
7//! is one, while a `file://` URL is not. The server is built in
8//! ([`super::http`]), so nothing needs to be installed.
9//!
10//! The output directory is the one the Pages workflow publishes: a landing
11//! page at the root, one directory per example beside it, and the API docs
12//! under `docs/` — which only the workflow builds. `--app` picks which
13//! examples are built; the landing page links the ones that are there.
14
15use std::path::{Path, PathBuf};
16
17use super::{RunWasmArgs, WebApp, http, step};
18
19/// The target triple the web build runs on.
20pub const TARGET: &str = "wasm32-unknown-unknown";
21/// Where the bindgen output lands, inside `target/`.
22pub const OUT_DIR: &str = "generated";
23/// The cargo profile every wasm build of this repository uses, defined in the
24/// workspace manifest: release's settings at a size-oriented `opt-level`, the
25/// trade-off the name keeps from the `wasm-dev` it was renamed from — wasm
26/// builds are only ever made for a browser to download, so none of them pay
27/// for the larger binary `--release` produces.
28pub const PROFILE: &str = "wasm-release";
29/// The port the site prefers to be served on. Fixed, so the URL to open
30/// is predictable; if it is taken, the server tries the ports after it.
31const PORT: u16 = 8000;
32
33/// One example the site can show.
34pub struct Example {
35    /// The binary crate the example lives in.
36    pub crate_name: &'static str,
37    /// The binary `wasm-bindgen` reads and names its output after.
38    pub binary: &'static str,
39    /// The directory the example is published under.
40    pub dir: &'static str,
41    /// The page's title.
42    pub title: &'static str,
43    /// The landing page's link label.
44    pub label: &'static str,
45    /// The landing page's one-line description.
46    pub description: &'static str,
47}
48
49/// Every example the site can show, in the order the landing page lists them.
50pub const EXAMPLES: &[Example] = &[
51    Example {
52        crate_name: "unlit3d_examples",
53        binary: "unlit3d-examples",
54        dir: "unlit3d",
55        title: "unlit3d + winit",
56        label: "unlit3d",
57        description: "The single-world example: glTF scenes, a mesh selector and the built-in UI.",
58    },
59    Example {
60        crate_name: "unlit3dx_examples",
61        binary: "unlit3dx-examples",
62        dir: "unlit3dx",
63        title: "unlit3dx multi-world",
64        label: "unlit3dx",
65        description: "The multi-world example: gameplay, physics and audio in worlds of their own.",
66    },
67];
68
69/// The examples `app` asks for.
70fn examples(app: WebApp) -> &'static [Example] {
71    match app {
72        WebApp::Unlit3d => &EXAMPLES[..1],
73        WebApp::Unlit3dx => &EXAMPLES[1..],
74        WebApp::All => EXAMPLES,
75    }
76}
77
78/// Build and serve the web site.
79pub fn run(args: &RunWasmArgs) -> Result<(), String> {
80    let out = build(args.app, &args.cargo_args)?;
81
82    if args.no_serve {
83        println!(
84            "built; serve {} with any static file server binding to localhost",
85            out.display()
86        );
87        return Ok(());
88    }
89
90    http::serve(&out, PORT)
91}
92
93/// Build the selected examples and the landing page into one directory.
94///
95/// Returns the directory to serve: it is the site's root, so the landing page
96/// is at `index.html` and each example under its own name.
97pub fn build(app: WebApp, cargo_args: &[String]) -> Result<PathBuf, String> {
98    let out = Path::new("target").join(OUT_DIR).join(PROFILE);
99
100    // Each run writes only the examples it was asked for, so what an earlier
101    // run left behind would otherwise stay on the site — a page linking an
102    // example this build did not make, or one built from older sources. The
103    // directory is this build's, so it starts empty.
104    match std::fs::remove_dir_all(&out) {
105        Ok(()) => {}
106        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
107        Err(error) => return Err(format!("clearing {}: {error}", out.display())),
108    }
109    std::fs::create_dir_all(&out)
110        .map_err(|error| format!("creating {}: {error}", out.display()))?;
111
112    for example in examples(app) {
113        build_example(example, cargo_args, &out.join(example.dir))?;
114    }
115
116    // The page is this build's, so it is written every time — a stale one
117    // restored from a cache would otherwise list examples that are gone.
118    let landing = out.join("index.html");
119    std::fs::write(&landing, landing_page(examples(app)))
120        .map_err(|error| format!("writing {}: {error}", landing.display()))?;
121    Ok(out)
122}
123
124/// Compile one example for `wasm32-unknown-unknown`, and bindgen it.
125///
126/// The build goes through [`PROFILE`], so a browser is handed a small wasm
127/// rather than a debug one that takes minutes to compile and several times the
128/// size to fetch.
129///
130/// Shared with `cargo xtask test-wasm`, which loads the same pages: writing
131/// them here keeps the pages under test identical to the ones `run-wasm`
132/// serves.
133pub fn build_example(example: &Example, cargo_args: &[String], out: &Path) -> Result<(), String> {
134    std::fs::create_dir_all(out).map_err(|error| format!("creating {}: {error}", out.display()))?;
135
136    let mut cargo = std::process::Command::new("cargo");
137    cargo
138        .args(["build", "--target", TARGET, "-p", example.crate_name])
139        .args(["--bin", example.binary])
140        .args(["--profile", PROFILE])
141        .args(cargo_args);
142    step::run(&mut cargo, &format!("the {} wasm build", example.label))?;
143
144    let wasm = Path::new("target")
145        .join(TARGET)
146        .join(PROFILE)
147        .join(format!("{}.wasm", example.binary));
148
149    let mut bindgen = std::process::Command::new("wasm-bindgen");
150    bindgen
151        .arg(&wasm)
152        .args(["--target", "web", "--no-typescript"])
153        .arg("--out-dir")
154        .arg(out)
155        .arg("--out-name")
156        .arg(example.binary);
157    step::run(&mut bindgen, &format!("wasm-bindgen for {}", example.label))?;
158
159    // The loader is imported as `./<binary>.js`, so the page sits beside it.
160    let index = out.join("index.html");
161    std::fs::write(&index, example_page(example))
162        .map_err(|error| format!("writing {}: {error}", index.display()))?;
163    Ok(())
164}
165
166/// The page that starts one example.
167fn example_page(example: &Example) -> String {
168    EXAMPLE_HTML
169        .replace("{{title}}", example.title)
170        .replace("{{module}}", example.binary)
171}
172
173/// The landing page: a link to each example that was built, and the API docs.
174///
175/// The docs link is relative because the Pages workflow copies them beside the
176/// page; a server that has only the examples — what `run-wasm` starts — leaves
177/// it without a target.
178fn landing_page(examples: &[Example]) -> String {
179    let cards: String = examples
180        .iter()
181        .map(|example| {
182            format!(
183                "      <li><a href=\"./{dir}/\"><strong>{label}</strong><span>{description}</span></a></li>\n",
184                dir = example.dir,
185                label = example.label,
186                description = example.description,
187            )
188        })
189        .collect();
190    LANDING_HTML
191        .replace("{{cards}}", &cards)
192        .replace("{{docs}}", DOCS_DIR)
193}
194
195/// Where the API docs sit under the site's root.
196const DOCS_DIR: &str = "docs";
197
198/// The page that starts one example.
199///
200/// The canvas is sized by this page, not by the window attributes the example
201/// asks for: winit writes those into the canvas's inline `style`, and `!important`
202/// is what lets the page override them so the canvas follows a phone's viewport
203/// instead of staying at the desktop size the example requests. The body's
204/// padding is the margin around the canvas, and `box-sizing` keeps that margin
205/// inside the viewport rather than pushing the page past it; the safe-area
206/// maxima keep the canvas clear of a phone's notch and home indicator.
207///
208/// The canvas therefore follows the viewport and not its own aspect ratio, so
209/// the examples' scenes take their camera aspect from the render target's size
210/// each frame. A window that changes shape re-aims the projection and shows
211/// more or less of the scene; nothing is stretched.
212///
213/// The page carries no overlay of its own: whatever the example draws over the
214/// canvas — the corner controls, its link to the API docs — is the example's
215/// own, so those sit together by construction rather than by two hand-kept
216/// rules that can collide. The landing page at the root is where the site's
217/// navigation lives; an example page reached directly has only the example's
218/// own overlay.
219///
220/// `touch-action: none` is what makes a finger drag reach the app at all. The
221/// Pointer Events specification has `preventDefault()` on `pointerdown` *not*
222/// cancel the browser's own panning, so without it a drag is taken over by the
223/// page mid-gesture and the app is sent a `pointercancel` instead of the moves.
224/// The overflow and overscroll rules keep the page itself from scrolling under
225/// the canvas.
226const EXAMPLE_HTML: &str = r#"<!DOCTYPE html>
227<html>
228  <head>
229    <meta charset="utf-8" />
230    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
231    <title>{{title}}</title>
232    <style>
233      :root {
234        /* The gap between the canvas and the window's edge. */
235        --margin: 16px;
236      }
237      html,
238      body {
239        margin: 0;
240        padding: 0;
241        /* The address bar retracts on scroll, so the dynamic viewport unit is
242           what keeps the canvas filling the visible area on a phone. */
243        height: 100dvh;
244        overflow: hidden;
245        overscroll-behavior: none;
246        background: #101014;
247      }
248      body {
249        /* The margin around the canvas. `border-box` keeps the padding inside
250           the viewport, so the canvas's own size is the viewport less this
251           margin. */
252        box-sizing: border-box;
253        padding:
254          max(var(--margin), env(safe-area-inset-top))
255          max(var(--margin), env(safe-area-inset-right))
256          max(var(--margin), env(safe-area-inset-bottom))
257          max(var(--margin), env(safe-area-inset-left));
258      }
259      canvas {
260        display: block;
261        /* Override the inline size winit sets from the window attributes, so
262           the canvas follows the viewport — less the margin above — instead of
263           staying at the size the example asks for. */
264        width: 100% !important;
265        height: 100% !important;
266        /* Without this the browser claims a drag as its own pan. */
267        touch-action: none;
268      }
269    </style>
270  </head>
271  <body>
272    <script type="module">
273      import init from "./{{module}}.js";
274      init();
275    </script>
276  </body>
277</html>
278"#;
279
280/// The site's landing page: what the examples are, and where the docs are.
281const LANDING_HTML: &str = r#"<!DOCTYPE html>
282<html lang="en">
283  <head>
284    <meta charset="utf-8" />
285    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
286    <title>unlit3d</title>
287    <style>
288      :root {
289        color-scheme: dark;
290      }
291      html,
292      body {
293        margin: 0;
294        padding: 0;
295        background: #101014;
296        color: #e8e8ee;
297        font-family: system-ui, sans-serif;
298      }
299      main {
300        max-width: 40rem;
301        margin: 0 auto;
302        padding: 4rem 1.5rem;
303      }
304      h1 {
305        margin: 0 0 0.5rem;
306        font-size: 2rem;
307      }
308      p.lede {
309        margin: 0 0 2.5rem;
310        color: #a0a0b0;
311      }
312      ul {
313        list-style: none;
314        margin: 0;
315        padding: 0;
316        display: grid;
317        gap: 1rem;
318      }
319      li a {
320        display: block;
321        padding: 1.25rem 1.5rem;
322        border: 1px solid #2a2a34;
323        border-radius: 0.75rem;
324        background: #16161c;
325        color: inherit;
326        text-decoration: none;
327      }
328      li a:hover {
329        border-color: #4a4a5a;
330        background: #1c1c24;
331      }
332      li strong {
333        display: block;
334        margin-bottom: 0.25rem;
335        font-size: 1.1rem;
336      }
337      li span {
338        color: #a0a0b0;
339        font-size: 0.95rem;
340      }
341    </style>
342  </head>
343  <body>
344    <main>
345      <h1>unlit3d</h1>
346      <p class="lede">A compact, opinionated 3D rendering library for WebGPU and WebGL2, with simple diffuse lighting and a multi-world runtime beside it.</p>
347      <ul>
348{{cards}}        <li>
349          <a href="./{{docs}}/index.html"><strong>API docs</strong></a>
350          <span>The workspace's rustdoc, built from the latest commit.</span>
351        </li>
352      </ul>
353    </main>
354  </body>
355</html>
356"#;