1use std::path::{Path, PathBuf};
16
17use super::{RunWasmArgs, WebApp, http, step};
18
19pub const TARGET: &str = "wasm32-unknown-unknown";
21pub const OUT_DIR: &str = "generated";
23pub const PROFILE: &str = "wasm-release";
29const PORT: u16 = 8000;
32
33pub struct Example {
35 pub crate_name: &'static str,
37 pub binary: &'static str,
39 pub dir: &'static str,
41 pub title: &'static str,
43 pub label: &'static str,
45 pub description: &'static str,
47}
48
49pub 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
69fn 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
78pub 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
93pub fn build(app: WebApp, cargo_args: &[String]) -> Result<PathBuf, String> {
98 let out = Path::new("target").join(OUT_DIR).join(PROFILE);
99
100 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 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
124pub 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 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
166fn example_page(example: &Example) -> String {
168 EXAMPLE_HTML
169 .replace("{{title}}", example.title)
170 .replace("{{module}}", example.binary)
171}
172
173fn 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
195const DOCS_DIR: &str = "docs";
197
198const 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
280const 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"#;