diff --git a/README.md b/README.md index cb12edb..5318f06 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ world. The world shows what _can_ run, not a trace of what did run. | 1–9 | Pick a callee at a violet choice portal | | Backspace | Back to where this function was entered | | R | Back to the entrance | +| I | Renderer stats (draws, triangles, …) | | Esc | Release the mouse | ## Run it locally diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 9dafa0d..b70df34 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/apps/web/src/feed.ts b/apps/web/src/feed.ts new file mode 100644 index 0000000..315be85 --- /dev/null +++ b/apps/web/src/feed.ts @@ -0,0 +1,38 @@ +/** + * A value handed from the render loop to the overlays without rendering the + * app (which would re-render the whole scene): the loop publishes, an open + * overlay subscribes through `useSyncExternalStore`. + */ +type Feed = { + readonly publish: (value: T) => void; + readonly subscribe: (listener: () => void) => () => void; + /** The latest value, the same object until the next publish. */ + readonly get: () => T | null; + /** Forgets the latest value, so a stale one is never shown. */ + readonly clear: () => void; +}; + +const createFeed = (): Feed => { + let latest: T | null = null; + const listeners = new Set<() => void>(); + const set = (value: T | null) => { + latest = value; + for (const listener of listeners) { + listener(); + } + }; + return { + publish: set, + clear: () => set(null), + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + get: () => latest, + }; +}; + +export { createFeed }; +export type { Feed }; diff --git a/apps/web/src/hud.test.tsx b/apps/web/src/hud.test.tsx index aeb1047..13257d6 100644 --- a/apps/web/src/hud.test.tsx +++ b/apps/web/src/hud.test.tsx @@ -34,4 +34,9 @@ describe("Hud", () => { it("shows the current level while walking", () => { expect(render(true)).toContain("seed 7 · 20 rooms · quality low"); }); + + it("links home to the landing page only before walking", () => { + expect(render(false)).toContain('href="/"'); + expect(render(true)).not.toContain('href="/"'); + }); }); diff --git a/apps/web/src/hud.tsx b/apps/web/src/hud.tsx index 50623a7..b6e02f5 100644 --- a/apps/web/src/hud.tsx +++ b/apps/web/src/hud.tsx @@ -36,6 +36,7 @@ const hints: readonly (readonly [string, string])[] = [ ["R", "back to the entrance"], ["N", "next seed"], ["Q", "toggle quality"], + ["I", "renderer stats"], ["M", "local map"], ["Tab", "world map"], ["Esc", "release the mouse"], @@ -134,6 +135,15 @@ const Hud = ({ } return (
+ {/* 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";