diff --git a/run.sh b/run.sh index 3ceba6b..5679ec6 100755 --- a/run.sh +++ b/run.sh @@ -20,17 +20,33 @@ for arg in "$@"; do esac done +# tick/toc — measure each build phase so we know where time goes. +# Bash arithmetic on $SECONDS gives whole-second resolution; cheap and +# portable. For sub-second jobs use `time` directly. +phase() { + local label="$1"; shift + local start=$SECONDS + echo "==> $label" + "$@" + local elapsed=$((SECONDS - start)) + echo " [${elapsed}s] $label" +} + +TOTAL_START=$SECONDS + if [[ $DO_BUILD -eq 1 ]]; then - echo "==> running unit tests (proves spawn / collision / mesh invariants)" - cargo test --lib + phase "running unit tests (proves spawn / collision / mesh invariants)" \ + cargo test --lib - echo "==> building wasm client (release)" - cargo build --target wasm32-unknown-unknown --release --lib - ~/.cargo/bin/wasm-bindgen --target web --out-dir web --no-typescript \ - target/wasm32-unknown-unknown/release/voxel_game.wasm + phase "building wasm client (release)" \ + cargo build --target wasm32-unknown-unknown --release --lib - echo "==> building server (release)" - (cd server && cargo build --release) + phase "wasm-bindgen → web/" \ + ~/.cargo/bin/wasm-bindgen --target web --out-dir web --no-typescript \ + target/wasm32-unknown-unknown/release/voxel_game.wasm + + phase "building server (release)" \ + bash -c "cd server && cargo build --release" # Catch JS no-undef / parse errors before they hit the browser. eslint # is optional: warns if absent so this step never blocks an emergency @@ -68,6 +84,8 @@ for i in {1..20}; do sleep 0.2 done +echo "==> total build+startup: $((SECONDS - TOTAL_START))s" + if [[ $DO_TUNNEL -eq 0 ]]; then echo "==> server running at http://localhost:8080 (no tunnel)" wait $SERVER_PID diff --git a/src/render/mod.rs b/src/render/mod.rs index 0cfbff8..1804b17 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -484,7 +484,22 @@ impl Renderer { let Some(chunk) = world.chunks.get(&coord) else { return; }; + // tick/toc — mesh build (esp. the sky_visibility hemisphere + // raycasts) is the suspected hot path on world init. Times + // come through the browser console for now; once we have a + // real perf overlay they'll go there. + let t0 = browser_now(); let mesh = build_chunk_mesh(world, chunk); + let mesh_ms = browser_now() - t0; + if mesh_ms > 5.0 { + log::info!( + "rebuild_chunk {:?}: {:.1}ms ({} verts, {} idx)", + coord, + mesh_ms, + mesh.vertices.len(), + mesh.indices.len() + ); + } if mesh.indices.is_empty() { self.chunk_buffers.remove(&coord); return; @@ -627,6 +642,29 @@ impl Renderer { } } +/// Cross-platform "now" in milliseconds — uses the browser's +/// `performance.now()` on wasm, `Instant::now()` on native. Always +/// monotonic. Pairs with `browser_now()` calls to give tick/toc +/// elapsed times for instrumenting hot paths (mesh build, etc.). +fn browser_now() -> f64 { + #[cfg(target_arch = "wasm32")] + { + web_sys::window() + .and_then(|w| w.performance()) + .map(|p| p.now()) + .unwrap_or(0.0) + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::Instant; + // The "0.0 epoch" is the first call; subsequent diffs are correct. + thread_local! { + static EPOCH: Instant = Instant::now(); + } + EPOCH.with(|e| (Instant::now() - *e).as_secs_f64() * 1000.0) + } +} + /// Run a single full-screen-triangle render pass: clear target if /// `clear` is `Some`, load otherwise; set pipeline; bind groups in /// order; draw three vertices. Used for every post-chain step so the diff --git a/src/shader.wgsl b/src/shader.wgsl index b6e69b9..d2a59ef 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -386,9 +386,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { lit = lit + in.color * sun_col * trans; } - let to_eye = eye_world() - in.world_pos; - let dist = length(to_eye); - let view_dir = -to_eye / max(dist, 0.0001); + // Reuse `to_eye` from the specular block (normalized eye-to-fragment). + // Fog needs the *raw* distance though, so recompute that here. + let to_eye_raw = eye_world() - in.world_pos; + let dist = length(to_eye_raw); + let view_dir = -to_eye_raw / max(dist, 0.0001); let color = apply_fog(lit, dist, view_dir); return vec4(color, 1.0); diff --git a/test/run.py b/test/run.py index d5d6989..054d161 100644 --- a/test/run.py +++ b/test/run.py @@ -166,25 +166,39 @@ def main() -> int: p, browser, page = attach_page() failures = 0 + total_start = time.perf_counter() + timings: list[tuple[str, float]] = [] try: for i, step in enumerate(scenario.get("steps", [])): + step_summary = next(iter(step.keys())) if step else f"step{i}" print(f"step {i}:") + step_start = time.perf_counter() try: ok = run_step(page, step, name, args.halt_on_fail) + step_ms = (time.perf_counter() - step_start) * 1000 + timings.append((step_summary, step_ms)) + print(f" [{step_ms:6.1f}ms]") if not ok: failures += 1 if args.halt_on_fail: break except Exception as e: # noqa: BLE001 + step_ms = (time.perf_counter() - step_start) * 1000 + timings.append((step_summary, step_ms)) print(f" [!] step error: {e}", file=sys.stderr) failures += 1 if args.halt_on_fail: break + total_ms = (time.perf_counter() - total_start) * 1000 print() - if failures == 0: - print(f"# {name}: OK ({len(scenario.get('steps', []))} steps)") - else: - print(f"# {name}: FAILED ({failures} step(s))", file=sys.stderr) + print(f"# {name}: {len(scenario.get('steps', []))} steps in {total_ms / 1000:.2f}s " + f"({failures} failure{'s' if failures != 1 else ''})") + # Per-step breakdown so the slowest steps are obvious. + if timings: + timings_sorted = sorted(enumerate(timings), key=lambda x: -x[1][1]) + print("# slowest steps:") + for i, (kind, ms) in timings_sorted[:5]: + print(f"# step {i:>2} {kind:>12} {ms:>7.1f}ms") finally: browser.close() p.stop() diff --git a/web/main.js b/web/main.js index ce99ca6..585a519 100644 --- a/web/main.js +++ b/web/main.js @@ -22,6 +22,12 @@ function applyInputMode(mode) { } init().then(() => { + // Expose all wasm-bindgen exports on window.voxel_game so the + // Playwright test harness (test/run.py + scenarios) can drive the + // game declaratively from JS: set_scene_time, teleport, look_at, + // get_position, etc. Dev-affordance only; production users never + // touch this surface. + window.voxel_game = wasm; wasm.reset_input(); setupTouch(); setupGamepad();