+ {/* A bare URL is the landing page. The click stays here: anywhere else
+ on the page it would lock the pointer on the way out. */}
+
event.stopPropagation()}
+ className="pointer-events-auto absolute top-4 left-4 text-sm text-amber-100/70 hover:text-amber-50"
+ >
+ ← Home
+
Code Backrooms
diff --git a/apps/web/src/params.test.ts b/apps/web/src/params.test.ts
index 22549fa..eda4c5a 100644
--- a/apps/web/src/params.test.ts
+++ b/apps/web/src/params.test.ts
@@ -19,6 +19,7 @@ describe("parseWorldParams", () => {
code: null,
eager: false,
view: "world",
+ stats: false,
});
});
@@ -32,6 +33,7 @@ describe("parseWorldParams", () => {
code: "demo",
eager: true,
view: "world",
+ stats: false,
});
});
@@ -49,6 +51,11 @@ describe("parseWorldParams", () => {
expect(parseWorldParams("?view").view).toBe("world");
});
+ it("shows the stats only for stats=1", () => {
+ expect(parseWorldParams("?stats=1").stats).toBe(true);
+ expect(parseWorldParams("?stats").stats).toBe(false);
+ });
+
it("is eager only for eager=1", () => {
expect(parseWorldParams("?eager=0").eager).toBe(false);
expect(parseWorldParams("?eager=true").eager).toBe(false);
@@ -65,6 +72,7 @@ describe("parseWorldParams", () => {
code: null,
eager: false,
view: "world",
+ stats: false,
});
expect(parseWorldParams("?rooms=-5")).toMatchObject({ rooms: 1 });
expect(parseWorldParams("?rooms=20junk&seed=1.5")).toMatchObject({
diff --git a/apps/web/src/params.ts b/apps/web/src/params.ts
index e779a76..80ce327 100644
--- a/apps/web/src/params.ts
+++ b/apps/web/src/params.ts
@@ -16,6 +16,8 @@ type WorldParams = {
readonly eager: boolean;
/** `?view=map`: the whole world as a 2D map, without WebGL. */
readonly view: "world" | "map";
+ /** `?stats=1`: show the renderer's numbers from the start (I toggles them). */
+ readonly stats: boolean;
};
/** Random and preset worlds without a `?seed`. */
@@ -62,6 +64,7 @@ const parseWorldParams = (search: string): WorldParams => {
code: code !== null && isExampleName(code) ? code : null,
eager: params.get("eager") === "1",
view: params.get("view") === "map" ? "map" : "world",
+ stats: params.get("stats") === "1",
};
};
diff --git a/apps/web/src/pose-feed.test.ts b/apps/web/src/pose-feed.test.ts
index bc1e458..3743b8d 100644
--- a/apps/web/src/pose-feed.test.ts
+++ b/apps/web/src/pose-feed.test.ts
@@ -30,4 +30,16 @@ describe("createPoseFeed", () => {
expect(seen).toEqual([1, 2]);
expect(feed.get()?.position.x).toBe(3);
});
+
+ it("forgets the latest pose on clear and tells subscribers", () => {
+ const feed = createPoseFeed();
+ feed.publish(pose(1));
+ let calls = 0;
+ feed.subscribe(() => {
+ calls += 1;
+ });
+ feed.clear();
+ expect(feed.get()).toBeNull();
+ expect(calls).toBe(1);
+ });
});
diff --git a/apps/web/src/pose-feed.ts b/apps/web/src/pose-feed.ts
index 69e91ed..8c47590 100644
--- a/apps/web/src/pose-feed.ts
+++ b/apps/web/src/pose-feed.ts
@@ -1,36 +1,12 @@
import type { Placement } from "@repo/types";
-/**
- * Where the player is, handed from the render loop to the overview map
- * without rendering the app (which would re-render the whole scene): the
- * Player publishes, the open map subscribes through `useSyncExternalStore`.
- */
-type PoseFeed = {
- readonly publish: (pose: Placement) => void;
- readonly subscribe: (listener: () => void) => () => void;
- /** The latest pose, the same object until the next publish. */
- readonly get: () => Placement | null;
-};
+import { createFeed } from "~/feed";
+import type { Feed } from "~/feed";
-const createPoseFeed = (): PoseFeed => {
- let latest: Placement | null = null;
- const listeners = new Set<() => void>();
- return {
- publish: (pose) => {
- latest = pose;
- for (const listener of listeners) {
- listener();
- }
- },
- subscribe: (listener) => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- },
- get: () => latest,
- };
-};
+/** Where the player is: the Player publishes, the open maps subscribe. */
+type PoseFeed = Feed;
+
+const createPoseFeed = (): PoseFeed => createFeed();
export { createPoseFeed };
export type { PoseFeed };
diff --git a/apps/web/src/source-request.test.ts b/apps/web/src/source-request.test.ts
index f7f5028..4bf928b 100644
--- a/apps/web/src/source-request.test.ts
+++ b/apps/web/src/source-request.test.ts
@@ -47,6 +47,7 @@ describe("showsLanding", () => {
"?code=bogus",
"?eager=1",
"?view=map",
+ "?stats=1",
])("opens a world directly for %s", (search) => {
expect(showsLanding(NONE, search)).toBe(false);
});
diff --git a/apps/web/src/source-request.ts b/apps/web/src/source-request.ts
index 285381f..34b8350 100644
--- a/apps/web/src/source-request.ts
+++ b/apps/web/src/source-request.ts
@@ -35,6 +35,7 @@ const WORLD_KEYS: readonly string[] = [
"code",
"eager",
"view",
+ "stats",
];
/**
diff --git a/apps/web/src/stats-line.tsx b/apps/web/src/stats-line.tsx
new file mode 100644
index 0000000..0828508
--- /dev/null
+++ b/apps/web/src/stats-line.tsx
@@ -0,0 +1,24 @@
+import type { RenderSample } from "@repo/renderer/stats";
+import { useSyncExternalStore } from "react";
+
+import type { Feed } from "~/feed";
+import { statsText } from "~/stats-text";
+
+type StatsLineProps = {
+ /** Filled from the render loop about twice a second. */
+ readonly feed: Feed;
+ /** Areas generated so far. */
+ readonly areas: number;
+};
+
+/** The renderer's numbers, bottom right; only this line re-renders per sample. */
+const StatsLine = ({ feed, areas }: StatsLineProps) => {
+ const sample = useSyncExternalStore(feed.subscribe, feed.get);
+ return (
+
+ {statsText(sample, areas)}
+
+ );
+};
+
+export { StatsLine };
diff --git a/apps/web/src/stats-text.test.ts b/apps/web/src/stats-text.test.ts
new file mode 100644
index 0000000..bb4808f
--- /dev/null
+++ b/apps/web/src/stats-text.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import { statsText } from "./stats-text";
+
+const sample = {
+ calls: 312,
+ triangles: 184_321,
+ geometries: 420,
+ textures: 36,
+ frameMs: 16.666,
+};
+
+describe("statsText", () => {
+ it("names the areas alone before the first sample", () => {
+ expect(statsText(null, 1)).toBe("1 area");
+ expect(statsText(null, 3)).toBe("3 areas");
+ });
+
+ it("shortens large counts and rounds the frame time", () => {
+ expect(statsText(sample, 9)).toBe(
+ "312 draws · 184k tris · 420 geo · 36 tex · 9 areas · 16.7 ms/frame"
+ );
+ });
+
+ it("writes millions with one decimal", () => {
+ expect(statsText({ ...sample, triangles: 1_234_567 }, 1)).toContain(
+ "1.2M tris · 420 geo · 36 tex · 1 area"
+ );
+ expect(statsText({ ...sample, triangles: 999_500 }, 1)).toContain(
+ "1.0M tris"
+ );
+ expect(statsText({ ...sample, triangles: 999_499 }, 1)).toContain(
+ "999k tris"
+ );
+ });
+});
diff --git a/apps/web/src/stats-text.ts b/apps/web/src/stats-text.ts
new file mode 100644
index 0000000..3389218
--- /dev/null
+++ b/apps/web/src/stats-text.ts
@@ -0,0 +1,34 @@
+import type { RenderSample } from "@repo/renderer/stats";
+
+/** `420`, `184k`, `1.2M`: short enough to read while walking. */
+const shortCount = (count: number): string => {
+ if (count < 1000) {
+ return String(count);
+ }
+ // 999 500 rounds to 1000k: it reads as 1.0M instead.
+ const thousands = Math.round(count / 1000);
+ return thousands < 1000
+ ? `${thousands}k`
+ : `${(count / 1_000_000).toFixed(1)}M`;
+};
+
+/**
+ * `312 draws · 184k tris · 420 geo · 36 tex · 9 areas · 16.7 ms/frame`, or
+ * the areas alone before the first sample.
+ */
+const statsText = (sample: RenderSample | null, areas: number): string => {
+ const built = areas === 1 ? "1 area" : `${areas} areas`;
+ if (sample === null) {
+ return built;
+ }
+ return [
+ `${shortCount(sample.calls)} draws`,
+ `${shortCount(sample.triangles)} tris`,
+ `${shortCount(sample.geometries)} geo`,
+ `${shortCount(sample.textures)} tex`,
+ built,
+ `${sample.frameMs.toFixed(1)} ms/frame`,
+ ].join(" · ");
+};
+
+export { statsText };
diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx
index 937a1b1..eb8d5ea 100644
--- a/apps/web/src/world-app.tsx
+++ b/apps/web/src/world-app.tsx
@@ -2,11 +2,14 @@ import { Canvas } from "@react-three/fiber";
import { PointerLook } from "@repo/renderer/controls";
import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
+import { RenderStats } from "@repo/renderer/stats";
+import type { RenderSample } from "@repo/renderer/stats";
import { World } from "@repo/renderer/world";
import type { Target } from "@repo/world-generator/interaction";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { choiceDigit } from "~/choice-keys";
+import { createFeed } from "~/feed";
import { Hud } from "~/hud";
import { mapKeyAction } from "~/map-keys";
import { EMPTY_VISITS, visit, visitedIn } from "~/map-model";
@@ -16,6 +19,7 @@ import { createPoseFeed } from "~/pose-feed";
import { getQuality, setQuality, toggleQuality, useQuality } from "~/quality";
import { SourcePanel } from "~/source-panel";
import { sourceView } from "~/source-view";
+import { StatsLine } from "~/stats-line";
import { useAreaWorld, zoneProgress } from "~/use-area-world";
import { useNavigation } from "~/use-navigation";
import { breadcrumbOf, describeRoom, promptOf } from "~/world-from-code";
@@ -25,6 +29,8 @@ import type { WorldSetup } from "~/world-setup";
// Fed from the render loop and read only by the open maps, never by the app.
const feed = createPoseFeed();
+// Likewise for the renderer's numbers, read only by the open stats line.
+const statsFeed = createFeed();
const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
const {
@@ -45,6 +51,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
const [sourceOpen, setSourceOpen] = useState(false);
const [mapOpen, setMapOpen] = useState(true);
const [worldMapOpen, setWorldMapOpen] = useState(false);
+ const [statsOpen, setStatsOpen] = useState(initial.stats);
const [visits, setVisits] = useState(EMPTY_VISITS);
const quality = useQuality();
// Generated once per seed, then grown an area at a time: it never
@@ -84,8 +91,15 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}
}, [target, ensurePortal, generated.world]);
- // N: next seed (the only thing that regenerates the world) and Q: the
- // rendering quality, in any pointer state. Backspace and R navigate, E
+ // A new seed or a reopened line starts without the last sample, which
+ // described another world or an earlier moment.
+ useEffect(() => {
+ statsFeed.clear();
+ }, [seed, statsOpen]);
+
+ // N: next seed (the only thing that regenerates the world), Q: the
+ // rendering quality and I: the renderer's numbers, in any pointer state.
+ // Backspace and R navigate, E
// shows the source and M the local map, only while walking, so a free
// pointer leaves them alone; so do 1 to 9, which pick a candidate at a
// choice portal (its area is generated first, so the pick lands). Tab
@@ -115,6 +129,8 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
setSeed(nextSeed);
} else if (event.code === "KeyQ") {
setQuality(toggleQuality(getQuality()));
+ } else if (event.code === "KeyI") {
+ setStatsOpen((open) => !open);
} else if (locked && event.code === "Backspace") {
event.preventDefault();
back();
@@ -213,6 +229,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}}
>
+ {statsOpen && }
{
{locked && sourceOpen && sources !== null && (
)}
+ {statsOpen && generated.world !== null && (
+
+ )}
({
+ render: { calls, triangles: calls * 100 },
+ memory: { geometries: 40, textures: 6 },
+});
+
+describe("createStatsWindow", () => {
+ it("samples nothing before the window has passed", () => {
+ const stats = createStatsWindow(500);
+ expect(stats.tick(1000, info(1))).toBeNull();
+ expect(stats.tick(1250, info(2))).toBeNull();
+ expect(stats.tick(1499, info(3))).toBeNull();
+ });
+
+ it("samples the last frame's counts and the mean frame interval at the edge", () => {
+ const stats = createStatsWindow(500);
+ stats.tick(1000, info(1));
+ stats.tick(1250, info(2));
+ stats.tick(1400, info(3));
+ expect(stats.tick(1500, info(4))).toEqual({
+ calls: 4,
+ triangles: 400,
+ geometries: 40,
+ textures: 6,
+ frameMs: 500 / 3,
+ });
+ });
+
+ it("divides by intervals, not ticks, in the first window", () => {
+ const stats = createStatsWindow(500);
+ stats.tick(1000, info(1));
+ stats.tick(1250, info(2));
+ expect(stats.tick(1500, info(3))).toMatchObject({ frameMs: 250 });
+ });
+
+ it("starts a new window after each sample", () => {
+ const stats = createStatsWindow(500);
+ stats.tick(0, info(1));
+ expect(stats.tick(500, info(2))).not.toBeNull();
+ expect(stats.tick(900, info(3))).toBeNull();
+ expect(stats.tick(1000, info(4))).toMatchObject({ calls: 4, frameMs: 250 });
+ });
+});
diff --git a/packages/renderer/src/stats-window.ts b/packages/renderer/src/stats-window.ts
new file mode 100644
index 0000000..cad74d1
--- /dev/null
+++ b/packages/renderer/src/stats-window.ts
@@ -0,0 +1,55 @@
+/** What the renderer drew and holds, sampled once per window. */
+type RenderSample = {
+ /** Draw calls of the last frame, every pass of the post-processing included. */
+ readonly calls: number;
+ readonly triangles: number;
+ /** Geometries and textures alive on the GPU. */
+ readonly geometries: number;
+ readonly textures: number;
+ /** Mean time between frames over the window, in milliseconds: not render cost. */
+ readonly frameMs: number;
+};
+
+/** The parts of `renderer.info` a sample reads. */
+type RenderInfo = {
+ readonly render: { readonly calls: number; readonly triangles: number };
+ readonly memory: { readonly geometries: number; readonly textures: number };
+};
+
+type StatsWindow = {
+ /** Counts a frame at `now`; a sample once `everyMs` have passed, else null. */
+ readonly tick: (now: number, info: RenderInfo) => RenderSample | null;
+};
+
+/** The window starts on the first tick, so nothing reads a clock up front. */
+const createStatsWindow = (everyMs: number): StatsWindow => {
+ let started: number | null = null;
+ let frames = 0;
+ return {
+ tick: (now, info) => {
+ // The first tick only opens the window: no frame interval precedes it.
+ if (started === null) {
+ started = now;
+ return null;
+ }
+ frames += 1;
+ const elapsed = now - started;
+ if (elapsed < everyMs) {
+ return null;
+ }
+ const sample = {
+ calls: info.render.calls,
+ triangles: info.render.triangles,
+ geometries: info.memory.geometries,
+ textures: info.memory.textures,
+ frameMs: elapsed / frames,
+ };
+ started = now;
+ frames = 0;
+ return sample;
+ },
+ };
+};
+
+export { createStatsWindow };
+export type { RenderSample };
diff --git a/packages/renderer/src/stats.tsx b/packages/renderer/src/stats.tsx
new file mode 100644
index 0000000..606706f
--- /dev/null
+++ b/packages/renderer/src/stats.tsx
@@ -0,0 +1,51 @@
+import { useFrame, useThree } from "@react-three/fiber";
+import { useEffect, useMemo } from "react";
+
+import { createStatsWindow } from "./stats-window";
+import type { RenderSample } from "./stats-window";
+
+type RenderStatsProps = {
+ /** Called once per window, never per frame, so the page can afford a render. */
+ readonly onSample: (sample: RenderSample) => void;
+ readonly everyMs?: number;
+};
+
+const DEFAULT_WINDOW_MS = 500;
+
+/**
+ * Samples `renderer.info` inside the Canvas while mounted. The renderer
+ * resets its counts on every render call and the effect composer renders
+ * several passes a frame, so it stops resetting on its own: this
+ * default-priority subscriber runs before the composer's render, reads the
+ * totals of the frame before and resets them.
+ */
+const RenderStats = ({
+ onSample,
+ everyMs = DEFAULT_WINDOW_MS,
+}: RenderStatsProps) => {
+ const get = useThree((state) => state.get);
+ const sampler = useMemo(() => createStatsWindow(everyMs), [everyMs]);
+
+ useEffect(() => {
+ const { info } = get().gl;
+ const before = info.autoReset;
+ info.autoReset = false;
+ info.reset();
+ return () => {
+ info.autoReset = before;
+ };
+ }, [get]);
+
+ useFrame(({ gl }) => {
+ const sample = sampler.tick(performance.now(), gl.info);
+ if (sample !== null) {
+ onSample(sample);
+ }
+ gl.info.reset();
+ });
+
+ return null;
+};
+
+export { RenderStats };
+export type { RenderSample } from "./stats-window";