@zkmake/three-meter 0.6.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.
@@ -1,3 +1,36 @@
1
+ //#region src/ui/budgets.ts
2
+ const DEFAULT_TARGET_FPS = 60;
3
+ const FLOORS = /* @__PURE__ */ new Set(["fps", "low"]);
4
+ const resolveBudgets = (budgets) => {
5
+ const targetFps = budgets && budgets.targetFps || DEFAULT_TARGET_FPS;
6
+ const frameMs = 1e3 / targetFps;
7
+ if (budgets === false) return {
8
+ frameMs,
9
+ limits: {}
10
+ };
11
+ const merged = {
12
+ cpu: frameMs,
13
+ fps: targetFps * .95,
14
+ gpu: frameMs,
15
+ low: targetFps / 2,
16
+ p99: frameMs * 1.5,
17
+ ...budgets
18
+ };
19
+ const limits = {};
20
+ for (const [key, limit] of Object.entries(merged)) if (key !== "targetFps" && typeof limit === "number" && Number.isFinite(limit)) limits[key] = limit;
21
+ return {
22
+ frameMs,
23
+ limits
24
+ };
25
+ };
26
+ const isFloor = (key) => FLOORS.has(key);
27
+ /** `null` means the value isn't known yet (no frames, no GPU timer), which is never over. */
28
+ const isOverBudget = (resolved, key, value) => {
29
+ const limit = resolved.limits[key];
30
+ if (limit === void 0 || value === null) return false;
31
+ return isFloor(key) ? value < limit : value > limit;
32
+ };
33
+ //#endregion
1
34
  //#region src/ui/theme.ts
2
35
  const THEME_MODES = [
3
36
  "dark",
@@ -453,6 +486,7 @@ const dockPanel = (element, options = {}) => {
453
486
  //#endregion
454
487
  //#region src/ui/icons.ts
455
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\"/>",
456
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\"/>",
457
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\"/>",
458
492
  circleDot: "<circle cx=\"12\" cy=\"12\" r=\"10\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/>",
@@ -493,7 +527,7 @@ const createIcon = (name, className) => {
493
527
  };
494
528
  //#endregion
495
529
  //#region package.json
496
- var version = "0.6.0";
530
+ var version = "0.8.0";
497
531
  //#endregion
498
532
  //#region src/ui/clipboard.ts
499
533
  /**
@@ -571,7 +605,7 @@ const METRIC_HELP = {
571
605
  //#region src/ui/report.ts
572
606
  /**
573
607
  * A plain-text snapshot of the monitor for bug reports: versions, backend and
574
- * 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`
575
609
  * code block so GitHub and Slack keep the line breaks.
576
610
  */
577
611
  const BACKEND_LABELS = {
@@ -586,7 +620,13 @@ const backendLabel = (environment) => {
586
620
  return environment.fallback ? `${label} fallback` : label;
587
621
  };
588
622
  const ms = (value) => `${value.toFixed(1)} ms`;
589
- 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 }) => {
590
630
  const { render, resources } = sample;
591
631
  const heading = [
592
632
  `three-meter v${version}`,
@@ -617,6 +657,7 @@ const renderReport = ({ environment, page, sample, stats }) => {
617
657
  `shaders ${formatCount(resources.programs)}`
618
658
  ].join(" · ")
619
659
  ];
660
+ if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
620
661
  if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
621
662
  return [
622
663
  "```text",
@@ -632,6 +673,7 @@ const readPage = () => typeof window === "undefined" ? null : {
632
673
  };
633
674
  /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
634
675
  const formatReport = (monitor) => renderReport({
676
+ cost: monitor.getSceneCost(),
635
677
  environment: monitor.getEnvironment(),
636
678
  page: readPage(),
637
679
  sample: monitor.getSample(),
@@ -639,7 +681,7 @@ const formatReport = (monitor) => renderReport({
639
681
  });
640
682
  //#endregion
641
683
  //#region src/ui/sparkline.ts
642
- function drawSparkline(ctx, width, height, values, style) {
684
+ function drawSparkline(ctx, width, height, values, style, guide) {
643
685
  ctx.clearRect(0, 0, width, height);
644
686
  if (values.length < 2) return;
645
687
  let max = 0;
@@ -658,6 +700,17 @@ function drawSparkline(ctx, width, height, values, style) {
658
700
  ctx.closePath();
659
701
  ctx.fillStyle = style.fill;
660
702
  ctx.fill();
703
+ if (guide && guide.value > 0 && guide.value <= range) {
704
+ const y = Math.round(toY(guide.value)) + .5;
705
+ ctx.save();
706
+ ctx.setLineDash([3, 3]);
707
+ ctx.strokeStyle = guide.color;
708
+ ctx.beginPath();
709
+ ctx.moveTo(0, y);
710
+ ctx.lineTo(width, y);
711
+ ctx.stroke();
712
+ ctx.restore();
713
+ }
661
714
  }
662
715
  //#endregion
663
716
  //#region src/ui/performance-view.ts
@@ -734,6 +787,24 @@ const THEME_OPTIONS = [
734
787
  const RELEASE_URL = `https://github.com/zkmake/three-meter/releases/tag/v${version}`;
735
788
  /** How long the report button says `copied` / `failed` before reading `copy` again. */
736
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
+ };
737
808
  const readTiming = (sample, metric) => {
738
809
  switch (metric) {
739
810
  case "fps": return sample.fps;
@@ -742,92 +813,130 @@ const readTiming = (sample, metric) => {
742
813
  }
743
814
  };
744
815
  const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
816
+ const whole = (value) => Math.round(value).toString();
817
+ const tenths = (value) => value.toFixed(1);
745
818
  const NUMBERS = [
746
819
  {
820
+ format: whole,
747
821
  icon: "gauge",
748
822
  key: "fps",
749
823
  label: "FPS",
750
- read: (sample) => Math.round(sample.fps).toString()
824
+ value: (sample) => sample.fps || null
751
825
  },
752
826
  {
827
+ format: formatCount,
753
828
  icon: "layers",
754
829
  key: "calls",
755
830
  label: "Calls",
756
- read: (sample) => formatCount(sample.render.calls)
831
+ value: (sample) => sample.render.calls
757
832
  },
758
833
  {
834
+ format: tenths,
759
835
  icon: "cpu",
760
836
  key: "cpu",
761
837
  label: "CPU",
762
- read: (sample) => sample.cpu.toFixed(1)
838
+ unit: "ms",
839
+ value: (sample) => sample.cpu
763
840
  },
764
841
  {
842
+ format: tenths,
765
843
  icon: "zap",
766
844
  key: "gpu",
767
845
  label: "GPU",
768
- read: (sample) => sample.gpu.available ? sample.gpu.ms.toFixed(1) : "—"
846
+ unit: "ms",
847
+ value: (sample) => sample.gpu.available ? sample.gpu.ms : null
769
848
  },
770
849
  {
850
+ format: whole,
771
851
  icon: "trendingDown",
772
852
  key: "low",
773
853
  label: "1% low",
774
- read: (_sample, stats) => stats.frames ? Math.round(stats.lowFps).toString() : "—"
854
+ value: (_sample, stats) => stats.frames ? stats.lowFps : null
775
855
  },
776
856
  {
857
+ format: tenths,
777
858
  icon: "timer",
778
859
  key: "p99",
779
860
  label: "Frame p99",
780
- read: (_sample, stats) => stats.frames ? stats.p99Ms.toFixed(1) : "—"
861
+ unit: "ms",
862
+ value: (_sample, stats) => stats.frames ? stats.p99Ms : null
781
863
  },
782
864
  {
865
+ format: formatCount,
783
866
  icon: "activity",
784
867
  key: "hitches",
785
868
  label: "Hitches",
786
- read: (_sample, stats) => stats.frames ? formatCount(stats.hitches) : "—"
869
+ value: (_sample, stats) => stats.frames ? stats.hitches : null
787
870
  },
788
871
  {
872
+ format: formatCount,
789
873
  icon: "triangle",
790
874
  key: "triangles",
791
875
  label: "Triangles",
792
- read: (sample) => formatCount(sample.render.triangles)
876
+ value: (sample) => sample.render.triangles
793
877
  },
794
878
  {
879
+ format: formatCount,
795
880
  icon: "spline",
796
881
  key: "lines",
797
882
  label: "Lines",
798
- read: (sample) => formatCount(sample.render.lines)
883
+ value: (sample) => sample.render.lines
799
884
  },
800
885
  {
886
+ format: formatCount,
801
887
  icon: "circleDot",
802
888
  key: "points",
803
889
  label: "Points",
804
- read: (sample) => formatCount(sample.render.points)
890
+ value: (sample) => sample.render.points
805
891
  },
806
892
  {
893
+ format: formatCount,
807
894
  icon: "repeat",
808
895
  key: "passes",
809
896
  label: "Render passes",
810
- read: (sample) => formatCount(sample.render.passes)
897
+ value: (sample) => sample.render.passes
811
898
  },
812
899
  {
900
+ format: formatCount,
813
901
  icon: "box",
814
902
  key: "geometries",
815
903
  label: "Geometries",
816
- read: (sample) => formatCount(sample.resources.geometries)
904
+ value: (sample) => sample.resources.geometries
817
905
  },
818
906
  {
907
+ format: formatCount,
819
908
  icon: "image",
820
909
  key: "textures",
821
910
  label: "Textures",
822
- read: (sample) => formatCount(sample.resources.textures)
911
+ value: (sample) => sample.resources.textures
823
912
  },
824
913
  {
914
+ format: formatCount,
825
915
  icon: "palette",
826
916
  key: "shaders",
827
917
  label: "Shaders",
828
- read: (sample) => formatCount(sample.resources.programs)
918
+ value: (sample) => sample.resources.programs
829
919
  }
830
920
  ];
921
+ const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
922
+ /** Canvas can't read CSS custom properties; the budget line's amber per theme. */
923
+ const GUIDE_COLOR = {
924
+ dark: "rgba(245, 158, 11, 0.75)",
925
+ light: "rgba(180, 83, 9, 0.7)"
926
+ };
927
+ /** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
928
+ const budgetTitle = (budgets, key) => {
929
+ const limit = budgets.limits[key];
930
+ const config = NUMBER_BY_KEY.get(key);
931
+ if (limit === void 0 || !config) return "";
932
+ return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
933
+ };
934
+ /** The sample value a timing graph shows, `null` when not known. */
935
+ const timingValue = (sample, metric) => {
936
+ if (!isTimingAvailable(sample, metric)) return null;
937
+ const value = readTiming(sample, metric);
938
+ return metric === "fps" && value === 0 ? null : value;
939
+ };
831
940
  /**
832
941
  * Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
833
942
  * anywhere; the view drives its own throttled repaint. Compact is the floating
@@ -860,10 +969,19 @@ var PerformanceView = class {
860
969
  hudGridEl;
861
970
  hudGraphs = /* @__PURE__ */ new Map();
862
971
  hudNumberEls = /* @__PURE__ */ new Map();
972
+ budgets;
863
973
  rafId = null;
864
974
  lastPaintAt = 0;
865
975
  resizeObserver;
866
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();
867
985
  constructor(options) {
868
986
  this.monitor = options.monitor;
869
987
  this.settings = options.settings ?? new HudSettings();
@@ -872,6 +990,7 @@ var PerformanceView = class {
872
990
  this.theme.setOverride(this.settings.theme);
873
991
  this.mode = options.mode ?? "full";
874
992
  this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
993
+ this.budgets = resolveBudgets(options.budgets);
875
994
  this.element = document.createElement("div");
876
995
  this.element.className = "perf-monitor";
877
996
  this.applyModeClass();
@@ -901,6 +1020,11 @@ var PerformanceView = class {
901
1020
  getMode() {
902
1021
  return this.mode;
903
1022
  }
1023
+ /** Replace the budgets; `false` turns them all off. Applies on the next paint. */
1024
+ setBudgets(budgets) {
1025
+ this.budgets = resolveBudgets(budgets);
1026
+ this.applyBudgetTitles();
1027
+ }
904
1028
  dispose() {
905
1029
  this.stop();
906
1030
  this.unsubscribe();
@@ -922,17 +1046,60 @@ var PerformanceView = class {
922
1046
  if (this.settings.info) this.renderFooter();
923
1047
  return;
924
1048
  }
925
- for (const config of TIMINGS) {
926
- const available = isTimingAvailable(sample, config.metric);
927
- const valueEl = this.graphValueEls.get(config.metric);
928
- valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : "unavailable";
929
- const graph = this.graphCanvases.get(config.metric);
930
- drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved]);
931
- }
1049
+ for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
932
1050
  const stats = this.monitor.getFrameStats();
933
- for (const config of NUMBERS) this.statValueEls.get(config.key).textContent = config.read(sample, stats);
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();
934
1053
  this.renderFooter();
935
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
+ }
936
1103
  /** The monitor caches the environment once its backend settles; until then this re-reads. */
937
1104
  renderFooter() {
938
1105
  const environment = this.monitor.getEnvironment();
@@ -954,16 +1121,38 @@ var PerformanceView = class {
954
1121
  renderHud(sample) {
955
1122
  const stats = this.hudNumberEls.size > 0 ? this.monitor.getFrameStats() : null;
956
1123
  for (const [key, element] of this.hudNumberEls) {
957
- const config = NUMBERS.find((number) => number.key === key);
958
- if (config && stats) element.textContent = config.read(sample, stats);
1124
+ const config = NUMBER_BY_KEY.get(key);
1125
+ if (config && stats) this.paintNumber(config, element, sample, stats);
959
1126
  }
960
1127
  for (const [metric, graph] of this.hudGraphs) {
961
1128
  const config = TIMINGS.find((timing) => timing.metric === metric);
962
- const available = isTimingAvailable(sample, metric);
963
- graph.valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, metric)) : "—";
964
- drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(metric) : [], config.style[this.theme.resolved]);
1129
+ this.paintGraph(config, sample, graph.valueEl, graph, "—");
965
1130
  }
966
1131
  }
1132
+ paintNumber(config, element, sample, stats) {
1133
+ const value = config.value(sample, stats);
1134
+ element.textContent = value === null ? "—" : config.format(value);
1135
+ element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
1136
+ }
1137
+ /** Value and sparkline, with the budget line drawn once the series reaches it. */
1138
+ paintGraph(config, sample, valueEl, graph, missing) {
1139
+ const value = timingValue(sample, config.metric);
1140
+ const available = isTimingAvailable(sample, config.metric);
1141
+ const limit = this.budgets.limits[config.metric];
1142
+ valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
1143
+ valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
1144
+ drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
1145
+ color: GUIDE_COLOR[this.theme.resolved],
1146
+ value: limit
1147
+ });
1148
+ }
1149
+ /** Each value's tooltip names its budget, so amber explains itself. */
1150
+ applyBudgetTitles() {
1151
+ for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
1152
+ for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
1153
+ for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
1154
+ for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
1155
+ }
967
1156
  formatTiming(config, value) {
968
1157
  return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
969
1158
  }
@@ -1102,7 +1291,7 @@ var PerformanceView = class {
1102
1291
  this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
1103
1292
  footerBody.append(softwareRow, this.footerHardwareEl);
1104
1293
  footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
1105
- this.element.append(hud, options, graphs, stats, footer);
1294
+ this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
1106
1295
  }
1107
1296
  rebuildHud() {
1108
1297
  const { selection } = this.settings;
@@ -1156,6 +1345,7 @@ var PerformanceView = class {
1156
1345
  this.hudGridEl.append(item);
1157
1346
  this.hudNumberEls.set(config.key, value);
1158
1347
  }
1348
+ this.applyBudgetTitles();
1159
1349
  this.resizeCanvases();
1160
1350
  }
1161
1351
  onSelectionChanged() {
@@ -1172,6 +1362,71 @@ var PerformanceView = class {
1172
1362
  applyTheme(this.element, this.theme);
1173
1363
  for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
1174
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
+ }
1175
1430
  buildReportRow() {
1176
1431
  const row = document.createElement("div");
1177
1432
  row.className = "perf-monitor__row perf-monitor__row--static";
@@ -1256,6 +1511,7 @@ const PERF_HUD_STYLES = `
1256
1511
  --perf-row: rgba(255, 255, 255, 0.04);
1257
1512
  --perf-border: rgba(255, 255, 255, 0.12);
1258
1513
  --perf-accent: #60a5fa;
1514
+ --perf-warn: #f59e0b;
1259
1515
  --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
1260
1516
 
1261
1517
  color-scheme: dark;
@@ -1270,6 +1526,7 @@ const PERF_HUD_STYLES = `
1270
1526
  --perf-row: rgba(0, 0, 0, 0.05);
1271
1527
  --perf-border: rgba(0, 0, 0, 0.12);
1272
1528
  --perf-accent: #2563eb;
1529
+ --perf-warn: #b45309;
1273
1530
  --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
1274
1531
 
1275
1532
  color-scheme: light;
@@ -1466,6 +1723,96 @@ const PERF_HUD_STYLES = `
1466
1723
  outline-offset: 1px;
1467
1724
  }
1468
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
+
1469
1816
  .perf-monitor__checkbox {
1470
1817
  flex: none;
1471
1818
  margin: 0;
@@ -1581,13 +1928,15 @@ const PERF_HUD_STYLES = `
1581
1928
  }
1582
1929
 
1583
1930
  .perf-monitor__badge.is-fallback {
1584
- border-color: #f59e0b;
1585
- color: #f59e0b;
1931
+ border-color: var(--perf-warn);
1932
+ color: var(--perf-warn);
1586
1933
  }
1587
1934
 
1588
- .perf-monitor[data-theme="light"] .perf-monitor__badge.is-fallback {
1589
- border-color: #d97706;
1590
- color: #b45309;
1935
+ /* Past its budget. The value's tooltip names the budget. */
1936
+ .perf-monitor__value.is-over,
1937
+ .perf-monitor__hud-value.is-over,
1938
+ .perf-monitor__graph-value.is-over {
1939
+ color: var(--perf-warn);
1591
1940
  }
1592
1941
 
1593
1942
  .perf-monitor__link {
@@ -1782,6 +2131,7 @@ const mountPerfHud = (monitor, options = {}) => {
1782
2131
  const toggle = disc("Toggle full performance metrics", "", "sliders");
1783
2132
  tools.append(grip, toggle);
1784
2133
  const view = new PerformanceView({
2134
+ budgets: options.budgets,
1785
2135
  mode: options.mode ?? "compact",
1786
2136
  monitor,
1787
2137
  refreshHz: options.refreshHz,
@@ -1857,6 +2207,7 @@ const mountPerfHud = (monitor, options = {}) => {
1857
2207
  },
1858
2208
  element: host,
1859
2209
  getMode: () => view.getMode(),
2210
+ setBudgets: (budgets) => view.setBudgets(budgets),
1860
2211
  setMode,
1861
2212
  getTheme: () => theme.mode,
1862
2213
  setTheme: (mode) => theme.setMode(mode),
@@ -1866,6 +2217,6 @@ const mountPerfHud = (monitor, options = {}) => {
1866
2217
  };
1867
2218
  };
1868
2219
  //#endregion
1869
- export { formatReport as a, HudSettings as c, isThemeMode as d, 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 };
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 };
1870
2221
 
1871
- //# sourceMappingURL=mount-perf-hud-C5wdjgmM.js.map
2222
+ //# sourceMappingURL=mount-perf-hud-B_cmTG2M.js.map