@zkmake/three-meter 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,38 @@
1
1
  # @zkmake/three-meter
2
2
 
3
- Frame metrics for a three.js renderer: FPS, CPU ms, GPU ms, draw calls, render passes, triangles,
4
- geometries, textures and programs, with a small dockable HUD. Zero dependencies. Works with
5
- `WebGLRenderer` and `WebGPURenderer`, with or without React.
3
+ Frame metrics for a three.js renderer, with a small dockable HUD: FPS, CPU and GPU time, stutter
4
+ (1% low, p99 frame time, hitches), draw calls, render passes, triangles, and the geometries,
5
+ textures and shaders in GPU memory. Zero dependencies. Works with `WebGLRenderer` and
6
+ `WebGPURenderer`, with or without React.
6
7
 
7
8
  ```sh
8
9
  bun add -d @zkmake/three-meter # or npm i -D / pnpm add -D
9
10
  ```
10
11
 
11
12
  Live demo: [three-meter.pages.dev](https://three-meter.pages.dev/), with a vanilla three and a
12
- React Three Fiber take on the same scene. Add `?webgpu` for the WebGPU renderer and `?count=5000` to
13
- load the scene up.
13
+ React Three Fiber take on the same scene. Add `?webgpu` for the WebGPU renderer and `?count=30000` to
14
+ load the scene up past the demo's triangle budget.
14
15
 
15
16
  <p align="center">
16
17
  <img
17
18
  src="https://raw.githubusercontent.com/zkmake/three-meter/main/docs/hud-full.png"
18
19
  width="320"
19
- alt="The HUD in full mode, dark theme. Option rows for theme (light, system, dark), dim on leave, explain metrics and a copy-report button; FPS, CPU and GPU sparklines with live values; then one row per metric, each with an icon, its value and a checkbox choosing whether the compact card shows it (FPS, calls, CPU, GPU, 1% low, frame p99, hitches, triangles, lines, points, render passes, geometries, textures, shaders); and a footer with the three-meter version, the three revision, a WebGL2 backend badge and the GPU name. Two discs beside it: a drag grip and the compact/full toggle."
20
+ alt="The HUD in full mode, dark theme. Option rows for theme (light, system, dark), dim on leave, explain metrics and a copy-report button; FPS, CPU and GPU sparklines with live values, the FPS graph showing a dashed budget line; then one row per metric, each with an icon, its value and a checkbox choosing whether the compact card shows it (FPS, calls, CPU, GPU, 1% low, frame p99, hitches, triangles, lines, points, render passes, geometries, textures, shaders), with the triangle count in amber for being over its budget; and a footer with the three-meter version, the three revision, a WebGL2 backend badge and the GPU name. Two discs beside it: a drag grip and the compact/full toggle."
20
21
  />
21
22
  </p>
22
23
 
23
- The sampler and the card are separate components, so toggling the HUD never remounts your canvas.
24
- GPU time is measured on WebGPU as well as WebGL. The counters are draw calls, render passes and
25
- resource counts, which is what you watch when a scene is instanced. The card docks to a screen edge,
26
- remembers where you put it, and hides its controls until the pointer comes near.
24
+ - **Spot problems at a glance.** Values past their budget turn amber, and every metric explains
25
+ itself on hover, so you don't need to know what a p99 is to see that one is bad.
26
+ - **See stutter, not just averages.** 1% low, p99 frame time and hitch counts catch the frame that
27
+ drops every few seconds while the FPS average still reads 60.
28
+ - **Real GPU time** from timer queries on WebGPU, and on WebGL in Chrome and Edge, not an estimate
29
+ from frame intervals.
30
+ - **Find what's expensive.** A breakdown of draw calls and triangles by mesh and material shows
31
+ which objects to instance or merge.
32
+ - **One-click bug reports.** Copy versions, backend, GPU and the current numbers as Markdown.
33
+ - **Stays out of the way.** The sampler and the card are separate components, so toggling the HUD
34
+ never remounts your canvas. The card docks to a screen edge, remembers where you put it, and hides
35
+ its controls until the pointer comes near.
27
36
 
28
37
  ## Entry points
29
38
 
@@ -90,66 +99,34 @@ Canvas is its own React root. To run two canvases on one page, pass the same `st
90
99
  | `injectStyles` | `true` | Append the stylesheet once per document. |
91
100
  | `refreshHz` | `10` | Repaint rate. |
92
101
  | `budgets` | timing defaults | Limits past which a value turns amber. See [Budgets](#budgets). |
102
+ | `label` | `"Performance"` | Accessible name of the panel. |
93
103
 
94
104
  `PerformanceMonitor` and `PerfSampler` take `trackGPU` (default true), `gpuQueryPoolSize` (default 5),
95
105
  `historySize` (default 120) and `frameStatsSize` (default 1000).
96
106
 
97
- ## Theme
98
-
99
- The HUD ships a dark and a light palette. Two layers decide which shows:
100
-
101
- 1. **The panel's own pick.** The full view has a light / system / dark row. A pick there is kept
102
- with the other settings under `storageKey` and wins while set.
103
- 2. **Your `theme` option.** `dark`, `light`, or `system` (the default), which follows the OS and
104
- switches when it does. Applies whenever the person using the HUD hasn't picked anything.
105
-
106
- Change your layer later from the handle, or from the `PerfHud` prop in React, which applies without
107
- remounting:
108
-
109
- ```ts
110
- const hud = mountPerfHud(monitor, { theme: "system" });
111
- hud.setTheme("light"); // your layer: "dark" | "light" | "system"
112
- hud.getTheme(); // your layer, as asked for
113
- hud.settings.theme; // the panel's pick, or null
114
- hud.settings.setTheme(null); // clear the pick so your layer applies again
115
- hud.theme.effective; // whichever layer is in force
116
- hud.theme.resolved; // "dark" | "light", what is on screen right now
117
- hud.theme.subscribe(() => syncMyPageWith(hud.theme.resolved));
118
- ```
119
-
120
- The resolved theme lands on `data-theme` of `.perf-hud` and `.perf-monitor`. To restyle either
121
- palette, override the `--perf-*` custom properties (`bg`, `fg`, `fg-dim`, `muted`, `row`,
122
- `border`, `accent`, `warn`, `shadow`) on those selectors. `warn` is the over-budget amber.
107
+ ## Reading the panel
123
108
 
124
- ## GPU timing
109
+ Every row has an icon, its value, and a checkbox on the right that puts it in the compact HUD. Hover
110
+ a label for a one-line explanation of the metric and what a bad reading means, or tick **explain
111
+ metrics** to show them all under the rows (also works on touch).
125
112
 
126
- On WebGL2 the monitor uses `EXT_disjoint_timer_query_webgl2`, which Chrome and Edge expose. Safari
127
- and Firefox don't, so the GPU row shows `—` and `sample.gpu.available` is `false`.
113
+ ## Stutter
128
114
 
129
- On WebGPU, construct the renderer with `trackTimestamp: true`. Timings resolve a frame or two late.
115
+ Average FPS hides a hitch every few seconds. Three rows in the full view catch it, and any of them
116
+ can be ticked into the compact HUD:
130
117
 
131
- ## Environment
118
+ - **1% low**: mean FPS across the slowest 1% of frames.
119
+ - **Frame p99**: 99% of frames finish within this many ms.
120
+ - **Hitches**: frames over twice the median frame time.
132
121
 
133
- The full view's footer has two rows: the three-meter version (linked to its release notes) and the
134
- three revision, then the backend and the GPU name. An amber `WebGL2 fallback` badge means
135
- `WebGPURenderer` couldn't get WebGPU. Its checkbox (off by default) shows the footer in the compact
136
- HUD too. The same data is on the monitor:
122
+ They cover the last `frameStatsSize` frames (1000 by default, about 16 s at 60 Hz). Gaps over a
123
+ second, like a hidden tab, are left out. Read them with `monitor.getFrameStats()`:
137
124
 
138
125
  ```ts
139
- monitor.getEnvironment();
140
- // { three: "186", backend: "webgpu", fallback: false, gpu: "Apple metal-3" }
126
+ monitor.getFrameStats();
127
+ // { frames: 1000, lowFps: 97.6, p99Ms: 10.2, hitches: 3 }
141
128
  ```
142
129
 
143
- `backend` is `null` until `WebGPURenderer.init()` settles. `gpu` comes from WebGPU's adapter info or
144
- WebGL's unmasked renderer string, and is `null` where the browser hides it. Chrome's WebGPU gives
145
- vendor and architecture, not the model.
146
-
147
- ## Reading the panel
148
-
149
- Every row has an icon, its value, and a checkbox on the right that puts it in the compact HUD. Hover
150
- a label for a one-line explanation of the metric and what a bad reading means, or tick **explain
151
- metrics** to show them all under the rows (also works on touch).
152
-
153
130
  ## Budgets
154
131
 
155
132
  A value past its budget turns amber in the full view and the compact HUD, and its tooltip names the
@@ -167,47 +144,108 @@ hud.setBudgets({ gpu: null }); // null drops a default; false drops them all
167
144
 
168
145
  `PerfHud` takes the same `budgets` prop and applies changes live.
169
146
 
170
- ## Stutter
147
+ ## Top costs
171
148
 
172
- Average FPS hides a hitch every few seconds. Three rows in the full view catch it, and any of them
173
- can be ticked into the compact HUD:
149
+ The HUD tells you there are 400 draw calls; **top costs** tells you which objects they come from.
150
+ Tick it in the full view for the five biggest costs in the main render pass, grouped by **meshes** or
151
+ **materials**, sorted by draw calls or triangles. Click a row to log its three objects to the
152
+ console.
174
153
 
175
- - **1% low**: mean FPS across the slowest 1% of frames.
176
- - **Frame p99**: 99% of frames finish within this many ms.
177
- - **Hitches**: frames over twice the median frame time.
154
+ ```text
155
+ calls tris
156
+ tree ×300 300 9,600 ← 300 separate meshes: instance or merge them
157
+ house 6 12 ← one draw per material in the array
158
+ dome 2 7,936 ← transparent and double-sided draws twice
159
+ grass ×20,000 1 40,000 ← already instanced
160
+ ```
178
161
 
179
- They cover the last `frameStatsSize` frames (1000 by default, about 16 s at 60 Hz). Gaps over a
180
- second, like a hidden tab, are left out. Read them with `monitor.getFrameStats()`:
162
+ Meshes with the same name and geometry share a row, so unmerged copies stand out. The numbers are
163
+ estimated from the scene graph the way three walks it: hidden objects, other layers and anything
164
+ outside the camera's frustum are left out. Against three's own `renderer.info` on a test scene they
165
+ matched exactly. Shadow maps and post-processing passes aren't included; the main pass is the render
166
+ call that drew the most, so a full-screen quad rendered last doesn't stand in for the scene. It
167
+ walks the scene only while open, twice a second.
181
168
 
182
169
  ```ts
183
- monitor.getFrameStats();
184
- // { frames: 1000, lowFps: 97.6, p99Ms: 10.2, hitches: 3 }
170
+ monitor.getSceneCost();
171
+ // { calls: 309, triangles: 57548, meshes: [{ label: "tree", calls: 300, … }, …], materials: […] }
185
172
  ```
186
173
 
187
174
  ## Bug reports
188
175
 
189
176
  The **report** row's copy button puts a Markdown snapshot on the clipboard: versions, backend, GPU,
190
- the current metrics, the stutter stats, the viewport and the user agent. Paste it into an issue.
177
+ the current metrics, the stutter stats, the top three meshes, the viewport and the user agent. Paste it into an issue.
191
178
  `formatReport(monitor)` from `@zkmake/three-meter/ui` returns the same text.
192
179
 
193
180
  ```text
194
- three-meter v0.5.0 · three r186 · WebGL2 · Apple M4 Max
181
+ three-meter v0.7.0 · three r186 · WebGL2 · Apple M4 Max
195
182
  FPS 120 · 1% low 98 · p99 10.2 ms · hitches 3 / 1,000 frames
196
183
  CPU 0.2 ms · GPU 0.7 ms
197
184
  Calls 1 · passes 1 · triangles 24,000 · lines 0 · points 0
198
185
  Geometries 1 · textures 1 · shaders 1
186
+ Top: tree ×300 (300 calls, 9,600 tris) · house (6 calls, 12 tris) · dome (2 calls, 7,936 tris)
199
187
  Viewport 1280×720 @2x
200
188
  Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) …
201
189
  ```
202
190
 
191
+ ## Environment
192
+
193
+ The full view's footer has two rows: the three-meter version (linked to its release notes) and the
194
+ three revision, then the backend and the GPU name. An amber `WebGL2 fallback` badge means
195
+ `WebGPURenderer` couldn't get WebGPU. Its checkbox (off by default) shows the footer in the compact
196
+ HUD too. The same data is on the monitor:
197
+
198
+ ```ts
199
+ monitor.getEnvironment();
200
+ // { three: "186", backend: "webgpu", fallback: false, gpu: "Apple metal-3" }
201
+ ```
202
+
203
+ `backend` is `null` until `WebGPURenderer.init()` settles. `gpu` comes from WebGPU's adapter info or
204
+ WebGL's unmasked renderer string, and is `null` where the browser hides it. Chrome's WebGPU gives
205
+ vendor and architecture, not the model.
206
+
207
+ ## GPU timing
208
+
209
+ On WebGL2 the monitor uses `EXT_disjoint_timer_query_webgl2`, which Chrome and Edge expose. Safari
210
+ and Firefox don't, so the GPU row shows `—` and `sample.gpu.available` is `false`.
211
+
212
+ On WebGPU, construct the renderer with `trackTimestamp: true`. Timings resolve a frame or two late.
213
+
214
+ ## Theme
215
+
216
+ The HUD ships a dark and a light palette. Two layers decide which shows:
217
+
218
+ 1. **The panel's own pick.** The full view has a light / system / dark row. A pick there is kept
219
+ with the other settings under `storageKey` and wins while set.
220
+ 2. **Your `theme` option.** `dark`, `light`, or `system` (the default), which follows the OS and
221
+ switches when it does. Applies whenever the person using the HUD hasn't picked anything.
222
+
223
+ Change your layer later from the handle, or from the `PerfHud` prop in React, which applies without
224
+ remounting:
225
+
226
+ ```ts
227
+ const hud = mountPerfHud(monitor, { theme: "system" });
228
+ hud.setTheme("light"); // your layer: "dark" | "light" | "system"
229
+ hud.getTheme(); // your layer, as asked for
230
+ hud.settings.theme; // the panel's pick, or null
231
+ hud.settings.setTheme(null); // clear the pick so your layer applies again
232
+ hud.theme.effective; // whichever layer is in force
233
+ hud.theme.resolved; // "dark" | "light", what is on screen right now
234
+ hud.theme.subscribe(() => syncMyPageWith(hud.theme.resolved));
235
+ ```
236
+
237
+ The resolved theme lands on `data-theme` of `.perf-hud` and `.perf-monitor`. To restyle either
238
+ palette, override the `--perf-*` custom properties (`bg`, `fg`, `fg-dim`, `muted`, `row`,
239
+ `border`, `accent`, `warn`, `shadow`) on those selectors. `warn` is the over-budget amber.
240
+
203
241
  ## Examples
204
242
 
205
243
  [`examples/site`](examples/site) is what runs at [three-meter.pages.dev](https://three-meter.pages.dev/):
206
244
  one Vite app with both integrations of the same scene, swapped from the header.
207
245
  [`src/demos/vanilla.ts`](examples/site/src/demos/vanilla.ts) is plain three with `mountPerfHud`;
208
246
  [`src/demos/r3f.tsx`](examples/site/src/demos/r3f.tsx) is React Three Fiber with `PerfSampler` and
209
- `PerfHud`. `?r3f` opens on Fiber, `?webgpu` uses `WebGPURenderer` in either, `?count=` scales the
210
- scene. The header's theme toggle sets the page theme and the HUD's `theme` layer together; the row
247
+ `PerfHud`. Both set a 250K triangle budget on top of the timing defaults. `?r3f` opens on Fiber,
248
+ `?webgpu` uses `WebGPURenderer` in either, `?count=` scales the scene. The header's theme toggle sets the page theme and the HUD's `theme` layer together; the row
211
249
  inside the panel overrides the HUD alone. Run `bun run dev` inside it.
212
250
 
213
251
  ## Shipping it
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as PerfRenderer, c as RenderCounts, d as TimingMetric, i as GpuTiming, l as ResourceCounts, n as Environment, o as PerformanceMonitorOptions, r as FrameStats, s as RenderBackend, t as PerformanceMonitor, u as Sample } from "./performance-monitor-CBN05_BC.js";
1
+ import { a as GpuTiming, c as RenderBackend, d as Sample, f as SceneCost, i as FrameStats, l as RenderCounts, n as CostEntry, o as PerfRenderer, p as TimingMetric, r as Environment, s as PerformanceMonitorOptions, t as PerformanceMonitor, u as ResourceCounts } from "./performance-monitor-DgHxeQ2X.js";
2
2
  //#region src/core/ring-buffer.d.ts
3
3
  /**
4
4
  * Fixed-capacity numeric ring buffer. Backs the per-frame timing history the
@@ -32,5 +32,5 @@ type AnimationLoop<Args extends unknown[]> = (...args: Args) => void;
32
32
  */
33
33
  declare function wrapAnimationLoop<Args extends unknown[]>(monitor: PerformanceMonitor, loop: AnimationLoop<Args>): AnimationLoop<Args>;
34
34
  //#endregion
35
- export { type Environment, type FrameStats, type GpuTiming, type PerfRenderer, PerformanceMonitor, type PerformanceMonitorOptions, type RenderBackend, type RenderCounts, type ResourceCounts, RingBuffer, type Sample, type TimingMetric, wrapAnimationLoop };
35
+ export { type CostEntry, type Environment, type FrameStats, type GpuTiming, type PerfRenderer, PerformanceMonitor, type PerformanceMonitorOptions, type RenderBackend, type RenderCounts, type ResourceCounts, RingBuffer, type Sample, type SceneCost, type TimingMetric, wrapAnimationLoop };
36
36
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as RingBuffer, t as PerformanceMonitor } from "./performance-monitor-yxsxs57v.js";
1
+ import { n as RingBuffer, t as PerformanceMonitor } from "./performance-monitor-DsBp13ts.js";
2
2
  //#region src/core/wrap-animation-loop.ts
3
3
  /**
4
4
  * Wrap a `renderer.setAnimationLoop` callback so each tick is one frame:
@@ -486,6 +486,7 @@ const dockPanel = (element, options = {}) => {
486
486
  //#endregion
487
487
  //#region src/ui/icons.ts
488
488
  const ICON_MARKUP = {
489
+ flame: "<path d=\"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z\"/>",
489
490
  activity: "<path d=\"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2\"/>",
490
491
  box: "<path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>",
491
492
  circleDot: "<circle cx=\"12\" cy=\"12\" r=\"10\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/>",
@@ -526,7 +527,7 @@ const createIcon = (name, className) => {
526
527
  };
527
528
  //#endregion
528
529
  //#region package.json
529
- var version = "0.7.0";
530
+ var version = "0.8.0";
530
531
  //#endregion
531
532
  //#region src/ui/clipboard.ts
532
533
  /**
@@ -604,7 +605,7 @@ const METRIC_HELP = {
604
605
  //#region src/ui/report.ts
605
606
  /**
606
607
  * A plain-text snapshot of the monitor for bug reports: versions, backend and
607
- * GPU, the current sample, stutter stats and the page. Fenced as a `text`
608
+ * GPU, the current sample, stutter stats, the scene's top meshes and the page. Fenced as a `text`
608
609
  * code block so GitHub and Slack keep the line breaks.
609
610
  */
610
611
  const BACKEND_LABELS = {
@@ -619,7 +620,13 @@ const backendLabel = (environment) => {
619
620
  return environment.fallback ? `${label} fallback` : label;
620
621
  };
621
622
  const ms = (value) => `${value.toFixed(1)} ms`;
622
- const renderReport = ({ environment, page, sample, stats }) => {
623
+ /** Top meshes by draw calls, three at most: `Top: cubes ×2,000 (1 call, 24K tris) · …`. */
624
+ const topMeshes = (cost) => `Top: ${cost.meshes.slice(0, 3).map((entry) => {
625
+ const copies = entry.instances > 1 ? ` ×${formatCount(entry.instances)}` : "";
626
+ const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
627
+ return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
628
+ }).join(" · ")}`;
629
+ const renderReport = ({ cost, environment, page, sample, stats }) => {
623
630
  const { render, resources } = sample;
624
631
  const heading = [
625
632
  `three-meter v${version}`,
@@ -650,6 +657,7 @@ const renderReport = ({ environment, page, sample, stats }) => {
650
657
  `shaders ${formatCount(resources.programs)}`
651
658
  ].join(" · ")
652
659
  ];
660
+ if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
653
661
  if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
654
662
  return [
655
663
  "```text",
@@ -665,6 +673,7 @@ const readPage = () => typeof window === "undefined" ? null : {
665
673
  };
666
674
  /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
667
675
  const formatReport = (monitor) => renderReport({
676
+ cost: monitor.getSceneCost(),
668
677
  environment: monitor.getEnvironment(),
669
678
  page: readPage(),
670
679
  sample: monitor.getSample(),
@@ -778,6 +787,24 @@ const THEME_OPTIONS = [
778
787
  const RELEASE_URL = `https://github.com/zkmake/three-meter/releases/tag/v${version}`;
779
788
  /** How long the report button says `copied` / `failed` before reading `copy` again. */
780
789
  const COPY_FEEDBACK_MS = 1500;
790
+ /** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
791
+ const COSTS_INTERVAL_MS = 500;
792
+ const COSTS_ROWS = 5;
793
+ const COST_GROUPS = [{
794
+ group: "meshes",
795
+ label: "meshes"
796
+ }, {
797
+ group: "materials",
798
+ label: "materials"
799
+ }];
800
+ const COSTS_HELP = "The scene's biggest draw costs in the main render pass, estimated from the scene graph. Hidden and off-screen objects are left out; shadow and post-processing passes aren't counted. Click a row to log its objects to the console.";
801
+ /** `×2,000` for instances or copies; nothing for a single object. */
802
+ const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
803
+ const costTitle = (entry) => {
804
+ const objects = entry.objects.length;
805
+ const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
806
+ return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
807
+ };
781
808
  const readTiming = (sample, metric) => {
782
809
  switch (metric) {
783
810
  case "fps": return sample.fps;
@@ -947,6 +974,14 @@ var PerformanceView = class {
947
974
  lastPaintAt = 0;
948
975
  resizeObserver;
949
976
  unsubscribe;
977
+ costsOpen = false;
978
+ costGroup = "meshes";
979
+ costSort = "calls";
980
+ lastCostsAt = -Infinity;
981
+ costsBodyEl;
982
+ costsListEl;
983
+ costGroupRadios = /* @__PURE__ */ new Map();
984
+ costSortButtons = /* @__PURE__ */ new Map();
950
985
  constructor(options) {
951
986
  this.monitor = options.monitor;
952
987
  this.settings = options.settings ?? new HudSettings();
@@ -1014,8 +1049,57 @@ var PerformanceView = class {
1014
1049
  for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
1015
1050
  const stats = this.monitor.getFrameStats();
1016
1051
  for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
1052
+ if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
1017
1053
  this.renderFooter();
1018
1054
  }
1055
+ renderCosts() {
1056
+ this.lastCostsAt = performance.now();
1057
+ const cost = this.monitor.getSceneCost();
1058
+ if (!cost) {
1059
+ this.costsListEl.replaceChildren(this.costsNote("No scene rendered yet."));
1060
+ return;
1061
+ }
1062
+ const entries = [...this.costGroup === "meshes" ? cost.meshes : cost.materials];
1063
+ if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
1064
+ if (entries.length === 0) {
1065
+ this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
1066
+ return;
1067
+ }
1068
+ const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
1069
+ const row = document.createElement("button");
1070
+ row.type = "button";
1071
+ row.className = "perf-monitor__cost";
1072
+ row.title = costTitle(entry);
1073
+ const name = document.createElement("span");
1074
+ name.className = "perf-monitor__cost-name";
1075
+ name.textContent = entry.label;
1076
+ const copies = copiesOf(entry);
1077
+ if (copies) {
1078
+ const suffix = document.createElement("span");
1079
+ suffix.className = "perf-monitor__cost-copies";
1080
+ suffix.textContent = ` ${copies}`;
1081
+ name.append(suffix);
1082
+ }
1083
+ const calls = document.createElement("span");
1084
+ calls.className = "perf-monitor__cost-value";
1085
+ calls.textContent = formatCount(entry.calls);
1086
+ const triangles = document.createElement("span");
1087
+ triangles.className = "perf-monitor__cost-value";
1088
+ triangles.textContent = formatCount(entry.triangles);
1089
+ row.append(name, calls, triangles);
1090
+ row.addEventListener("click", () => {
1091
+ console.log(`three-meter: ${entry.label}`, entry.objects);
1092
+ });
1093
+ return row;
1094
+ });
1095
+ this.costsListEl.replaceChildren(...rows);
1096
+ }
1097
+ costsNote(text) {
1098
+ const note = document.createElement("div");
1099
+ note.className = "perf-monitor__costs-note";
1100
+ note.textContent = text;
1101
+ return note;
1102
+ }
1019
1103
  /** The monitor caches the environment once its backend settles; until then this re-reads. */
1020
1104
  renderFooter() {
1021
1105
  const environment = this.monitor.getEnvironment();
@@ -1207,7 +1291,7 @@ var PerformanceView = class {
1207
1291
  this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
1208
1292
  footerBody.append(softwareRow, this.footerHardwareEl);
1209
1293
  footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
1210
- this.element.append(hud, options, graphs, stats, footer);
1294
+ this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
1211
1295
  }
1212
1296
  rebuildHud() {
1213
1297
  const { selection } = this.settings;
@@ -1278,6 +1362,71 @@ var PerformanceView = class {
1278
1362
  applyTheme(this.element, this.theme);
1279
1363
  for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
1280
1364
  }
1365
+ /** "top costs": a toggle row, then meshes / materials and a sortable top five. */
1366
+ buildCosts() {
1367
+ const section = document.createElement("div");
1368
+ section.className = "perf-monitor__section perf-monitor__costs";
1369
+ const toggleRow = document.createElement("label");
1370
+ toggleRow.className = "perf-monitor__row";
1371
+ toggleRow.title = COSTS_HELP;
1372
+ const label = document.createElement("span");
1373
+ label.className = "perf-monitor__label";
1374
+ label.textContent = "top costs";
1375
+ const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
1376
+ this.costsOpen = on;
1377
+ this.costsBodyEl.hidden = !on;
1378
+ if (on) this.renderCosts();
1379
+ });
1380
+ toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
1381
+ this.costsBodyEl = document.createElement("div");
1382
+ this.costsBodyEl.className = "perf-monitor__costs-body";
1383
+ this.costsBodyEl.hidden = true;
1384
+ const head = document.createElement("div");
1385
+ head.className = "perf-monitor__cost perf-monitor__cost--head";
1386
+ const segment = document.createElement("div");
1387
+ segment.className = "perf-monitor__segment";
1388
+ segment.setAttribute("role", "radiogroup");
1389
+ segment.setAttribute("aria-label", "Group costs by");
1390
+ for (const option of COST_GROUPS) {
1391
+ const radio = document.createElement("button");
1392
+ radio.type = "button";
1393
+ radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
1394
+ radio.setAttribute("role", "radio");
1395
+ radio.setAttribute("aria-checked", String(option.group === this.costGroup));
1396
+ radio.textContent = option.label;
1397
+ radio.addEventListener("click", () => {
1398
+ this.costGroup = option.group;
1399
+ for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
1400
+ this.renderCosts();
1401
+ });
1402
+ this.costGroupRadios.set(option.group, radio);
1403
+ segment.append(radio);
1404
+ }
1405
+ head.append(segment);
1406
+ for (const sort of ["calls", "triangles"]) {
1407
+ const button = document.createElement("button");
1408
+ button.type = "button";
1409
+ button.className = "perf-monitor__cost-sort";
1410
+ button.textContent = sort === "calls" ? "calls" : "tris";
1411
+ button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
1412
+ button.setAttribute("aria-pressed", String(sort === this.costSort));
1413
+ button.addEventListener("click", () => {
1414
+ this.costSort = sort;
1415
+ for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
1416
+ this.renderCosts();
1417
+ });
1418
+ this.costSortButtons.set(sort, button);
1419
+ head.append(button);
1420
+ }
1421
+ this.costsListEl = document.createElement("div");
1422
+ this.costsListEl.className = "perf-monitor__costs-list";
1423
+ const hint = document.createElement("span");
1424
+ hint.className = "perf-monitor__hint";
1425
+ hint.textContent = COSTS_HELP;
1426
+ this.costsBodyEl.append(head, this.costsListEl, hint);
1427
+ section.append(toggleRow, this.costsBodyEl);
1428
+ return section;
1429
+ }
1281
1430
  buildReportRow() {
1282
1431
  const row = document.createElement("div");
1283
1432
  row.className = "perf-monitor__row perf-monitor__row--static";
@@ -1574,6 +1723,96 @@ const PERF_HUD_STYLES = `
1574
1723
  outline-offset: 1px;
1575
1724
  }
1576
1725
 
1726
+ /* Top costs: name, calls, triangles. Rows are buttons that log their objects. */
1727
+ .perf-monitor__costs-body {
1728
+ display: flex;
1729
+ flex-direction: column;
1730
+ gap: 1px;
1731
+ padding-top: 2px;
1732
+ }
1733
+
1734
+ .perf-monitor__costs-body[hidden] {
1735
+ display: none;
1736
+ }
1737
+
1738
+ .perf-monitor__cost {
1739
+ /* Rows are buttons, which shrink to their content without a width. */
1740
+ box-sizing: border-box;
1741
+ width: 100%;
1742
+ display: grid;
1743
+ grid-template-columns: minmax(0, 1fr) 5ch 6ch;
1744
+ gap: 6px;
1745
+ align-items: baseline;
1746
+ padding: 2px 6px;
1747
+ border: 0;
1748
+ border-radius: 3px;
1749
+ background: transparent;
1750
+ color: inherit;
1751
+ font: inherit;
1752
+ text-align: left;
1753
+ cursor: pointer;
1754
+ }
1755
+
1756
+ .perf-monitor__cost:not(.perf-monitor__cost--head):hover {
1757
+ background: var(--perf-row);
1758
+ }
1759
+
1760
+ .perf-monitor__cost:focus-visible {
1761
+ outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
1762
+ outline-offset: -1px;
1763
+ }
1764
+
1765
+ .perf-monitor__cost--head {
1766
+ align-items: center;
1767
+ cursor: default;
1768
+ }
1769
+
1770
+ .perf-monitor__cost-name {
1771
+ overflow: hidden;
1772
+ text-overflow: ellipsis;
1773
+ white-space: nowrap;
1774
+ color: var(--perf-fg-dim);
1775
+ }
1776
+
1777
+ .perf-monitor__cost-copies {
1778
+ color: var(--perf-muted);
1779
+ }
1780
+
1781
+ .perf-monitor__cost-value {
1782
+ font-variant-numeric: tabular-nums lining-nums;
1783
+ text-align: right;
1784
+ }
1785
+
1786
+ .perf-monitor__cost-sort {
1787
+ padding: 0;
1788
+ border: 0;
1789
+ background: transparent;
1790
+ color: var(--perf-muted);
1791
+ font: inherit;
1792
+ font-size: 10px;
1793
+ text-align: right;
1794
+ cursor: pointer;
1795
+ }
1796
+
1797
+ .perf-monitor__cost-sort[aria-pressed="true"] {
1798
+ color: var(--perf-fg);
1799
+ text-decoration: underline;
1800
+ text-underline-offset: 2px;
1801
+ }
1802
+
1803
+ .perf-monitor__segment-option--text {
1804
+ width: auto;
1805
+ padding: 0 5px;
1806
+ font: inherit;
1807
+ font-size: 10px;
1808
+ }
1809
+
1810
+ .perf-monitor__costs-note {
1811
+ padding: 2px 6px;
1812
+ color: var(--perf-muted);
1813
+ font-size: 10px;
1814
+ }
1815
+
1577
1816
  .perf-monitor__checkbox {
1578
1817
  flex: none;
1579
1818
  margin: 0;
@@ -1980,4 +2219,4 @@ const mountPerfHud = (monitor, options = {}) => {
1980
2219
  //#endregion
1981
2220
  export { formatReport as a, HudSettings as c, isThemeMode as d, resolveBudgets as f, PerformanceView as i, HudTheme as l, PERF_HUD_STYLES as n, dockPanel as o, injectStyles as r, DEFAULT_STORAGE_KEY as s, mountPerfHud as t, THEME_MODES as u };
1982
2221
 
1983
- //# sourceMappingURL=mount-perf-hud-DqY_tP6s.js.map
2222
+ //# sourceMappingURL=mount-perf-hud-B_cmTG2M.js.map