@zkmake/three-meter 0.10.0 → 0.11.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.
@@ -537,1135 +537,32 @@ const createIcon = (name, className) => {
537
537
  return svg;
538
538
  };
539
539
  //#endregion
540
- //#region package.json
541
- var name = "@zkmake/three-meter";
542
- var version = "0.10.0";
543
- //#endregion
544
- //#region src/ui/clipboard.ts
545
- /**
546
- * Copy text from a click handler. The async Clipboard API needs a secure
547
- * context and the `clipboard-write` permission; a dev server opened over a LAN
548
- * IP has neither, and some embeds deny the permission. The fallback is the old
549
- * select-a-textarea `execCommand("copy")`, still honoured inside a user gesture.
550
- */
551
- const copyWithSelection = (text) => {
552
- const textarea = document.createElement("textarea");
553
- textarea.value = text;
554
- textarea.setAttribute("readonly", "");
555
- textarea.style.position = "fixed";
556
- textarea.style.opacity = "0";
557
- textarea.style.pointerEvents = "none";
558
- const previous = document.activeElement;
559
- document.body.append(textarea);
560
- textarea.select();
561
- try {
562
- return document.execCommand("copy");
563
- } catch {
564
- return false;
565
- } finally {
566
- textarea.remove();
567
- previous?.focus?.();
568
- }
569
- };
570
- const copyText = async (text) => {
571
- try {
572
- await navigator.clipboard.writeText(text);
573
- return true;
574
- } catch {
575
- return copyWithSelection(text);
576
- }
577
- };
578
- //#endregion
579
- //#region src/ui/format.ts
580
- /**
581
- * Number formatting for the card. Counts stay exact with thousands separators
582
- * while they fit the compact grid's 6ch value column (`99,999`), then switch
583
- * to compact notation (`250K`, `1.4M`, `12.3M`, `1.2B`) so a triangle count in
584
- * the millions can't run into the neighbouring label.
585
- */
586
- const COMPACT_FROM = 1e5;
587
- const exact = new Intl.NumberFormat("en-US");
588
- const compact = new Intl.NumberFormat("en-US", {
589
- maximumFractionDigits: 1,
590
- notation: "compact"
591
- });
592
- const formatCount = (value) => Math.abs(value) < COMPACT_FROM ? exact.format(value) : compact.format(value);
593
- //#endregion
594
- //#region src/ui/hider.ts
595
- var Hider = class {
596
- groups = /* @__PURE__ */ new Map();
597
- /** Each hidden object's `visible` from before the first group hid it. */
598
- original = /* @__PURE__ */ new Map();
599
- hide(key, objects) {
600
- if (this.groups.has(key)) return;
601
- const hideable = objects.filter((object) => typeof object === "object" && object !== null);
602
- this.groups.set(key, hideable);
603
- for (const object of hideable) {
604
- if (!this.original.has(object)) this.original.set(object, object.visible !== false);
605
- object.visible = false;
606
- }
607
- }
608
- show(key) {
609
- const objects = this.groups.get(key);
610
- if (!objects) return;
611
- this.groups.delete(key);
612
- for (const object of objects) if (!this.heldElsewhere(object)) {
613
- object.visible = this.original.get(object) ?? true;
614
- this.original.delete(object);
615
- }
616
- }
617
- isHidden(key) {
618
- return this.groups.has(key);
619
- }
620
- /** Hidden group keys, in the order they were hidden. */
621
- keys() {
622
- return [...this.groups.keys()];
623
- }
624
- restoreAll() {
625
- for (const key of this.keys()) this.show(key);
626
- }
627
- heldElsewhere(object) {
628
- for (const objects of this.groups.values()) if (objects.includes(object)) return true;
629
- return false;
630
- }
631
- };
632
- //#endregion
633
- //#region src/ui/metric-help.ts
634
- /**
635
- * One plain-language line per metric: what it is, and what a bad reading
636
- * means. Shown as the label's tooltip and, with "explain metrics" on, under
637
- * each row. Keyed like the stat rows; the graphs reuse `fps`, `cpu`, `gpu`.
638
- */
639
- const METRIC_HELP = {
640
- calls: "Separate draw commands sent to the GPU this frame. Fewer is better: merge or instance meshes that share a material.",
641
- cpu: "JavaScript time per frame: your update code plus three.js preparing the render. It has to fit the frame budget (16.7 ms at 60 FPS).",
642
- fps: "Frames drawn per second. Higher is smoother; most displays top out at 60 or 120.",
643
- geometries: "Geometries held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
644
- headroom: "Spare time left in each frame: the frame budget minus whichever of CPU and GPU took longer. Below zero, frames miss the display's refresh.",
645
- refresh: "The rate the loop runs at when it keeps up: the display's refresh rate, or the app's own cap. Budgets use it unless you set targetFps.",
646
- gpu: "Time the graphics card spent drawing the frame. High means shaders, overdraw or resolution are the bottleneck.",
647
- hitches: "Frames that took over twice as long as usual, in the last 1,000 frames. Each one is a visible stutter.",
648
- lines: "Line segments drawn this frame.",
649
- low: "Frame rate during the slowest 1% of frames. Far below FPS means stutter the average hides.",
650
- p99: "99% of frames finished within this many ms. Above the frame budget (16.7 ms at 60 FPS) means dropped frames.",
651
- passes: "Times renderer.render() ran this frame. Shadows, post-processing and extra cameras each add passes.",
652
- points: "Points drawn this frame.",
653
- shaders: "Compiled shader programs. A jump mid-session usually means a stutter while a new material compiled.",
654
- textures: "Textures held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
655
- triangles: "Triangles drawn this frame. Mostly GPU cost: use lower-poly models or level of detail to cut it."
656
- };
657
- //#endregion
658
- //#region src/ui/report.ts
540
+ //#region src/ui/styles.ts
659
541
  /**
660
- * A plain-text snapshot of the monitor for bug reports: versions, backend and
661
- * GPU, the current sample, stutter stats, the scene's top meshes and the page. Fenced as a `text`
662
- * code block so GitHub and Slack keep the line breaks.
542
+ * The HUD stylesheet as a string, so the package works without a bundler and
543
+ * injects itself once per document. `.perf-monitor` is the metrics card,
544
+ * `.perf-hud` the floating host around it. Both namespaced so a host page can
545
+ * override safely; the `--perf-*` custom properties are the theming surface.
546
+ *
547
+ * Both elements carry `data-theme="dark" | "light"` (see `HudTheme`) and each
548
+ * resolves its own tokens from it, so the card also themes when mounted on
549
+ * its own, outside a `.perf-hud`.
663
550
  */
664
- const BACKEND_LABELS = {
665
- webgl: "WebGL",
666
- webgl2: "WebGL2",
667
- webgpu: "WebGPU"
668
- };
669
- /** `WebGPU`, `WebGL2`, `WebGL2 fallback`, or `""` before the backend settles. */
670
- const backendLabel = (environment) => {
671
- if (!environment.backend) return "";
672
- const label = BACKEND_LABELS[environment.backend];
673
- return environment.fallback ? `${label} fallback` : label;
674
- };
675
- const ms = (value) => `${value.toFixed(1)} ms`;
676
- /** Top meshes by draw calls, three at most: `Top: cubes ×2,000 (1 call, 24K tris) · …`. */
677
- const topMeshes = (cost) => `Top: ${cost.meshes.slice(0, 3).map((entry) => {
678
- const copies = entry.instances > 1 ? ` ×${formatCount(entry.instances)}` : "";
679
- const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
680
- return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
681
- }).join(" · ")}`;
682
- const renderReport = ({ cost, environment, page, sample, stats }) => {
683
- const { render, resources } = sample;
684
- const heading = [
685
- `three-meter v${version}`,
686
- environment.three ? `three r${environment.three}` : "",
687
- backendLabel(environment),
688
- environment.gpu ?? ""
689
- ].filter(Boolean);
690
- const frames = stats.frames === 0 ? `FPS ${Math.round(sample.fps)}` : [
691
- `FPS ${Math.round(sample.fps)}`,
692
- `1% low ${Math.round(stats.lowFps)}`,
693
- `p99 ${ms(stats.p99Ms)}`,
694
- `hitches ${formatCount(stats.hitches)} / ${formatCount(stats.frames)} frames`
695
- ].join(" · ");
696
- const timing = [`CPU ${ms(sample.cpu)}`, `GPU ${sample.gpu.available ? ms(sample.gpu.ms) : "unavailable"}`];
697
- if (stats.refreshHz) {
698
- const busiest = Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0);
699
- timing.push(`refresh ${stats.refreshHz} Hz`, `headroom ${ms(1e3 / stats.refreshHz - busiest)}`);
700
- }
701
- const lines = [
702
- heading.join(" · "),
703
- frames,
704
- timing.join(" · "),
705
- [
706
- `Calls ${formatCount(render.calls)}`,
707
- `passes ${formatCount(render.passes)}`,
708
- `triangles ${formatCount(render.triangles)}`,
709
- `lines ${formatCount(render.lines)}`,
710
- `points ${formatCount(render.points)}`
711
- ].join(" · "),
712
- [
713
- `Geometries ${formatCount(resources.geometries)}`,
714
- `textures ${formatCount(resources.textures)}`,
715
- `shaders ${formatCount(resources.programs)}`
716
- ].join(" · ")
717
- ];
718
- if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
719
- if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
720
- return [
721
- "```text",
722
- ...lines,
723
- "```"
724
- ].join("\n");
725
- };
726
- const readPage = () => typeof window === "undefined" ? null : {
727
- dpr: window.devicePixelRatio || 1,
728
- height: window.innerHeight,
729
- userAgent: navigator.userAgent,
730
- width: window.innerWidth
731
- };
732
- /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
733
- const formatReport = (monitor) => renderReport({
734
- cost: monitor.getSceneCost(),
735
- environment: monitor.getEnvironment(),
736
- page: readPage(),
737
- sample: monitor.getSample(),
738
- stats: monitor.getFrameStats()
739
- });
740
- //#endregion
741
- //#region src/ui/sparkline.ts
742
- function drawSparkline(ctx, width, height, values, style, guide) {
743
- ctx.clearRect(0, 0, width, height);
744
- if (values.length < 2) return;
745
- let max = 0;
746
- for (const value of values) if (value > max) max = value;
747
- const range = max * 1.1 || 1;
748
- const stepX = width / (values.length - 1);
749
- const toY = (value) => height - value / range * height;
750
- ctx.beginPath();
751
- ctx.moveTo(0, toY(values[0]));
752
- for (let index = 1; index < values.length; index += 1) ctx.lineTo(index * stepX, toY(values[index]));
753
- ctx.lineWidth = 1;
754
- ctx.strokeStyle = style.stroke;
755
- ctx.stroke();
756
- ctx.lineTo(width, height);
757
- ctx.lineTo(0, height);
758
- ctx.closePath();
759
- ctx.fillStyle = style.fill;
760
- ctx.fill();
761
- if (guide && guide.value > 0 && guide.value <= range) {
762
- const y = Math.round(toY(guide.value)) + .5;
763
- ctx.save();
764
- ctx.setLineDash([3, 3]);
765
- ctx.strokeStyle = guide.color;
766
- ctx.beginPath();
767
- ctx.moveTo(0, y);
768
- ctx.lineTo(width, y);
769
- ctx.stroke();
770
- ctx.restore();
771
- }
772
- }
773
- //#endregion
774
- //#region src/ui/performance-view.ts
775
- const TIMINGS = [
776
- {
777
- format: (value) => Math.round(value).toString(),
778
- icon: "gauge",
779
- label: "FPS",
780
- metric: "fps",
781
- style: {
782
- dark: {
783
- fill: "rgba(74, 222, 128, 0.15)",
784
- stroke: "#4ade80"
785
- },
786
- light: {
787
- fill: "rgba(22, 163, 74, 0.12)",
788
- stroke: "#16a34a"
789
- }
790
- },
791
- unit: ""
792
- },
793
- {
794
- format: (value) => value.toFixed(1),
795
- icon: "cpu",
796
- label: "CPU",
797
- metric: "cpu",
798
- style: {
799
- dark: {
800
- fill: "rgba(96, 165, 250, 0.15)",
801
- stroke: "#60a5fa"
802
- },
803
- light: {
804
- fill: "rgba(37, 99, 235, 0.12)",
805
- stroke: "#2563eb"
806
- }
807
- },
808
- unit: "ms"
809
- },
810
- {
811
- format: (value) => value.toFixed(1),
812
- icon: "zap",
813
- label: "GPU",
814
- metric: "gpu",
815
- style: {
816
- dark: {
817
- fill: "rgba(244, 114, 182, 0.15)",
818
- stroke: "#f472b6"
819
- },
820
- light: {
821
- fill: "rgba(219, 39, 119, 0.12)",
822
- stroke: "#db2777"
823
- }
824
- },
825
- unit: "ms"
826
- }
827
- ];
828
- const THEME_OPTIONS = [
829
- {
830
- icon: "sun",
831
- label: "Light theme",
832
- mode: "light"
833
- },
834
- {
835
- icon: "monitor",
836
- label: "Match the system theme",
837
- mode: "system"
838
- },
839
- {
840
- icon: "moon",
841
- label: "Dark theme",
842
- mode: "dark"
843
- }
844
- ];
845
- /** The monorepo tags each package's releases `@zkmake/three-meter@0.9.2`. */
846
- const RELEASE_URL = `https://github.com/zkmake/three-kit/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
847
- /** How long the report button says `copied` / `failed` before reading `copy` again. */
848
- const COPY_FEEDBACK_MS = 1500;
849
- /** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
850
- const COSTS_INTERVAL_MS = 500;
851
- const COSTS_ROWS = 5;
852
- const COST_GROUPS = [{
853
- group: "meshes",
854
- label: "meshes"
855
- }, {
856
- group: "materials",
857
- label: "materials"
858
- }];
859
- 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; use the eye to hide them and measure the time they cost.";
860
- /** Frames averaged on each side of hiding a row. */
861
- const MEASURE_FRAMES = 30;
862
- /** How long after hiding before the saving is read, so the average holds only frames without the row. */
863
- const MEASURE_SETTLE_MS = 700;
864
- const meanOfLast = (values, count) => {
865
- const tail = values.slice(-count);
866
- return tail.length === 0 ? 0 : tail.reduce((sum, value) => sum + value, 0) / tail.length;
867
- };
868
- /** `×2,000` for instances or copies; nothing for a single object. */
869
- const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
870
- const costTitle = (entry) => {
871
- const objects = entry.objects.length;
872
- const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
873
- return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
874
- };
875
- const readTiming = (sample, metric) => {
876
- switch (metric) {
877
- case "fps": return sample.fps;
878
- case "cpu": return sample.cpu;
879
- case "gpu": return sample.gpu.ms;
880
- }
881
- };
882
- const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
883
- const whole = (value) => Math.round(value).toString();
884
- const tenths = (value) => value.toFixed(1);
885
- const NUMBERS = [
886
- {
887
- format: whole,
888
- icon: "gauge",
889
- key: "fps",
890
- label: "FPS",
891
- value: (sample) => sample.fps || null
892
- },
893
- {
894
- format: formatCount,
895
- icon: "layers",
896
- key: "calls",
897
- label: "Calls",
898
- value: (sample) => sample.render.calls
899
- },
900
- {
901
- format: tenths,
902
- icon: "cpu",
903
- key: "cpu",
904
- label: "CPU",
905
- unit: "ms",
906
- value: (sample) => sample.cpu
907
- },
908
- {
909
- format: tenths,
910
- icon: "zap",
911
- key: "gpu",
912
- label: "GPU",
913
- unit: "ms",
914
- value: (sample) => sample.gpu.available ? sample.gpu.ms : null
915
- },
916
- {
917
- format: tenths,
918
- icon: "hourglass",
919
- key: "headroom",
920
- label: "Headroom",
921
- unit: "ms",
922
- value: (sample, _stats, frameMs) => sample.fps ? frameMs - Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0) : null
923
- },
924
- {
925
- format: (value) => `${Math.round(value)} Hz`,
926
- icon: "monitor",
927
- key: "refresh",
928
- label: "Refresh",
929
- unit: "Hz",
930
- value: (_sample, stats) => stats.refreshHz
931
- },
932
- {
933
- format: whole,
934
- icon: "trendingDown",
935
- key: "low",
936
- label: "1% low",
937
- value: (_sample, stats) => stats.frames ? stats.lowFps : null
938
- },
939
- {
940
- format: tenths,
941
- icon: "timer",
942
- key: "p99",
943
- label: "Frame p99",
944
- unit: "ms",
945
- value: (_sample, stats) => stats.frames ? stats.p99Ms : null
946
- },
947
- {
948
- format: formatCount,
949
- icon: "activity",
950
- key: "hitches",
951
- label: "Hitches",
952
- value: (_sample, stats) => stats.frames ? stats.hitches : null
953
- },
954
- {
955
- format: formatCount,
956
- icon: "triangle",
957
- key: "triangles",
958
- label: "Triangles",
959
- value: (sample) => sample.render.triangles
960
- },
961
- {
962
- format: formatCount,
963
- icon: "spline",
964
- key: "lines",
965
- label: "Lines",
966
- value: (sample) => sample.render.lines
967
- },
968
- {
969
- format: formatCount,
970
- icon: "circleDot",
971
- key: "points",
972
- label: "Points",
973
- value: (sample) => sample.render.points
974
- },
975
- {
976
- format: formatCount,
977
- icon: "repeat",
978
- key: "passes",
979
- label: "Render passes",
980
- value: (sample) => sample.render.passes
981
- },
982
- {
983
- format: formatCount,
984
- icon: "box",
985
- key: "geometries",
986
- label: "Geometries",
987
- value: (sample) => sample.resources.geometries
988
- },
989
- {
990
- format: formatCount,
991
- icon: "image",
992
- key: "textures",
993
- label: "Textures",
994
- value: (sample) => sample.resources.textures
995
- },
996
- {
997
- format: formatCount,
998
- icon: "palette",
999
- key: "shaders",
1000
- label: "Shaders",
1001
- value: (sample) => sample.resources.programs
1002
- }
1003
- ];
1004
- const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
1005
- /** Canvas can't read CSS custom properties; the budget line's amber per theme. */
1006
- const GUIDE_COLOR = {
1007
- dark: "rgba(245, 158, 11, 0.75)",
1008
- light: "rgba(180, 83, 9, 0.7)"
1009
- };
1010
- /** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
1011
- const budgetTitle = (budgets, key) => {
1012
- const limit = budgets.limits[key];
1013
- const config = NUMBER_BY_KEY.get(key);
1014
- if (limit === void 0 || !config) return "";
1015
- return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
1016
- };
1017
- /** The sample value a timing graph shows, `null` when not known. */
1018
- const timingValue = (sample, metric) => {
1019
- if (!isTimingAvailable(sample, metric)) return null;
1020
- const value = readTiming(sample, metric);
1021
- return metric === "fps" && value === 0 ? null : value;
1022
- };
1023
- /**
1024
- * Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
1025
- * anywhere; the view drives its own throttled repaint. Compact is the floating
1026
- * HUD; full is the checkbox list that chooses what compact shows. Styles are
1027
- * not injected here. Call `injectStyles()` or use `mountPerfHud`.
1028
- */
1029
- var PerformanceView = class {
1030
- element;
1031
- settings;
1032
- theme;
1033
- ownsTheme;
1034
- unsubscribeTheme;
1035
- monitor;
1036
- minIntervalMs;
1037
- mode;
1038
- graphValueEls = /* @__PURE__ */ new Map();
1039
- graphCanvases = /* @__PURE__ */ new Map();
1040
- graphCheckboxes = /* @__PURE__ */ new Map();
1041
- statValueEls = /* @__PURE__ */ new Map();
1042
- statCheckboxes = /* @__PURE__ */ new Map();
1043
- infoCheckbox;
1044
- themeRadios = /* @__PURE__ */ new Map();
1045
- footerThreeEl;
1046
- footerHardwareEl;
1047
- footerBackendEl;
1048
- footerGpuEl;
1049
- lastEnvironmentKey = "";
1050
- hudGraphsEl;
1051
- hudGridEl;
1052
- hudGraphs = /* @__PURE__ */ new Map();
1053
- hudNumberEls = /* @__PURE__ */ new Map();
1054
- budgets;
1055
- budgetsInput;
1056
- detectedHz = null;
1057
- hider = new Hider();
1058
- hiddenRows = /* @__PURE__ */ new Map();
1059
- rafId = null;
1060
- lastPaintAt = 0;
1061
- resizeObserver;
1062
- unsubscribe;
1063
- costsOpen = false;
1064
- costGroup = "meshes";
1065
- costSort = "calls";
1066
- lastCostsAt = -Infinity;
1067
- costsBodyEl;
1068
- costsListEl;
1069
- costGroupRadios = /* @__PURE__ */ new Map();
1070
- costSortButtons = /* @__PURE__ */ new Map();
1071
- constructor(options) {
1072
- this.monitor = options.monitor;
1073
- this.settings = options.settings ?? new HudSettings();
1074
- this.ownsTheme = !options.theme;
1075
- this.theme = options.theme ?? new HudTheme();
1076
- this.theme.setOverride(this.settings.theme);
1077
- this.mode = options.mode ?? "full";
1078
- this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
1079
- this.budgetsInput = options.budgets;
1080
- this.budgets = resolveBudgets(options.budgets);
1081
- this.element = document.createElement("div");
1082
- this.element.className = "perf-monitor";
1083
- this.applyModeClass();
1084
- this.resizeObserver = new ResizeObserver(() => this.resizeCanvases());
1085
- this.build();
1086
- this.applyInfoClass();
1087
- this.rebuildHud();
1088
- this.onThemeChanged();
1089
- this.unsubscribe = this.settings.subscribe(() => this.onSelectionChanged());
1090
- this.unsubscribeTheme = this.theme.subscribe(() => this.onThemeChanged());
1091
- }
1092
- start() {
1093
- if (this.rafId === null) this.rafId = requestAnimationFrame(this.frame);
1094
- }
1095
- stop() {
1096
- if (this.rafId !== null) {
1097
- cancelAnimationFrame(this.rafId);
1098
- this.rafId = null;
1099
- }
1100
- }
1101
- setMode(mode) {
1102
- if (mode === this.mode) return;
1103
- this.mode = mode;
1104
- this.applyModeClass();
1105
- this.resizeCanvases();
1106
- }
1107
- getMode() {
1108
- return this.mode;
1109
- }
1110
- /** Replace the budgets; `false` turns them all off. Applies on the next paint. */
1111
- setBudgets(budgets) {
1112
- this.budgetsInput = budgets;
1113
- this.budgets = resolveBudgets(budgets, this.detectedHz);
1114
- this.applyBudgetTitles();
1115
- }
1116
- dispose() {
1117
- this.stop();
1118
- this.showAllHidden();
1119
- this.unsubscribe();
1120
- this.unsubscribeTheme();
1121
- if (this.ownsTheme) this.theme.dispose();
1122
- this.resizeObserver.disconnect();
1123
- this.element.remove();
1124
- }
1125
- frame = (now) => {
1126
- this.rafId = requestAnimationFrame(this.frame);
1127
- if (now - this.lastPaintAt < this.minIntervalMs) return;
1128
- this.lastPaintAt = now;
1129
- this.render();
1130
- };
1131
- render() {
1132
- const sample = this.monitor.getSample();
1133
- const stats = this.monitor.getFrameStats();
1134
- this.followRefresh(stats.refreshHz);
1135
- if (this.mode === "compact") {
1136
- this.renderHud(sample, stats);
1137
- if (this.settings.info) this.renderFooter();
1138
- return;
1139
- }
1140
- for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
1141
- for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
1142
- if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
1143
- this.renderFooter();
1144
- }
1145
- renderCosts() {
1146
- this.lastCostsAt = performance.now();
1147
- const cost = this.monitor.getSceneCost();
1148
- const hidden = [...this.hiddenRows.values()].map((row) => this.hiddenCostRow(row));
1149
- if (!cost) {
1150
- this.costsListEl.replaceChildren(...hidden, this.costsNote("No scene rendered yet."));
1151
- return;
1152
- }
1153
- const entries = (this.costGroup === "meshes" ? cost.meshes : cost.materials).filter((entry) => !this.hiddenRows.has(entry.key));
1154
- if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
1155
- if (entries.length === 0 && hidden.length === 0) {
1156
- this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
1157
- return;
1158
- }
1159
- const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
1160
- const row = this.costRowShell(entry);
1161
- const calls = document.createElement("span");
1162
- calls.className = "perf-monitor__cost-value";
1163
- calls.textContent = formatCount(entry.calls);
1164
- const triangles = document.createElement("span");
1165
- triangles.className = "perf-monitor__cost-value";
1166
- triangles.textContent = formatCount(entry.triangles);
1167
- row.append(calls, triangles, this.eyeButton(entry, false));
1168
- return row;
1169
- });
1170
- this.costsListEl.replaceChildren(...hidden, ...rows);
1171
- }
1172
- /** Name button (logs the objects) in a row; the caller appends the value cells and the eye. */
1173
- costRowShell(entry) {
1174
- const row = document.createElement("div");
1175
- row.className = "perf-monitor__cost";
1176
- const name = document.createElement("button");
1177
- name.type = "button";
1178
- name.className = "perf-monitor__cost-name";
1179
- name.title = costTitle(entry);
1180
- name.textContent = entry.label;
1181
- const copies = copiesOf(entry);
1182
- if (copies) {
1183
- const suffix = document.createElement("span");
1184
- suffix.className = "perf-monitor__cost-copies";
1185
- suffix.textContent = ` ${copies}`;
1186
- name.append(suffix);
1187
- }
1188
- name.addEventListener("click", () => {
1189
- console.log(`three-meter: ${entry.label}`, entry.objects);
1190
- });
1191
- row.append(name);
1192
- return row;
1193
- }
1194
- /** A hidden row: what hiding it saved, measured from the timing history. */
1195
- hiddenCostRow(hidden) {
1196
- const row = this.costRowShell(hidden.entry);
1197
- row.classList.add("is-hidden");
1198
- const saved = document.createElement("span");
1199
- saved.className = "perf-monitor__cost-saved";
1200
- const label = hidden.metric.toUpperCase();
1201
- if (performance.now() - hidden.hiddenAt < MEASURE_SETTLE_MS) saved.textContent = "measuring…";
1202
- else {
1203
- const now = meanOfLast(this.monitor.getHistory(hidden.metric), MEASURE_FRAMES);
1204
- const delta = hidden.baseline - now;
1205
- saved.textContent = `${delta >= 0 ? "−" : "+"}${Math.abs(delta).toFixed(1)} ms ${label}`;
1206
- saved.title = `${label} time went from ${hidden.baseline.toFixed(1)} ms to ${now.toFixed(1)} ms with this hidden, averaged over ${MEASURE_FRAMES} frames each side.`;
1207
- }
1208
- row.append(saved, this.eyeButton(hidden.entry, true));
1209
- return row;
1210
- }
1211
- eyeButton(entry, hidden) {
1212
- const eye = document.createElement("button");
1213
- eye.type = "button";
1214
- eye.className = "perf-monitor__cost-eye";
1215
- eye.setAttribute("aria-pressed", String(hidden));
1216
- const label = hidden ? `Show ${entry.label} again` : `Hide ${entry.label} to measure its cost`;
1217
- eye.title = label;
1218
- eye.setAttribute("aria-label", label);
1219
- eye.append(createIcon(hidden ? "eyeOff" : "eye", "perf-monitor__icon"));
1220
- eye.addEventListener("click", () => hidden ? this.showRow(entry.key) : this.hideRow(entry));
1221
- return eye;
1222
- }
1223
- hideRow(entry) {
1224
- const metric = this.monitor.getSample().gpu.available ? "gpu" : "cpu";
1225
- this.hiddenRows.set(entry.key, {
1226
- baseline: meanOfLast(this.monitor.getHistory(metric), MEASURE_FRAMES),
1227
- entry,
1228
- hiddenAt: performance.now(),
1229
- metric
1230
- });
1231
- const all = this.monitor.getObjectsForKey(entry.key);
1232
- this.hider.hide(entry.key, all.length > 0 ? all : entry.objects);
1233
- this.renderCosts();
1234
- }
1235
- showRow(key) {
1236
- this.hider.show(key);
1237
- this.hiddenRows.delete(key);
1238
- this.renderCosts();
1239
- }
1240
- /** Everything hidden goes back: on closing top costs and on dispose. */
1241
- showAllHidden() {
1242
- this.hider.restoreAll();
1243
- this.hiddenRows.clear();
1244
- }
1245
- costsNote(text) {
1246
- const note = document.createElement("div");
1247
- note.className = "perf-monitor__costs-note";
1248
- note.textContent = text;
1249
- return note;
1250
- }
1251
- /** The monitor caches the environment once its backend settles; until then this re-reads. */
1252
- renderFooter() {
1253
- const environment = this.monitor.getEnvironment();
1254
- const three = environment.three ? `three r${environment.three}` : "";
1255
- const backend = backendLabel(environment);
1256
- const gpu = environment.gpu ?? "";
1257
- const key = `${three}\n${backend}\n${gpu}`;
1258
- if (key === this.lastEnvironmentKey) return;
1259
- this.lastEnvironmentKey = key;
1260
- this.footerThreeEl.textContent = three;
1261
- this.footerBackendEl.textContent = backend;
1262
- this.footerBackendEl.hidden = backend === "";
1263
- this.footerBackendEl.title = environment.fallback ? "WebGPURenderer couldn't get WebGPU and fell back to WebGL2" : "";
1264
- this.footerBackendEl.classList.toggle("is-fallback", environment.fallback);
1265
- this.footerGpuEl.textContent = gpu;
1266
- this.footerGpuEl.title = gpu;
1267
- this.footerHardwareEl.hidden = backend === "" && gpu === "";
1268
- }
1269
- /** Budgets follow the detected refresh rate unless `targetFps` was given. */
1270
- followRefresh(hz) {
1271
- if (hz === this.detectedHz) return;
1272
- this.detectedHz = hz;
1273
- this.budgets = resolveBudgets(this.budgetsInput, hz);
1274
- this.applyBudgetTitles();
1275
- }
1276
- renderHud(sample, stats) {
1277
- for (const [key, element] of this.hudNumberEls) {
1278
- const config = NUMBER_BY_KEY.get(key);
1279
- if (config) this.paintNumber(config, element, sample, stats);
1280
- }
1281
- for (const [metric, graph] of this.hudGraphs) {
1282
- const config = TIMINGS.find((timing) => timing.metric === metric);
1283
- this.paintGraph(config, sample, graph.valueEl, graph, "—");
1284
- }
1285
- }
1286
- paintNumber(config, element, sample, stats) {
1287
- const value = config.value(sample, stats, this.budgets.frameMs);
1288
- element.textContent = value === null ? "—" : config.format(value);
1289
- element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
1290
- }
1291
- /** Value and sparkline, with the budget line drawn once the series reaches it. */
1292
- paintGraph(config, sample, valueEl, graph, missing) {
1293
- const value = timingValue(sample, config.metric);
1294
- const available = isTimingAvailable(sample, config.metric);
1295
- const limit = this.budgets.limits[config.metric];
1296
- valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
1297
- valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
1298
- drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
1299
- color: GUIDE_COLOR[this.theme.resolved],
1300
- value: limit
1301
- });
1302
- }
1303
- /** Each value's tooltip names its budget, so amber explains itself. */
1304
- applyBudgetTitles() {
1305
- for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
1306
- for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
1307
- for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
1308
- for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
1309
- }
1310
- formatTiming(config, value) {
1311
- return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
1312
- }
1313
- build() {
1314
- const hud = document.createElement("div");
1315
- hud.className = "perf-monitor__hud";
1316
- this.hudGraphsEl = document.createElement("div");
1317
- this.hudGraphsEl.className = "perf-monitor__hud-graphs";
1318
- this.hudGridEl = document.createElement("div");
1319
- this.hudGridEl.className = "perf-monitor__hud-grid";
1320
- hud.append(this.hudGraphsEl, this.hudGridEl);
1321
- const options = document.createElement("div");
1322
- options.className = "perf-monitor__section perf-monitor__options";
1323
- const themeRow = document.createElement("div");
1324
- themeRow.className = "perf-monitor__row perf-monitor__row--static";
1325
- const themeLabel = document.createElement("span");
1326
- themeLabel.className = "perf-monitor__label";
1327
- themeLabel.textContent = "theme";
1328
- const segment = document.createElement("div");
1329
- segment.className = "perf-monitor__segment";
1330
- segment.setAttribute("role", "radiogroup");
1331
- segment.setAttribute("aria-label", "Panel theme");
1332
- for (const option of THEME_OPTIONS) {
1333
- const radio = document.createElement("button");
1334
- radio.type = "button";
1335
- radio.className = "perf-monitor__segment-option";
1336
- radio.setAttribute("role", "radio");
1337
- radio.setAttribute("aria-checked", "false");
1338
- radio.setAttribute("aria-label", option.label);
1339
- radio.title = option.label;
1340
- radio.append(createIcon(option.icon, "perf-monitor__icon"));
1341
- radio.addEventListener("click", () => this.settings.setTheme(option.mode));
1342
- this.themeRadios.set(option.mode, radio);
1343
- segment.append(radio);
1344
- }
1345
- themeRow.append(createIcon("contrast", "perf-monitor__icon"), themeLabel, segment);
1346
- const explainRow = document.createElement("label");
1347
- explainRow.className = "perf-monitor__row";
1348
- const explainCheckbox = this.buildCheckbox(false, "Explain each metric", (on) => this.element.classList.toggle("perf-monitor--explain", on));
1349
- const explainLabel = document.createElement("span");
1350
- explainLabel.className = "perf-monitor__label";
1351
- explainLabel.textContent = "explain metrics";
1352
- explainRow.append(createIcon("help", "perf-monitor__icon"), explainLabel, explainCheckbox);
1353
- options.append(themeRow, explainRow, this.buildReportRow());
1354
- const graphs = document.createElement("div");
1355
- graphs.className = "perf-monitor__graphs";
1356
- for (const config of TIMINGS) {
1357
- const graph = document.createElement("div");
1358
- graph.className = "perf-monitor__graph";
1359
- const canvas = document.createElement("canvas");
1360
- canvas.className = "perf-monitor__canvas";
1361
- const overlay = document.createElement("label");
1362
- overlay.className = "perf-monitor__graph-overlay perf-monitor__graph-overlay--toggle";
1363
- const head = document.createElement("span");
1364
- head.className = "perf-monitor__graph-head";
1365
- const checkbox = this.buildCheckbox(this.settings.isGraphEnabled(config.metric), `Show ${config.label} graph in HUD`, (on) => this.settings.setGraphEnabled(config.metric, on));
1366
- this.graphCheckboxes.set(config.metric, checkbox);
1367
- const label = document.createElement("span");
1368
- label.className = "perf-monitor__graph-label";
1369
- label.textContent = config.label;
1370
- label.title = METRIC_HELP[config.metric] ?? "";
1371
- const value = document.createElement("span");
1372
- value.className = "perf-monitor__graph-value";
1373
- value.textContent = "—";
1374
- this.graphValueEls.set(config.metric, value);
1375
- const tail = document.createElement("span");
1376
- tail.className = "perf-monitor__graph-head";
1377
- tail.append(value, checkbox);
1378
- head.append(createIcon(config.icon, "perf-monitor__icon"), label);
1379
- overlay.append(head, tail);
1380
- graph.append(canvas, overlay);
1381
- graphs.append(graph);
1382
- const ctx = canvas.getContext("2d");
1383
- if (ctx) {
1384
- this.graphCanvases.set(config.metric, {
1385
- canvas,
1386
- ctx,
1387
- height: 0,
1388
- width: 0
1389
- });
1390
- this.resizeObserver.observe(graph);
1391
- }
1392
- }
1393
- const stats = document.createElement("div");
1394
- stats.className = "perf-monitor__section";
1395
- for (const config of NUMBERS) {
1396
- const rowEl = document.createElement("label");
1397
- rowEl.className = "perf-monitor__row";
1398
- const checkbox = this.buildCheckbox(this.settings.isNumberEnabled(config.key), `Show ${config.label} in HUD`, (on) => this.settings.setNumberEnabled(config.key, on));
1399
- this.statCheckboxes.set(config.key, checkbox);
1400
- const help = METRIC_HELP[config.key] ?? "";
1401
- const label = document.createElement("span");
1402
- label.className = "perf-monitor__label";
1403
- label.textContent = config.label;
1404
- label.title = help;
1405
- const value = document.createElement("span");
1406
- value.className = "perf-monitor__value";
1407
- value.textContent = "—";
1408
- this.statValueEls.set(config.key, value);
1409
- const hint = document.createElement("span");
1410
- hint.className = "perf-monitor__hint";
1411
- hint.textContent = help;
1412
- rowEl.append(createIcon(config.icon, "perf-monitor__icon"), label, value, checkbox, hint);
1413
- stats.append(rowEl);
1414
- }
1415
- const footer = document.createElement("div");
1416
- footer.className = "perf-monitor__footer";
1417
- this.infoCheckbox = this.buildCheckbox(this.settings.info, "Show version and environment in HUD", (on) => this.settings.setInfo(on));
1418
- const footerBody = document.createElement("div");
1419
- footerBody.className = "perf-monitor__footer-body";
1420
- const softwareRow = document.createElement("div");
1421
- softwareRow.className = "perf-monitor__footer-row";
1422
- const release = document.createElement("a");
1423
- release.className = "perf-monitor__link";
1424
- release.href = RELEASE_URL;
1425
- release.target = "_blank";
1426
- release.rel = "noopener noreferrer";
1427
- release.title = `Release notes for v${version}`;
1428
- release.textContent = `three-meter v${version}`;
1429
- this.footerThreeEl = document.createElement("span");
1430
- softwareRow.append(release, this.footerThreeEl);
1431
- this.footerHardwareEl = document.createElement("div");
1432
- this.footerHardwareEl.className = "perf-monitor__footer-row";
1433
- this.footerHardwareEl.hidden = true;
1434
- this.footerBackendEl = document.createElement("span");
1435
- this.footerBackendEl.className = "perf-monitor__badge";
1436
- this.footerGpuEl = document.createElement("span");
1437
- this.footerGpuEl.className = "perf-monitor__footer-gpu";
1438
- this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
1439
- footerBody.append(softwareRow, this.footerHardwareEl);
1440
- footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
1441
- this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
1442
- }
1443
- rebuildHud() {
1444
- const { selection } = this.settings;
1445
- for (const graph of this.hudGraphs.values()) this.resizeObserver.unobserve(graph.canvas.parentElement ?? graph.canvas);
1446
- this.hudGraphs.clear();
1447
- this.hudNumberEls.clear();
1448
- this.hudGraphsEl.replaceChildren();
1449
- this.hudGridEl.replaceChildren();
1450
- for (const config of TIMINGS) {
1451
- if (!selection.graphs.has(config.metric)) continue;
1452
- const row = document.createElement("div");
1453
- row.className = "perf-monitor__hud-graph";
1454
- const canvas = document.createElement("canvas");
1455
- canvas.className = "perf-monitor__canvas";
1456
- const overlay = document.createElement("div");
1457
- overlay.className = "perf-monitor__graph-overlay";
1458
- const label = document.createElement("span");
1459
- label.className = "perf-monitor__graph-label";
1460
- label.textContent = config.label;
1461
- label.title = METRIC_HELP[config.metric] ?? "";
1462
- const head = document.createElement("span");
1463
- head.className = "perf-monitor__graph-head";
1464
- head.append(createIcon(config.icon, "perf-monitor__icon"), label);
1465
- const value = document.createElement("span");
1466
- value.className = "perf-monitor__graph-value";
1467
- value.textContent = "—";
1468
- overlay.append(head, value);
1469
- row.append(canvas, overlay);
1470
- this.hudGraphsEl.append(row);
1471
- const ctx = canvas.getContext("2d");
1472
- if (ctx) {
1473
- this.hudGraphs.set(config.metric, {
1474
- canvas,
1475
- ctx,
1476
- height: 0,
1477
- valueEl: value,
1478
- width: 0
1479
- });
1480
- this.resizeObserver.observe(row);
1481
- }
1482
- }
1483
- for (const config of NUMBERS) {
1484
- if (!selection.numbers.has(config.key)) continue;
1485
- const item = document.createElement("div");
1486
- item.className = "perf-monitor__hud-item";
1487
- const label = document.createElement("span");
1488
- label.className = "perf-monitor__hud-label";
1489
- label.title = METRIC_HELP[config.key] ?? "";
1490
- label.append(createIcon(config.icon, "perf-monitor__icon"), config.label);
1491
- const value = document.createElement("span");
1492
- value.className = "perf-monitor__hud-value";
1493
- value.textContent = "—";
1494
- item.append(label, value);
1495
- this.hudGridEl.append(item);
1496
- this.hudNumberEls.set(config.key, value);
1497
- }
1498
- this.applyBudgetTitles();
1499
- this.resizeCanvases();
1500
- }
1501
- onSelectionChanged() {
1502
- for (const [metric, checkbox] of this.graphCheckboxes) checkbox.checked = this.settings.isGraphEnabled(metric);
1503
- for (const [key, checkbox] of this.statCheckboxes) checkbox.checked = this.settings.isNumberEnabled(key);
1504
- this.infoCheckbox.checked = this.settings.info;
1505
- this.applyInfoClass();
1506
- this.theme.setOverride(this.settings.theme);
1507
- this.rebuildHud();
1508
- }
1509
- /** Repaint the palette and mark the radio matching what applies (pick, else consumer mode). */
1510
- onThemeChanged() {
1511
- applyTheme(this.element, this.theme);
1512
- for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
1513
- }
1514
- /** "top costs": a toggle row, then meshes / materials and a sortable top five. */
1515
- buildCosts() {
1516
- const section = document.createElement("div");
1517
- section.className = "perf-monitor__section perf-monitor__costs";
1518
- const toggleRow = document.createElement("label");
1519
- toggleRow.className = "perf-monitor__row";
1520
- toggleRow.title = COSTS_HELP;
1521
- const label = document.createElement("span");
1522
- label.className = "perf-monitor__label";
1523
- label.textContent = "top costs";
1524
- const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
1525
- this.costsOpen = on;
1526
- this.costsBodyEl.hidden = !on;
1527
- if (!on) this.showAllHidden();
1528
- if (on) this.renderCosts();
1529
- });
1530
- toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
1531
- this.costsBodyEl = document.createElement("div");
1532
- this.costsBodyEl.className = "perf-monitor__costs-body";
1533
- this.costsBodyEl.hidden = true;
1534
- const head = document.createElement("div");
1535
- head.className = "perf-monitor__cost perf-monitor__cost--head";
1536
- const segment = document.createElement("div");
1537
- segment.className = "perf-monitor__segment";
1538
- segment.setAttribute("role", "radiogroup");
1539
- segment.setAttribute("aria-label", "Group costs by");
1540
- for (const option of COST_GROUPS) {
1541
- const radio = document.createElement("button");
1542
- radio.type = "button";
1543
- radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
1544
- radio.setAttribute("role", "radio");
1545
- radio.setAttribute("aria-checked", String(option.group === this.costGroup));
1546
- radio.textContent = option.label;
1547
- radio.addEventListener("click", () => {
1548
- this.costGroup = option.group;
1549
- for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
1550
- this.renderCosts();
1551
- });
1552
- this.costGroupRadios.set(option.group, radio);
1553
- segment.append(radio);
1554
- }
1555
- head.append(segment);
1556
- for (const sort of ["calls", "triangles"]) {
1557
- const button = document.createElement("button");
1558
- button.type = "button";
1559
- button.className = "perf-monitor__cost-sort";
1560
- button.textContent = sort === "calls" ? "calls" : "tris";
1561
- button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
1562
- button.setAttribute("aria-pressed", String(sort === this.costSort));
1563
- button.addEventListener("click", () => {
1564
- this.costSort = sort;
1565
- for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
1566
- this.renderCosts();
1567
- });
1568
- this.costSortButtons.set(sort, button);
1569
- head.append(button);
1570
- }
1571
- head.append(document.createElement("span"));
1572
- this.costsListEl = document.createElement("div");
1573
- this.costsListEl.className = "perf-monitor__costs-list";
1574
- const hint = document.createElement("span");
1575
- hint.className = "perf-monitor__hint";
1576
- hint.textContent = COSTS_HELP;
1577
- this.costsBodyEl.append(head, this.costsListEl, hint);
1578
- section.append(toggleRow, this.costsBodyEl);
1579
- return section;
1580
- }
1581
- buildReportRow() {
1582
- const row = document.createElement("div");
1583
- row.className = "perf-monitor__row perf-monitor__row--static";
1584
- const label = document.createElement("span");
1585
- label.className = "perf-monitor__label";
1586
- label.textContent = "report";
1587
- const button = document.createElement("button");
1588
- button.type = "button";
1589
- button.className = "perf-monitor__button";
1590
- button.title = "Copy versions, GPU and current metrics as Markdown for a bug report";
1591
- button.textContent = "copy";
1592
- let resetTimer = 0;
1593
- const flash = (text) => {
1594
- button.textContent = text;
1595
- window.clearTimeout(resetTimer);
1596
- resetTimer = window.setTimeout(() => {
1597
- button.textContent = "copy";
1598
- }, COPY_FEEDBACK_MS);
1599
- };
1600
- button.addEventListener("click", () => {
1601
- copyText(formatReport(this.monitor)).then((copied) => flash(copied ? "copied" : "failed"));
1602
- });
1603
- row.append(createIcon("clipboard", "perf-monitor__icon"), label, button);
1604
- return row;
1605
- }
1606
- buildCheckbox(checked, title, onChange) {
1607
- const checkbox = document.createElement("input");
1608
- checkbox.type = "checkbox";
1609
- checkbox.className = "perf-monitor__checkbox";
1610
- checkbox.checked = checked;
1611
- checkbox.title = title;
1612
- checkbox.setAttribute("aria-label", title);
1613
- checkbox.addEventListener("change", () => onChange(checkbox.checked));
1614
- return checkbox;
1615
- }
1616
- applyInfoClass() {
1617
- this.element.classList.toggle("perf-monitor--info", this.settings.info);
1618
- }
1619
- applyModeClass() {
1620
- this.element.classList.toggle("perf-monitor--compact", this.mode === "compact");
1621
- this.element.classList.toggle("perf-monitor--full", this.mode === "full");
1622
- }
1623
- resizeCanvases() {
1624
- const dpr = window.devicePixelRatio || 1;
1625
- const sizeAll = (graphs) => {
1626
- for (const graph of graphs) {
1627
- const { canvas, ctx } = graph;
1628
- const width = canvas.clientWidth;
1629
- const height = canvas.clientHeight;
1630
- if (width === 0 || height === 0) continue;
1631
- canvas.width = Math.round(width * dpr);
1632
- canvas.height = Math.round(height * dpr);
1633
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1634
- graph.width = width;
1635
- graph.height = height;
1636
- }
1637
- };
1638
- sizeAll(this.graphCanvases.values());
1639
- sizeAll(this.hudGraphs.values());
1640
- }
1641
- };
1642
- //#endregion
1643
- //#region src/ui/styles.ts
1644
- /**
1645
- * The HUD stylesheet as a string, so the package works without a bundler and
1646
- * injects itself once per document. `.perf-monitor` is the metrics card,
1647
- * `.perf-hud` the floating host around it. Both namespaced so a host page can
1648
- * override safely; the `--perf-*` custom properties are the theming surface.
1649
- *
1650
- * Both elements carry `data-theme="dark" | "light"` (see `HudTheme`) and each
1651
- * resolves its own tokens from it, so the card also themes when mounted on
1652
- * its own, outside a `.perf-hud`.
1653
- */
1654
- const STYLE_ATTRIBUTE = "data-three-meter";
1655
- const PERF_HUD_STYLES = `
1656
- .perf-hud,
1657
- .perf-monitor {
1658
- --perf-bg: rgba(22, 24, 29, 0.85);
1659
- --perf-fg: #e6e8eb;
1660
- --perf-fg-dim: #c5c8ce;
1661
- --perf-muted: #8b909a;
1662
- --perf-row: rgba(255, 255, 255, 0.04);
1663
- --perf-border: rgba(255, 255, 255, 0.12);
1664
- --perf-accent: #60a5fa;
1665
- --perf-warn: #f59e0b;
1666
- --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
1667
-
1668
- color-scheme: dark;
551
+ const STYLE_ATTRIBUTE = "data-three-meter";
552
+ const PERF_HUD_STYLES = `
553
+ .perf-hud,
554
+ .perf-monitor {
555
+ --perf-bg: rgba(22, 24, 29, 0.85);
556
+ --perf-fg: #e6e8eb;
557
+ --perf-fg-dim: #c5c8ce;
558
+ --perf-muted: #8b909a;
559
+ --perf-row: rgba(255, 255, 255, 0.04);
560
+ --perf-border: rgba(255, 255, 255, 0.12);
561
+ --perf-accent: #60a5fa;
562
+ --perf-warn: #f59e0b;
563
+ --perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
564
+
565
+ color-scheme: dark;
1669
566
  }
1670
567
 
1671
568
  .perf-hud[data-theme="light"],
@@ -2205,21 +1102,24 @@ const PERF_HUD_STYLES = `
2205
1102
  touch-action: none;
2206
1103
  }
2207
1104
 
2208
- .perf-hud--full > .perf-monitor {
1105
+ .perf-hud--full .perf-hud__card > .perf-monitor {
2209
1106
  width: 16rem;
2210
1107
  max-width: 100%;
2211
1108
  }
2212
1109
 
2213
1110
  /* Room for the hints, so most fit on two or three lines. Plus padding, this fills the host's 20rem cap. */
2214
- .perf-hud--full > .perf-monitor--explain {
1111
+ .perf-hud--full .perf-hud__card > .perf-monitor--explain {
2215
1112
  width: 19rem;
2216
1113
  }
2217
1114
 
2218
- .perf-hud > .perf-monitor {
1115
+ /* The card every dev panel sits in (\`mountDevPanel\`): the brand label, then the panel's content. */
1116
+ .perf-hud__card {
2219
1117
  position: relative;
2220
1118
  z-index: 1;
1119
+ display: flex;
1120
+ flex-direction: column;
2221
1121
  max-height: 70vh;
2222
- overflow: auto;
1122
+ overflow: hidden;
2223
1123
  background: var(--perf-bg);
2224
1124
  border-radius: 6px;
2225
1125
  box-shadow: var(--perf-shadow);
@@ -2228,8 +1128,57 @@ const PERF_HUD_STYLES = `
2228
1128
  transition: opacity 0.25s ease;
2229
1129
  }
2230
1130
 
2231
- .perf-hud--dim:not(.is-awake):not(.is-dragging) > .perf-monitor {
2232
- opacity: 0.32;
1131
+ .perf-hud__card > .perf-monitor {
1132
+ min-height: 0;
1133
+ overflow: auto;
1134
+ }
1135
+
1136
+ .perf-hud--dim:not(.is-awake):not(.is-dragging) > .perf-hud__card {
1137
+ opacity: 0.32;
1138
+ }
1139
+
1140
+ .perf-hud__brand {
1141
+ display: flex;
1142
+ align-items: center;
1143
+ gap: 6px;
1144
+ padding: 6px 8px 0;
1145
+ color: var(--perf-muted);
1146
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1147
+ font-size: 9px;
1148
+ font-weight: 600;
1149
+ letter-spacing: 0.08em;
1150
+ line-height: 1;
1151
+ text-transform: uppercase;
1152
+ }
1153
+
1154
+ /* Alone in the card: pad it all round. */
1155
+ .perf-hud__brand:last-child {
1156
+ padding-bottom: 6px;
1157
+ }
1158
+
1159
+ .perf-hud__brand-name {
1160
+ display: inline-flex;
1161
+ align-items: center;
1162
+ gap: 5px;
1163
+ }
1164
+
1165
+ .perf-hud__brand-name::before {
1166
+ width: 6px;
1167
+ height: 6px;
1168
+ border-radius: 2px;
1169
+ background: var(--perf-accent);
1170
+ content: "";
1171
+ }
1172
+
1173
+ .perf-hud__brand-detail {
1174
+ margin-left: auto;
1175
+ font-weight: 400;
1176
+ letter-spacing: 0.02em;
1177
+ text-transform: none;
1178
+ }
1179
+
1180
+ .perf-hud__brand-detail:empty {
1181
+ display: none;
2233
1182
  }
2234
1183
 
2235
1184
  .perf-hud__hotspot {
@@ -2317,50 +1266,1275 @@ const PERF_HUD_STYLES = `
2317
1266
  background: var(--perf-accent);
2318
1267
  color: var(--perf-bg);
2319
1268
  }
2320
- `;
1269
+ `;
1270
+ /**
1271
+ * Append the stylesheet to `target`'s head, once. Call before mounting a view
1272
+ * by hand; {@link mountPerfHud} does it for you unless told not to.
1273
+ */
1274
+ const injectStyles = (target = document) => {
1275
+ if (target.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return;
1276
+ const style = target.createElement("style");
1277
+ style.setAttribute(STYLE_ATTRIBUTE, "");
1278
+ style.textContent = PERF_HUD_STYLES;
1279
+ target.head.append(style);
1280
+ };
1281
+ //#endregion
1282
+ //#region src/ui/dev-panel.ts
1283
+ /**
1284
+ * The frame every zkmake dev panel shares, so they look and handle alike: a fixed host docked to a
1285
+ * screen edge, a card with a brand label on top and the panel's content under it, and three discs
1286
+ * on the inward side — drag grip, expand/compact, dim on leave — that stay hidden until the pointer
1287
+ * is near or someone taps. `mountPerfHud` is one; `@zkmake/three-textures`' panel is another.
1288
+ */
1289
+ const TOUCH_LINGER_MS = 2500;
1290
+ const disc = (label, className, icon) => {
1291
+ const button = document.createElement("button");
1292
+ button.type = "button";
1293
+ button.className = `perf-hud__disc ${className}`.trim();
1294
+ button.setAttribute("aria-label", label);
1295
+ button.title = label;
1296
+ button.append(createIcon(icon, "perf-hud__icon"));
1297
+ return button;
1298
+ };
1299
+ /** A dim flag in localStorage, for panels with no settings of their own. */
1300
+ const storedDim = (key) => {
1301
+ const listeners = /* @__PURE__ */ new Set();
1302
+ let on = false;
1303
+ try {
1304
+ on = key !== null && localStorage.getItem(key) === "1";
1305
+ } catch {}
1306
+ return {
1307
+ get: () => on,
1308
+ set: (next) => {
1309
+ on = next;
1310
+ try {
1311
+ if (key !== null) localStorage.setItem(key, next ? "1" : "0");
1312
+ } catch {}
1313
+ for (const listener of listeners) listener();
1314
+ },
1315
+ subscribe: (listener) => {
1316
+ listeners.add(listener);
1317
+ return () => {
1318
+ listeners.delete(listener);
1319
+ };
1320
+ }
1321
+ };
1322
+ };
1323
+ const mountDevPanel = (options) => {
1324
+ const parent = options.parent ?? document.body;
1325
+ const dim = options.dim ?? storedDim(options.storageKey === null ? null : `${options.storageKey}:dim`);
1326
+ if (options.injectStyles ?? true) injectStyles(parent.ownerDocument);
1327
+ const host = document.createElement("div");
1328
+ host.className = "perf-hud";
1329
+ host.dataset.edge = "left";
1330
+ host.setAttribute("aria-label", options.label);
1331
+ const hotspot = document.createElement("div");
1332
+ hotspot.className = "perf-hud__hotspot";
1333
+ hotspot.setAttribute("aria-hidden", "true");
1334
+ const tools = document.createElement("div");
1335
+ tools.className = "perf-hud__tools";
1336
+ const grip = disc(`Drag the ${options.brand} panel`, "perf-hud__drag", "grip");
1337
+ const toggle = disc(options.toggleLabel ?? "Expand or compact the panel", "", "sliders");
1338
+ const dimDisc = disc("Dim the panel when the pointer leaves", "perf-hud__dim", "blend");
1339
+ dimDisc.addEventListener("click", () => dim.set(!dim.get()));
1340
+ toggle.addEventListener("click", () => options.onToggle());
1341
+ tools.append(grip, toggle, dimDisc);
1342
+ const card = document.createElement("div");
1343
+ card.className = "perf-hud__card";
1344
+ const brand = document.createElement("div");
1345
+ brand.className = "perf-hud__brand";
1346
+ const name = document.createElement("span");
1347
+ name.className = "perf-hud__brand-name";
1348
+ name.textContent = options.brand;
1349
+ const detail = document.createElement("span");
1350
+ detail.className = "perf-hud__brand-detail";
1351
+ brand.append(name, detail);
1352
+ card.append(brand, options.content);
1353
+ host.append(hotspot, tools, card);
1354
+ parent.append(host);
1355
+ const applyDim = () => {
1356
+ host.classList.toggle("perf-hud--dim", dim.get());
1357
+ dimDisc.setAttribute("aria-pressed", String(dim.get()));
1358
+ dimDisc.title = `Dim on leave: ${dim.get() ? "on" : "off"}`;
1359
+ };
1360
+ applyDim();
1361
+ applyTheme(host, options.theme);
1362
+ const unsubscribeDim = dim.subscribe(applyDim);
1363
+ const unsubscribeTheme = options.theme.subscribe(() => applyTheme(host, options.theme));
1364
+ const dock = dockPanel(host, {
1365
+ defaultPlacement: options.defaultPlacement,
1366
+ handle: grip,
1367
+ storageKey: options.storageKey
1368
+ });
1369
+ let edge = host.dataset.edge;
1370
+ const edgeWatch = new MutationObserver(() => {
1371
+ const next = host.dataset.edge;
1372
+ if (next !== edge) {
1373
+ edge = next;
1374
+ options.onEdgeChange?.(edge);
1375
+ }
1376
+ });
1377
+ edgeWatch.observe(host, {
1378
+ attributes: true,
1379
+ attributeFilter: ["data-edge"]
1380
+ });
1381
+ options.onEdgeChange?.(edge);
1382
+ let hideTimer = 0;
1383
+ const sleep = () => {
1384
+ if (host.classList.contains("is-dragging")) return;
1385
+ host.classList.remove("is-awake");
1386
+ };
1387
+ const wake = (linger) => {
1388
+ host.classList.add("is-awake");
1389
+ window.clearTimeout(hideTimer);
1390
+ if (linger) hideTimer = window.setTimeout(sleep, TOUCH_LINGER_MS);
1391
+ };
1392
+ const onEnter = () => {
1393
+ wake(false);
1394
+ };
1395
+ const onLeave = (event) => {
1396
+ if (host.contains(event.relatedTarget)) return;
1397
+ if (event.pointerType === "touch" || event.pointerType === "pen") {
1398
+ wake(true);
1399
+ return;
1400
+ }
1401
+ sleep();
1402
+ };
1403
+ const onDown = (event) => {
1404
+ wake(event.pointerType !== "mouse");
1405
+ };
1406
+ host.addEventListener("pointerenter", onEnter);
1407
+ host.addEventListener("pointerleave", onLeave);
1408
+ host.addEventListener("pointerdown", onDown);
1409
+ return {
1410
+ element: host,
1411
+ card,
1412
+ detail,
1413
+ refresh: () => {
1414
+ requestAnimationFrame(() => dock.refresh());
1415
+ },
1416
+ dispose: () => {
1417
+ window.clearTimeout(hideTimer);
1418
+ edgeWatch.disconnect();
1419
+ host.removeEventListener("pointerenter", onEnter);
1420
+ host.removeEventListener("pointerleave", onLeave);
1421
+ host.removeEventListener("pointerdown", onDown);
1422
+ unsubscribeDim();
1423
+ unsubscribeTheme();
1424
+ dock.dispose();
1425
+ host.remove();
1426
+ }
1427
+ };
1428
+ };
1429
+ //#endregion
1430
+ //#region package.json
1431
+ var name = "@zkmake/three-meter";
1432
+ var version = "0.11.0";
1433
+ //#endregion
1434
+ //#region src/ui/clipboard.ts
1435
+ /**
1436
+ * Copy text from a click handler. The async Clipboard API needs a secure
1437
+ * context and the `clipboard-write` permission; a dev server opened over a LAN
1438
+ * IP has neither, and some embeds deny the permission. The fallback is the old
1439
+ * select-a-textarea `execCommand("copy")`, still honoured inside a user gesture.
1440
+ */
1441
+ const copyWithSelection = (text) => {
1442
+ const textarea = document.createElement("textarea");
1443
+ textarea.value = text;
1444
+ textarea.setAttribute("readonly", "");
1445
+ textarea.style.position = "fixed";
1446
+ textarea.style.opacity = "0";
1447
+ textarea.style.pointerEvents = "none";
1448
+ const previous = document.activeElement;
1449
+ document.body.append(textarea);
1450
+ textarea.select();
1451
+ try {
1452
+ return document.execCommand("copy");
1453
+ } catch {
1454
+ return false;
1455
+ } finally {
1456
+ textarea.remove();
1457
+ previous?.focus?.();
1458
+ }
1459
+ };
1460
+ const copyText = async (text) => {
1461
+ try {
1462
+ await navigator.clipboard.writeText(text);
1463
+ return true;
1464
+ } catch {
1465
+ return copyWithSelection(text);
1466
+ }
1467
+ };
1468
+ //#endregion
1469
+ //#region src/ui/format.ts
1470
+ /**
1471
+ * Number formatting for the card. Counts stay exact with thousands separators
1472
+ * while they fit the compact grid's 6ch value column (`99,999`), then switch
1473
+ * to compact notation (`250K`, `1.4M`, `12.3M`, `1.2B`) so a triangle count in
1474
+ * the millions can't run into the neighbouring label.
1475
+ */
1476
+ const COMPACT_FROM = 1e5;
1477
+ const exact = new Intl.NumberFormat("en-US");
1478
+ const compact = new Intl.NumberFormat("en-US", {
1479
+ maximumFractionDigits: 1,
1480
+ notation: "compact"
1481
+ });
1482
+ const formatCount = (value) => Math.abs(value) < COMPACT_FROM ? exact.format(value) : compact.format(value);
1483
+ //#endregion
1484
+ //#region src/ui/hider.ts
1485
+ var Hider = class {
1486
+ groups = /* @__PURE__ */ new Map();
1487
+ /** Each hidden object's `visible` from before the first group hid it. */
1488
+ original = /* @__PURE__ */ new Map();
1489
+ hide(key, objects) {
1490
+ if (this.groups.has(key)) return;
1491
+ const hideable = objects.filter((object) => typeof object === "object" && object !== null);
1492
+ this.groups.set(key, hideable);
1493
+ for (const object of hideable) {
1494
+ if (!this.original.has(object)) this.original.set(object, object.visible !== false);
1495
+ object.visible = false;
1496
+ }
1497
+ }
1498
+ show(key) {
1499
+ const objects = this.groups.get(key);
1500
+ if (!objects) return;
1501
+ this.groups.delete(key);
1502
+ for (const object of objects) if (!this.heldElsewhere(object)) {
1503
+ object.visible = this.original.get(object) ?? true;
1504
+ this.original.delete(object);
1505
+ }
1506
+ }
1507
+ isHidden(key) {
1508
+ return this.groups.has(key);
1509
+ }
1510
+ /** Hidden group keys, in the order they were hidden. */
1511
+ keys() {
1512
+ return [...this.groups.keys()];
1513
+ }
1514
+ restoreAll() {
1515
+ for (const key of this.keys()) this.show(key);
1516
+ }
1517
+ heldElsewhere(object) {
1518
+ for (const objects of this.groups.values()) if (objects.includes(object)) return true;
1519
+ return false;
1520
+ }
1521
+ };
1522
+ //#endregion
1523
+ //#region src/ui/metric-help.ts
1524
+ /**
1525
+ * One plain-language line per metric: what it is, and what a bad reading
1526
+ * means. Shown as the label's tooltip and, with "explain metrics" on, under
1527
+ * each row. Keyed like the stat rows; the graphs reuse `fps`, `cpu`, `gpu`.
1528
+ */
1529
+ const METRIC_HELP = {
1530
+ calls: "Separate draw commands sent to the GPU this frame. Fewer is better: merge or instance meshes that share a material.",
1531
+ cpu: "JavaScript time per frame: your update code plus three.js preparing the render. It has to fit the frame budget (16.7 ms at 60 FPS).",
1532
+ fps: "Frames drawn per second. Higher is smoother; most displays top out at 60 or 120.",
1533
+ geometries: "Geometries held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
1534
+ headroom: "Spare time left in each frame: the frame budget minus whichever of CPU and GPU took longer. Below zero, frames miss the display's refresh.",
1535
+ refresh: "The rate the loop runs at when it keeps up: the display's refresh rate, or the app's own cap. Budgets use it unless you set targetFps.",
1536
+ gpu: "Time the graphics card spent drawing the frame. High means shaders, overdraw or resolution are the bottleneck.",
1537
+ hitches: "Frames that took over twice as long as usual, in the last 1,000 frames. Each one is a visible stutter.",
1538
+ lines: "Line segments drawn this frame.",
1539
+ low: "Frame rate during the slowest 1% of frames. Far below FPS means stutter the average hides.",
1540
+ p99: "99% of frames finished within this many ms. Above the frame budget (16.7 ms at 60 FPS) means dropped frames.",
1541
+ passes: "Times renderer.render() ran this frame. Shadows, post-processing and extra cameras each add passes.",
1542
+ points: "Points drawn this frame.",
1543
+ shaders: "Compiled shader programs. A jump mid-session usually means a stutter while a new material compiled.",
1544
+ textures: "Textures held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
1545
+ triangles: "Triangles drawn this frame. Mostly GPU cost: use lower-poly models or level of detail to cut it."
1546
+ };
1547
+ //#endregion
1548
+ //#region src/ui/report.ts
1549
+ /**
1550
+ * A plain-text snapshot of the monitor for bug reports: versions, backend and
1551
+ * GPU, the current sample, stutter stats, the scene's top meshes and the page. Fenced as a `text`
1552
+ * code block so GitHub and Slack keep the line breaks.
1553
+ */
1554
+ const BACKEND_LABELS = {
1555
+ webgl: "WebGL",
1556
+ webgl2: "WebGL2",
1557
+ webgpu: "WebGPU"
1558
+ };
1559
+ /** `WebGPU`, `WebGL2`, `WebGL2 fallback`, or `""` before the backend settles. */
1560
+ const backendLabel = (environment) => {
1561
+ if (!environment.backend) return "";
1562
+ const label = BACKEND_LABELS[environment.backend];
1563
+ return environment.fallback ? `${label} fallback` : label;
1564
+ };
1565
+ const ms = (value) => `${value.toFixed(1)} ms`;
1566
+ /** Top meshes by draw calls, three at most: `Top: cubes ×2,000 (1 call, 24K tris) · …`. */
1567
+ const topMeshes = (cost) => `Top: ${cost.meshes.slice(0, 3).map((entry) => {
1568
+ const copies = entry.instances > 1 ? ` ×${formatCount(entry.instances)}` : "";
1569
+ const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
1570
+ return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
1571
+ }).join(" · ")}`;
1572
+ const renderReport = ({ cost, environment, page, sample, stats }) => {
1573
+ const { render, resources } = sample;
1574
+ const heading = [
1575
+ `three-meter v${version}`,
1576
+ environment.three ? `three r${environment.three}` : "",
1577
+ backendLabel(environment),
1578
+ environment.gpu ?? ""
1579
+ ].filter(Boolean);
1580
+ const frames = stats.frames === 0 ? `FPS ${Math.round(sample.fps)}` : [
1581
+ `FPS ${Math.round(sample.fps)}`,
1582
+ `1% low ${Math.round(stats.lowFps)}`,
1583
+ `p99 ${ms(stats.p99Ms)}`,
1584
+ `hitches ${formatCount(stats.hitches)} / ${formatCount(stats.frames)} frames`
1585
+ ].join(" · ");
1586
+ const timing = [`CPU ${ms(sample.cpu)}`, `GPU ${sample.gpu.available ? ms(sample.gpu.ms) : "unavailable"}`];
1587
+ if (stats.refreshHz) {
1588
+ const busiest = Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0);
1589
+ timing.push(`refresh ${stats.refreshHz} Hz`, `headroom ${ms(1e3 / stats.refreshHz - busiest)}`);
1590
+ }
1591
+ const lines = [
1592
+ heading.join(" · "),
1593
+ frames,
1594
+ timing.join(" · "),
1595
+ [
1596
+ `Calls ${formatCount(render.calls)}`,
1597
+ `passes ${formatCount(render.passes)}`,
1598
+ `triangles ${formatCount(render.triangles)}`,
1599
+ `lines ${formatCount(render.lines)}`,
1600
+ `points ${formatCount(render.points)}`
1601
+ ].join(" · "),
1602
+ [
1603
+ `Geometries ${formatCount(resources.geometries)}`,
1604
+ `textures ${formatCount(resources.textures)}`,
1605
+ `shaders ${formatCount(resources.programs)}`
1606
+ ].join(" · ")
1607
+ ];
1608
+ if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
1609
+ if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
1610
+ return [
1611
+ "```text",
1612
+ ...lines,
1613
+ "```"
1614
+ ].join("\n");
1615
+ };
1616
+ const readPage = () => typeof window === "undefined" ? null : {
1617
+ dpr: window.devicePixelRatio || 1,
1618
+ height: window.innerHeight,
1619
+ userAgent: navigator.userAgent,
1620
+ width: window.innerWidth
1621
+ };
1622
+ /** The report for `monitor` right now, as Markdown ready to paste into an issue. */
1623
+ const formatReport = (monitor) => renderReport({
1624
+ cost: monitor.getSceneCost(),
1625
+ environment: monitor.getEnvironment(),
1626
+ page: readPage(),
1627
+ sample: monitor.getSample(),
1628
+ stats: monitor.getFrameStats()
1629
+ });
1630
+ //#endregion
1631
+ //#region src/ui/sparkline.ts
1632
+ function drawSparkline(ctx, width, height, values, style, guide) {
1633
+ ctx.clearRect(0, 0, width, height);
1634
+ if (values.length < 2) return;
1635
+ let max = 0;
1636
+ for (const value of values) if (value > max) max = value;
1637
+ const range = max * 1.1 || 1;
1638
+ const stepX = width / (values.length - 1);
1639
+ const toY = (value) => height - value / range * height;
1640
+ ctx.beginPath();
1641
+ ctx.moveTo(0, toY(values[0]));
1642
+ for (let index = 1; index < values.length; index += 1) ctx.lineTo(index * stepX, toY(values[index]));
1643
+ ctx.lineWidth = 1;
1644
+ ctx.strokeStyle = style.stroke;
1645
+ ctx.stroke();
1646
+ ctx.lineTo(width, height);
1647
+ ctx.lineTo(0, height);
1648
+ ctx.closePath();
1649
+ ctx.fillStyle = style.fill;
1650
+ ctx.fill();
1651
+ if (guide && guide.value > 0 && guide.value <= range) {
1652
+ const y = Math.round(toY(guide.value)) + .5;
1653
+ ctx.save();
1654
+ ctx.setLineDash([3, 3]);
1655
+ ctx.strokeStyle = guide.color;
1656
+ ctx.beginPath();
1657
+ ctx.moveTo(0, y);
1658
+ ctx.lineTo(width, y);
1659
+ ctx.stroke();
1660
+ ctx.restore();
1661
+ }
1662
+ }
1663
+ //#endregion
1664
+ //#region src/ui/performance-view.ts
1665
+ const TIMINGS = [
1666
+ {
1667
+ format: (value) => Math.round(value).toString(),
1668
+ icon: "gauge",
1669
+ label: "FPS",
1670
+ metric: "fps",
1671
+ style: {
1672
+ dark: {
1673
+ fill: "rgba(74, 222, 128, 0.15)",
1674
+ stroke: "#4ade80"
1675
+ },
1676
+ light: {
1677
+ fill: "rgba(22, 163, 74, 0.12)",
1678
+ stroke: "#16a34a"
1679
+ }
1680
+ },
1681
+ unit: ""
1682
+ },
1683
+ {
1684
+ format: (value) => value.toFixed(1),
1685
+ icon: "cpu",
1686
+ label: "CPU",
1687
+ metric: "cpu",
1688
+ style: {
1689
+ dark: {
1690
+ fill: "rgba(96, 165, 250, 0.15)",
1691
+ stroke: "#60a5fa"
1692
+ },
1693
+ light: {
1694
+ fill: "rgba(37, 99, 235, 0.12)",
1695
+ stroke: "#2563eb"
1696
+ }
1697
+ },
1698
+ unit: "ms"
1699
+ },
1700
+ {
1701
+ format: (value) => value.toFixed(1),
1702
+ icon: "zap",
1703
+ label: "GPU",
1704
+ metric: "gpu",
1705
+ style: {
1706
+ dark: {
1707
+ fill: "rgba(244, 114, 182, 0.15)",
1708
+ stroke: "#f472b6"
1709
+ },
1710
+ light: {
1711
+ fill: "rgba(219, 39, 119, 0.12)",
1712
+ stroke: "#db2777"
1713
+ }
1714
+ },
1715
+ unit: "ms"
1716
+ }
1717
+ ];
1718
+ const THEME_OPTIONS = [
1719
+ {
1720
+ icon: "sun",
1721
+ label: "Light theme",
1722
+ mode: "light"
1723
+ },
1724
+ {
1725
+ icon: "monitor",
1726
+ label: "Match the system theme",
1727
+ mode: "system"
1728
+ },
1729
+ {
1730
+ icon: "moon",
1731
+ label: "Dark theme",
1732
+ mode: "dark"
1733
+ }
1734
+ ];
1735
+ /** The monorepo tags each package's releases `@zkmake/three-meter@0.9.2`. */
1736
+ const RELEASE_URL = `https://github.com/zkmake/three-kit/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
1737
+ /** How long the report button says `copied` / `failed` before reading `copy` again. */
1738
+ const COPY_FEEDBACK_MS = 1500;
1739
+ /** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
1740
+ const COSTS_INTERVAL_MS = 500;
1741
+ const COSTS_ROWS = 5;
1742
+ const COST_GROUPS = [{
1743
+ group: "meshes",
1744
+ label: "meshes"
1745
+ }, {
1746
+ group: "materials",
1747
+ label: "materials"
1748
+ }];
1749
+ 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; use the eye to hide them and measure the time they cost.";
1750
+ /** Frames averaged on each side of hiding a row. */
1751
+ const MEASURE_FRAMES = 30;
1752
+ /** How long after hiding before the saving is read, so the average holds only frames without the row. */
1753
+ const MEASURE_SETTLE_MS = 700;
1754
+ const meanOfLast = (values, count) => {
1755
+ const tail = values.slice(-count);
1756
+ return tail.length === 0 ? 0 : tail.reduce((sum, value) => sum + value, 0) / tail.length;
1757
+ };
1758
+ /** `×2,000` for instances or copies; nothing for a single object. */
1759
+ const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
1760
+ const costTitle = (entry) => {
1761
+ const objects = entry.objects.length;
1762
+ const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
1763
+ return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
1764
+ };
1765
+ const readTiming = (sample, metric) => {
1766
+ switch (metric) {
1767
+ case "fps": return sample.fps;
1768
+ case "cpu": return sample.cpu;
1769
+ case "gpu": return sample.gpu.ms;
1770
+ }
1771
+ };
1772
+ const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
1773
+ const whole = (value) => Math.round(value).toString();
1774
+ const tenths = (value) => value.toFixed(1);
1775
+ const NUMBERS = [
1776
+ {
1777
+ format: whole,
1778
+ icon: "gauge",
1779
+ key: "fps",
1780
+ label: "FPS",
1781
+ value: (sample) => sample.fps || null
1782
+ },
1783
+ {
1784
+ format: formatCount,
1785
+ icon: "layers",
1786
+ key: "calls",
1787
+ label: "Calls",
1788
+ value: (sample) => sample.render.calls
1789
+ },
1790
+ {
1791
+ format: tenths,
1792
+ icon: "cpu",
1793
+ key: "cpu",
1794
+ label: "CPU",
1795
+ unit: "ms",
1796
+ value: (sample) => sample.cpu
1797
+ },
1798
+ {
1799
+ format: tenths,
1800
+ icon: "zap",
1801
+ key: "gpu",
1802
+ label: "GPU",
1803
+ unit: "ms",
1804
+ value: (sample) => sample.gpu.available ? sample.gpu.ms : null
1805
+ },
1806
+ {
1807
+ format: tenths,
1808
+ icon: "hourglass",
1809
+ key: "headroom",
1810
+ label: "Headroom",
1811
+ unit: "ms",
1812
+ value: (sample, _stats, frameMs) => sample.fps ? frameMs - Math.max(sample.cpu, sample.gpu.available ? sample.gpu.ms : 0) : null
1813
+ },
1814
+ {
1815
+ format: (value) => `${Math.round(value)} Hz`,
1816
+ icon: "monitor",
1817
+ key: "refresh",
1818
+ label: "Refresh",
1819
+ unit: "Hz",
1820
+ value: (_sample, stats) => stats.refreshHz
1821
+ },
1822
+ {
1823
+ format: whole,
1824
+ icon: "trendingDown",
1825
+ key: "low",
1826
+ label: "1% low",
1827
+ value: (_sample, stats) => stats.frames ? stats.lowFps : null
1828
+ },
1829
+ {
1830
+ format: tenths,
1831
+ icon: "timer",
1832
+ key: "p99",
1833
+ label: "Frame p99",
1834
+ unit: "ms",
1835
+ value: (_sample, stats) => stats.frames ? stats.p99Ms : null
1836
+ },
1837
+ {
1838
+ format: formatCount,
1839
+ icon: "activity",
1840
+ key: "hitches",
1841
+ label: "Hitches",
1842
+ value: (_sample, stats) => stats.frames ? stats.hitches : null
1843
+ },
1844
+ {
1845
+ format: formatCount,
1846
+ icon: "triangle",
1847
+ key: "triangles",
1848
+ label: "Triangles",
1849
+ value: (sample) => sample.render.triangles
1850
+ },
1851
+ {
1852
+ format: formatCount,
1853
+ icon: "spline",
1854
+ key: "lines",
1855
+ label: "Lines",
1856
+ value: (sample) => sample.render.lines
1857
+ },
1858
+ {
1859
+ format: formatCount,
1860
+ icon: "circleDot",
1861
+ key: "points",
1862
+ label: "Points",
1863
+ value: (sample) => sample.render.points
1864
+ },
1865
+ {
1866
+ format: formatCount,
1867
+ icon: "repeat",
1868
+ key: "passes",
1869
+ label: "Render passes",
1870
+ value: (sample) => sample.render.passes
1871
+ },
1872
+ {
1873
+ format: formatCount,
1874
+ icon: "box",
1875
+ key: "geometries",
1876
+ label: "Geometries",
1877
+ value: (sample) => sample.resources.geometries
1878
+ },
1879
+ {
1880
+ format: formatCount,
1881
+ icon: "image",
1882
+ key: "textures",
1883
+ label: "Textures",
1884
+ value: (sample) => sample.resources.textures
1885
+ },
1886
+ {
1887
+ format: formatCount,
1888
+ icon: "palette",
1889
+ key: "shaders",
1890
+ label: "Shaders",
1891
+ value: (sample) => sample.resources.programs
1892
+ }
1893
+ ];
1894
+ const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
1895
+ /** Canvas can't read CSS custom properties; the budget line's amber per theme. */
1896
+ const GUIDE_COLOR = {
1897
+ dark: "rgba(245, 158, 11, 0.75)",
1898
+ light: "rgba(180, 83, 9, 0.7)"
1899
+ };
1900
+ /** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
1901
+ const budgetTitle = (budgets, key) => {
1902
+ const limit = budgets.limits[key];
1903
+ const config = NUMBER_BY_KEY.get(key);
1904
+ if (limit === void 0 || !config) return "";
1905
+ return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
1906
+ };
1907
+ /** The sample value a timing graph shows, `null` when not known. */
1908
+ const timingValue = (sample, metric) => {
1909
+ if (!isTimingAvailable(sample, metric)) return null;
1910
+ const value = readTiming(sample, metric);
1911
+ return metric === "fps" && value === 0 ? null : value;
1912
+ };
2321
1913
  /**
2322
- * Append the stylesheet to `target`'s head, once. Call before mounting a view
2323
- * by hand; {@link mountPerfHud} does it for you unless told not to.
1914
+ * Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
1915
+ * anywhere; the view drives its own throttled repaint. Compact is the floating
1916
+ * HUD; full is the checkbox list that chooses what compact shows. Styles are
1917
+ * not injected here. Call `injectStyles()` or use `mountPerfHud`.
2324
1918
  */
2325
- const injectStyles = (target = document) => {
2326
- if (target.head.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return;
2327
- const style = target.createElement("style");
2328
- style.setAttribute(STYLE_ATTRIBUTE, "");
2329
- style.textContent = PERF_HUD_STYLES;
2330
- target.head.append(style);
1919
+ var PerformanceView = class {
1920
+ element;
1921
+ settings;
1922
+ theme;
1923
+ ownsTheme;
1924
+ unsubscribeTheme;
1925
+ monitor;
1926
+ minIntervalMs;
1927
+ mode;
1928
+ graphValueEls = /* @__PURE__ */ new Map();
1929
+ graphCanvases = /* @__PURE__ */ new Map();
1930
+ graphCheckboxes = /* @__PURE__ */ new Map();
1931
+ statValueEls = /* @__PURE__ */ new Map();
1932
+ statCheckboxes = /* @__PURE__ */ new Map();
1933
+ infoCheckbox;
1934
+ themeRadios = /* @__PURE__ */ new Map();
1935
+ footerThreeEl;
1936
+ footerHardwareEl;
1937
+ footerBackendEl;
1938
+ footerGpuEl;
1939
+ lastEnvironmentKey = "";
1940
+ hudGraphsEl;
1941
+ hudGridEl;
1942
+ hudGraphs = /* @__PURE__ */ new Map();
1943
+ hudNumberEls = /* @__PURE__ */ new Map();
1944
+ budgets;
1945
+ budgetsInput;
1946
+ detectedHz = null;
1947
+ hider = new Hider();
1948
+ hiddenRows = /* @__PURE__ */ new Map();
1949
+ rafId = null;
1950
+ lastPaintAt = 0;
1951
+ resizeObserver;
1952
+ unsubscribe;
1953
+ costsOpen = false;
1954
+ costGroup = "meshes";
1955
+ costSort = "calls";
1956
+ lastCostsAt = -Infinity;
1957
+ costsBodyEl;
1958
+ costsListEl;
1959
+ costGroupRadios = /* @__PURE__ */ new Map();
1960
+ costSortButtons = /* @__PURE__ */ new Map();
1961
+ constructor(options) {
1962
+ this.monitor = options.monitor;
1963
+ this.settings = options.settings ?? new HudSettings();
1964
+ this.ownsTheme = !options.theme;
1965
+ this.theme = options.theme ?? new HudTheme();
1966
+ this.theme.setOverride(this.settings.theme);
1967
+ this.mode = options.mode ?? "full";
1968
+ this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
1969
+ this.budgetsInput = options.budgets;
1970
+ this.budgets = resolveBudgets(options.budgets);
1971
+ this.element = document.createElement("div");
1972
+ this.element.className = "perf-monitor";
1973
+ this.applyModeClass();
1974
+ this.resizeObserver = new ResizeObserver(() => this.resizeCanvases());
1975
+ this.build();
1976
+ this.applyInfoClass();
1977
+ this.rebuildHud();
1978
+ this.onThemeChanged();
1979
+ this.unsubscribe = this.settings.subscribe(() => this.onSelectionChanged());
1980
+ this.unsubscribeTheme = this.theme.subscribe(() => this.onThemeChanged());
1981
+ }
1982
+ start() {
1983
+ if (this.rafId === null) this.rafId = requestAnimationFrame(this.frame);
1984
+ }
1985
+ stop() {
1986
+ if (this.rafId !== null) {
1987
+ cancelAnimationFrame(this.rafId);
1988
+ this.rafId = null;
1989
+ }
1990
+ }
1991
+ setMode(mode) {
1992
+ if (mode === this.mode) return;
1993
+ this.mode = mode;
1994
+ this.applyModeClass();
1995
+ this.resizeCanvases();
1996
+ }
1997
+ getMode() {
1998
+ return this.mode;
1999
+ }
2000
+ /** Replace the budgets; `false` turns them all off. Applies on the next paint. */
2001
+ setBudgets(budgets) {
2002
+ this.budgetsInput = budgets;
2003
+ this.budgets = resolveBudgets(budgets, this.detectedHz);
2004
+ this.applyBudgetTitles();
2005
+ }
2006
+ dispose() {
2007
+ this.stop();
2008
+ this.showAllHidden();
2009
+ this.unsubscribe();
2010
+ this.unsubscribeTheme();
2011
+ if (this.ownsTheme) this.theme.dispose();
2012
+ this.resizeObserver.disconnect();
2013
+ this.element.remove();
2014
+ }
2015
+ frame = (now) => {
2016
+ this.rafId = requestAnimationFrame(this.frame);
2017
+ if (now - this.lastPaintAt < this.minIntervalMs) return;
2018
+ this.lastPaintAt = now;
2019
+ this.render();
2020
+ };
2021
+ render() {
2022
+ const sample = this.monitor.getSample();
2023
+ const stats = this.monitor.getFrameStats();
2024
+ this.followRefresh(stats.refreshHz);
2025
+ if (this.mode === "compact") {
2026
+ this.renderHud(sample, stats);
2027
+ if (this.settings.info) this.renderFooter();
2028
+ return;
2029
+ }
2030
+ for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
2031
+ for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
2032
+ if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
2033
+ this.renderFooter();
2034
+ }
2035
+ renderCosts() {
2036
+ this.lastCostsAt = performance.now();
2037
+ const cost = this.monitor.getSceneCost();
2038
+ const hidden = [...this.hiddenRows.values()].map((row) => this.hiddenCostRow(row));
2039
+ if (!cost) {
2040
+ this.costsListEl.replaceChildren(...hidden, this.costsNote("No scene rendered yet."));
2041
+ return;
2042
+ }
2043
+ const entries = (this.costGroup === "meshes" ? cost.meshes : cost.materials).filter((entry) => !this.hiddenRows.has(entry.key));
2044
+ if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
2045
+ if (entries.length === 0 && hidden.length === 0) {
2046
+ this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
2047
+ return;
2048
+ }
2049
+ const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
2050
+ const row = this.costRowShell(entry);
2051
+ const calls = document.createElement("span");
2052
+ calls.className = "perf-monitor__cost-value";
2053
+ calls.textContent = formatCount(entry.calls);
2054
+ const triangles = document.createElement("span");
2055
+ triangles.className = "perf-monitor__cost-value";
2056
+ triangles.textContent = formatCount(entry.triangles);
2057
+ row.append(calls, triangles, this.eyeButton(entry, false));
2058
+ return row;
2059
+ });
2060
+ this.costsListEl.replaceChildren(...hidden, ...rows);
2061
+ }
2062
+ /** Name button (logs the objects) in a row; the caller appends the value cells and the eye. */
2063
+ costRowShell(entry) {
2064
+ const row = document.createElement("div");
2065
+ row.className = "perf-monitor__cost";
2066
+ const name = document.createElement("button");
2067
+ name.type = "button";
2068
+ name.className = "perf-monitor__cost-name";
2069
+ name.title = costTitle(entry);
2070
+ name.textContent = entry.label;
2071
+ const copies = copiesOf(entry);
2072
+ if (copies) {
2073
+ const suffix = document.createElement("span");
2074
+ suffix.className = "perf-monitor__cost-copies";
2075
+ suffix.textContent = ` ${copies}`;
2076
+ name.append(suffix);
2077
+ }
2078
+ name.addEventListener("click", () => {
2079
+ console.log(`three-meter: ${entry.label}`, entry.objects);
2080
+ });
2081
+ row.append(name);
2082
+ return row;
2083
+ }
2084
+ /** A hidden row: what hiding it saved, measured from the timing history. */
2085
+ hiddenCostRow(hidden) {
2086
+ const row = this.costRowShell(hidden.entry);
2087
+ row.classList.add("is-hidden");
2088
+ const saved = document.createElement("span");
2089
+ saved.className = "perf-monitor__cost-saved";
2090
+ const label = hidden.metric.toUpperCase();
2091
+ if (performance.now() - hidden.hiddenAt < MEASURE_SETTLE_MS) saved.textContent = "measuring…";
2092
+ else {
2093
+ const now = meanOfLast(this.monitor.getHistory(hidden.metric), MEASURE_FRAMES);
2094
+ const delta = hidden.baseline - now;
2095
+ saved.textContent = `${delta >= 0 ? "−" : "+"}${Math.abs(delta).toFixed(1)} ms ${label}`;
2096
+ saved.title = `${label} time went from ${hidden.baseline.toFixed(1)} ms to ${now.toFixed(1)} ms with this hidden, averaged over ${MEASURE_FRAMES} frames each side.`;
2097
+ }
2098
+ row.append(saved, this.eyeButton(hidden.entry, true));
2099
+ return row;
2100
+ }
2101
+ eyeButton(entry, hidden) {
2102
+ const eye = document.createElement("button");
2103
+ eye.type = "button";
2104
+ eye.className = "perf-monitor__cost-eye";
2105
+ eye.setAttribute("aria-pressed", String(hidden));
2106
+ const label = hidden ? `Show ${entry.label} again` : `Hide ${entry.label} to measure its cost`;
2107
+ eye.title = label;
2108
+ eye.setAttribute("aria-label", label);
2109
+ eye.append(createIcon(hidden ? "eyeOff" : "eye", "perf-monitor__icon"));
2110
+ eye.addEventListener("click", () => hidden ? this.showRow(entry.key) : this.hideRow(entry));
2111
+ return eye;
2112
+ }
2113
+ hideRow(entry) {
2114
+ const metric = this.monitor.getSample().gpu.available ? "gpu" : "cpu";
2115
+ this.hiddenRows.set(entry.key, {
2116
+ baseline: meanOfLast(this.monitor.getHistory(metric), MEASURE_FRAMES),
2117
+ entry,
2118
+ hiddenAt: performance.now(),
2119
+ metric
2120
+ });
2121
+ const all = this.monitor.getObjectsForKey(entry.key);
2122
+ this.hider.hide(entry.key, all.length > 0 ? all : entry.objects);
2123
+ this.renderCosts();
2124
+ }
2125
+ showRow(key) {
2126
+ this.hider.show(key);
2127
+ this.hiddenRows.delete(key);
2128
+ this.renderCosts();
2129
+ }
2130
+ /** Everything hidden goes back: on closing top costs and on dispose. */
2131
+ showAllHidden() {
2132
+ this.hider.restoreAll();
2133
+ this.hiddenRows.clear();
2134
+ }
2135
+ costsNote(text) {
2136
+ const note = document.createElement("div");
2137
+ note.className = "perf-monitor__costs-note";
2138
+ note.textContent = text;
2139
+ return note;
2140
+ }
2141
+ /** The monitor caches the environment once its backend settles; until then this re-reads. */
2142
+ renderFooter() {
2143
+ const environment = this.monitor.getEnvironment();
2144
+ const three = environment.three ? `three r${environment.three}` : "";
2145
+ const backend = backendLabel(environment);
2146
+ const gpu = environment.gpu ?? "";
2147
+ const key = `${three}\n${backend}\n${gpu}`;
2148
+ if (key === this.lastEnvironmentKey) return;
2149
+ this.lastEnvironmentKey = key;
2150
+ this.footerThreeEl.textContent = three;
2151
+ this.footerBackendEl.textContent = backend;
2152
+ this.footerBackendEl.hidden = backend === "";
2153
+ this.footerBackendEl.title = environment.fallback ? "WebGPURenderer couldn't get WebGPU and fell back to WebGL2" : "";
2154
+ this.footerBackendEl.classList.toggle("is-fallback", environment.fallback);
2155
+ this.footerGpuEl.textContent = gpu;
2156
+ this.footerGpuEl.title = gpu;
2157
+ this.footerHardwareEl.hidden = backend === "" && gpu === "";
2158
+ }
2159
+ /** Budgets follow the detected refresh rate unless `targetFps` was given. */
2160
+ followRefresh(hz) {
2161
+ if (hz === this.detectedHz) return;
2162
+ this.detectedHz = hz;
2163
+ this.budgets = resolveBudgets(this.budgetsInput, hz);
2164
+ this.applyBudgetTitles();
2165
+ }
2166
+ renderHud(sample, stats) {
2167
+ for (const [key, element] of this.hudNumberEls) {
2168
+ const config = NUMBER_BY_KEY.get(key);
2169
+ if (config) this.paintNumber(config, element, sample, stats);
2170
+ }
2171
+ for (const [metric, graph] of this.hudGraphs) {
2172
+ const config = TIMINGS.find((timing) => timing.metric === metric);
2173
+ this.paintGraph(config, sample, graph.valueEl, graph, "—");
2174
+ }
2175
+ }
2176
+ paintNumber(config, element, sample, stats) {
2177
+ const value = config.value(sample, stats, this.budgets.frameMs);
2178
+ element.textContent = value === null ? "—" : config.format(value);
2179
+ element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
2180
+ }
2181
+ /** Value and sparkline, with the budget line drawn once the series reaches it. */
2182
+ paintGraph(config, sample, valueEl, graph, missing) {
2183
+ const value = timingValue(sample, config.metric);
2184
+ const available = isTimingAvailable(sample, config.metric);
2185
+ const limit = this.budgets.limits[config.metric];
2186
+ valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
2187
+ valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
2188
+ drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
2189
+ color: GUIDE_COLOR[this.theme.resolved],
2190
+ value: limit
2191
+ });
2192
+ }
2193
+ /** Each value's tooltip names its budget, so amber explains itself. */
2194
+ applyBudgetTitles() {
2195
+ for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
2196
+ for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
2197
+ for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
2198
+ for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
2199
+ }
2200
+ formatTiming(config, value) {
2201
+ return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
2202
+ }
2203
+ build() {
2204
+ const hud = document.createElement("div");
2205
+ hud.className = "perf-monitor__hud";
2206
+ this.hudGraphsEl = document.createElement("div");
2207
+ this.hudGraphsEl.className = "perf-monitor__hud-graphs";
2208
+ this.hudGridEl = document.createElement("div");
2209
+ this.hudGridEl.className = "perf-monitor__hud-grid";
2210
+ hud.append(this.hudGraphsEl, this.hudGridEl);
2211
+ const options = document.createElement("div");
2212
+ options.className = "perf-monitor__section perf-monitor__options";
2213
+ const themeRow = document.createElement("div");
2214
+ themeRow.className = "perf-monitor__row perf-monitor__row--static";
2215
+ const themeLabel = document.createElement("span");
2216
+ themeLabel.className = "perf-monitor__label";
2217
+ themeLabel.textContent = "theme";
2218
+ const segment = document.createElement("div");
2219
+ segment.className = "perf-monitor__segment";
2220
+ segment.setAttribute("role", "radiogroup");
2221
+ segment.setAttribute("aria-label", "Panel theme");
2222
+ for (const option of THEME_OPTIONS) {
2223
+ const radio = document.createElement("button");
2224
+ radio.type = "button";
2225
+ radio.className = "perf-monitor__segment-option";
2226
+ radio.setAttribute("role", "radio");
2227
+ radio.setAttribute("aria-checked", "false");
2228
+ radio.setAttribute("aria-label", option.label);
2229
+ radio.title = option.label;
2230
+ radio.append(createIcon(option.icon, "perf-monitor__icon"));
2231
+ radio.addEventListener("click", () => this.settings.setTheme(option.mode));
2232
+ this.themeRadios.set(option.mode, radio);
2233
+ segment.append(radio);
2234
+ }
2235
+ themeRow.append(createIcon("contrast", "perf-monitor__icon"), themeLabel, segment);
2236
+ const explainRow = document.createElement("label");
2237
+ explainRow.className = "perf-monitor__row";
2238
+ const explainCheckbox = this.buildCheckbox(false, "Explain each metric", (on) => this.element.classList.toggle("perf-monitor--explain", on));
2239
+ const explainLabel = document.createElement("span");
2240
+ explainLabel.className = "perf-monitor__label";
2241
+ explainLabel.textContent = "explain metrics";
2242
+ explainRow.append(createIcon("help", "perf-monitor__icon"), explainLabel, explainCheckbox);
2243
+ options.append(themeRow, explainRow, this.buildReportRow());
2244
+ const graphs = document.createElement("div");
2245
+ graphs.className = "perf-monitor__graphs";
2246
+ for (const config of TIMINGS) {
2247
+ const graph = document.createElement("div");
2248
+ graph.className = "perf-monitor__graph";
2249
+ const canvas = document.createElement("canvas");
2250
+ canvas.className = "perf-monitor__canvas";
2251
+ const overlay = document.createElement("label");
2252
+ overlay.className = "perf-monitor__graph-overlay perf-monitor__graph-overlay--toggle";
2253
+ const head = document.createElement("span");
2254
+ head.className = "perf-monitor__graph-head";
2255
+ const checkbox = this.buildCheckbox(this.settings.isGraphEnabled(config.metric), `Show ${config.label} graph in HUD`, (on) => this.settings.setGraphEnabled(config.metric, on));
2256
+ this.graphCheckboxes.set(config.metric, checkbox);
2257
+ const label = document.createElement("span");
2258
+ label.className = "perf-monitor__graph-label";
2259
+ label.textContent = config.label;
2260
+ label.title = METRIC_HELP[config.metric] ?? "";
2261
+ const value = document.createElement("span");
2262
+ value.className = "perf-monitor__graph-value";
2263
+ value.textContent = "—";
2264
+ this.graphValueEls.set(config.metric, value);
2265
+ const tail = document.createElement("span");
2266
+ tail.className = "perf-monitor__graph-head";
2267
+ tail.append(value, checkbox);
2268
+ head.append(createIcon(config.icon, "perf-monitor__icon"), label);
2269
+ overlay.append(head, tail);
2270
+ graph.append(canvas, overlay);
2271
+ graphs.append(graph);
2272
+ const ctx = canvas.getContext("2d");
2273
+ if (ctx) {
2274
+ this.graphCanvases.set(config.metric, {
2275
+ canvas,
2276
+ ctx,
2277
+ height: 0,
2278
+ width: 0
2279
+ });
2280
+ this.resizeObserver.observe(graph);
2281
+ }
2282
+ }
2283
+ const stats = document.createElement("div");
2284
+ stats.className = "perf-monitor__section";
2285
+ for (const config of NUMBERS) {
2286
+ const rowEl = document.createElement("label");
2287
+ rowEl.className = "perf-monitor__row";
2288
+ const checkbox = this.buildCheckbox(this.settings.isNumberEnabled(config.key), `Show ${config.label} in HUD`, (on) => this.settings.setNumberEnabled(config.key, on));
2289
+ this.statCheckboxes.set(config.key, checkbox);
2290
+ const help = METRIC_HELP[config.key] ?? "";
2291
+ const label = document.createElement("span");
2292
+ label.className = "perf-monitor__label";
2293
+ label.textContent = config.label;
2294
+ label.title = help;
2295
+ const value = document.createElement("span");
2296
+ value.className = "perf-monitor__value";
2297
+ value.textContent = "—";
2298
+ this.statValueEls.set(config.key, value);
2299
+ const hint = document.createElement("span");
2300
+ hint.className = "perf-monitor__hint";
2301
+ hint.textContent = help;
2302
+ rowEl.append(createIcon(config.icon, "perf-monitor__icon"), label, value, checkbox, hint);
2303
+ stats.append(rowEl);
2304
+ }
2305
+ const footer = document.createElement("div");
2306
+ footer.className = "perf-monitor__footer";
2307
+ this.infoCheckbox = this.buildCheckbox(this.settings.info, "Show version and environment in HUD", (on) => this.settings.setInfo(on));
2308
+ const footerBody = document.createElement("div");
2309
+ footerBody.className = "perf-monitor__footer-body";
2310
+ const softwareRow = document.createElement("div");
2311
+ softwareRow.className = "perf-monitor__footer-row";
2312
+ const release = document.createElement("a");
2313
+ release.className = "perf-monitor__link";
2314
+ release.href = RELEASE_URL;
2315
+ release.target = "_blank";
2316
+ release.rel = "noopener noreferrer";
2317
+ release.title = `Release notes for v${version}`;
2318
+ release.textContent = `three-meter v${version}`;
2319
+ this.footerThreeEl = document.createElement("span");
2320
+ softwareRow.append(release, this.footerThreeEl);
2321
+ this.footerHardwareEl = document.createElement("div");
2322
+ this.footerHardwareEl.className = "perf-monitor__footer-row";
2323
+ this.footerHardwareEl.hidden = true;
2324
+ this.footerBackendEl = document.createElement("span");
2325
+ this.footerBackendEl.className = "perf-monitor__badge";
2326
+ this.footerGpuEl = document.createElement("span");
2327
+ this.footerGpuEl.className = "perf-monitor__footer-gpu";
2328
+ this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
2329
+ footerBody.append(softwareRow, this.footerHardwareEl);
2330
+ footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
2331
+ this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
2332
+ }
2333
+ rebuildHud() {
2334
+ const { selection } = this.settings;
2335
+ for (const graph of this.hudGraphs.values()) this.resizeObserver.unobserve(graph.canvas.parentElement ?? graph.canvas);
2336
+ this.hudGraphs.clear();
2337
+ this.hudNumberEls.clear();
2338
+ this.hudGraphsEl.replaceChildren();
2339
+ this.hudGridEl.replaceChildren();
2340
+ for (const config of TIMINGS) {
2341
+ if (!selection.graphs.has(config.metric)) continue;
2342
+ const row = document.createElement("div");
2343
+ row.className = "perf-monitor__hud-graph";
2344
+ const canvas = document.createElement("canvas");
2345
+ canvas.className = "perf-monitor__canvas";
2346
+ const overlay = document.createElement("div");
2347
+ overlay.className = "perf-monitor__graph-overlay";
2348
+ const label = document.createElement("span");
2349
+ label.className = "perf-monitor__graph-label";
2350
+ label.textContent = config.label;
2351
+ label.title = METRIC_HELP[config.metric] ?? "";
2352
+ const head = document.createElement("span");
2353
+ head.className = "perf-monitor__graph-head";
2354
+ head.append(createIcon(config.icon, "perf-monitor__icon"), label);
2355
+ const value = document.createElement("span");
2356
+ value.className = "perf-monitor__graph-value";
2357
+ value.textContent = "—";
2358
+ overlay.append(head, value);
2359
+ row.append(canvas, overlay);
2360
+ this.hudGraphsEl.append(row);
2361
+ const ctx = canvas.getContext("2d");
2362
+ if (ctx) {
2363
+ this.hudGraphs.set(config.metric, {
2364
+ canvas,
2365
+ ctx,
2366
+ height: 0,
2367
+ valueEl: value,
2368
+ width: 0
2369
+ });
2370
+ this.resizeObserver.observe(row);
2371
+ }
2372
+ }
2373
+ for (const config of NUMBERS) {
2374
+ if (!selection.numbers.has(config.key)) continue;
2375
+ const item = document.createElement("div");
2376
+ item.className = "perf-monitor__hud-item";
2377
+ const label = document.createElement("span");
2378
+ label.className = "perf-monitor__hud-label";
2379
+ label.title = METRIC_HELP[config.key] ?? "";
2380
+ label.append(createIcon(config.icon, "perf-monitor__icon"), config.label);
2381
+ const value = document.createElement("span");
2382
+ value.className = "perf-monitor__hud-value";
2383
+ value.textContent = "—";
2384
+ item.append(label, value);
2385
+ this.hudGridEl.append(item);
2386
+ this.hudNumberEls.set(config.key, value);
2387
+ }
2388
+ this.applyBudgetTitles();
2389
+ this.resizeCanvases();
2390
+ }
2391
+ onSelectionChanged() {
2392
+ for (const [metric, checkbox] of this.graphCheckboxes) checkbox.checked = this.settings.isGraphEnabled(metric);
2393
+ for (const [key, checkbox] of this.statCheckboxes) checkbox.checked = this.settings.isNumberEnabled(key);
2394
+ this.infoCheckbox.checked = this.settings.info;
2395
+ this.applyInfoClass();
2396
+ this.theme.setOverride(this.settings.theme);
2397
+ this.rebuildHud();
2398
+ }
2399
+ /** Repaint the palette and mark the radio matching what applies (pick, else consumer mode). */
2400
+ onThemeChanged() {
2401
+ applyTheme(this.element, this.theme);
2402
+ for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
2403
+ }
2404
+ /** "top costs": a toggle row, then meshes / materials and a sortable top five. */
2405
+ buildCosts() {
2406
+ const section = document.createElement("div");
2407
+ section.className = "perf-monitor__section perf-monitor__costs";
2408
+ const toggleRow = document.createElement("label");
2409
+ toggleRow.className = "perf-monitor__row";
2410
+ toggleRow.title = COSTS_HELP;
2411
+ const label = document.createElement("span");
2412
+ label.className = "perf-monitor__label";
2413
+ label.textContent = "top costs";
2414
+ const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
2415
+ this.costsOpen = on;
2416
+ this.costsBodyEl.hidden = !on;
2417
+ if (!on) this.showAllHidden();
2418
+ if (on) this.renderCosts();
2419
+ });
2420
+ toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
2421
+ this.costsBodyEl = document.createElement("div");
2422
+ this.costsBodyEl.className = "perf-monitor__costs-body";
2423
+ this.costsBodyEl.hidden = true;
2424
+ const head = document.createElement("div");
2425
+ head.className = "perf-monitor__cost perf-monitor__cost--head";
2426
+ const segment = document.createElement("div");
2427
+ segment.className = "perf-monitor__segment";
2428
+ segment.setAttribute("role", "radiogroup");
2429
+ segment.setAttribute("aria-label", "Group costs by");
2430
+ for (const option of COST_GROUPS) {
2431
+ const radio = document.createElement("button");
2432
+ radio.type = "button";
2433
+ radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
2434
+ radio.setAttribute("role", "radio");
2435
+ radio.setAttribute("aria-checked", String(option.group === this.costGroup));
2436
+ radio.textContent = option.label;
2437
+ radio.addEventListener("click", () => {
2438
+ this.costGroup = option.group;
2439
+ for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
2440
+ this.renderCosts();
2441
+ });
2442
+ this.costGroupRadios.set(option.group, radio);
2443
+ segment.append(radio);
2444
+ }
2445
+ head.append(segment);
2446
+ for (const sort of ["calls", "triangles"]) {
2447
+ const button = document.createElement("button");
2448
+ button.type = "button";
2449
+ button.className = "perf-monitor__cost-sort";
2450
+ button.textContent = sort === "calls" ? "calls" : "tris";
2451
+ button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
2452
+ button.setAttribute("aria-pressed", String(sort === this.costSort));
2453
+ button.addEventListener("click", () => {
2454
+ this.costSort = sort;
2455
+ for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
2456
+ this.renderCosts();
2457
+ });
2458
+ this.costSortButtons.set(sort, button);
2459
+ head.append(button);
2460
+ }
2461
+ head.append(document.createElement("span"));
2462
+ this.costsListEl = document.createElement("div");
2463
+ this.costsListEl.className = "perf-monitor__costs-list";
2464
+ const hint = document.createElement("span");
2465
+ hint.className = "perf-monitor__hint";
2466
+ hint.textContent = COSTS_HELP;
2467
+ this.costsBodyEl.append(head, this.costsListEl, hint);
2468
+ section.append(toggleRow, this.costsBodyEl);
2469
+ return section;
2470
+ }
2471
+ buildReportRow() {
2472
+ const row = document.createElement("div");
2473
+ row.className = "perf-monitor__row perf-monitor__row--static";
2474
+ const label = document.createElement("span");
2475
+ label.className = "perf-monitor__label";
2476
+ label.textContent = "report";
2477
+ const button = document.createElement("button");
2478
+ button.type = "button";
2479
+ button.className = "perf-monitor__button";
2480
+ button.title = "Copy versions, GPU and current metrics as Markdown for a bug report";
2481
+ button.textContent = "copy";
2482
+ let resetTimer = 0;
2483
+ const flash = (text) => {
2484
+ button.textContent = text;
2485
+ window.clearTimeout(resetTimer);
2486
+ resetTimer = window.setTimeout(() => {
2487
+ button.textContent = "copy";
2488
+ }, COPY_FEEDBACK_MS);
2489
+ };
2490
+ button.addEventListener("click", () => {
2491
+ copyText(formatReport(this.monitor)).then((copied) => flash(copied ? "copied" : "failed"));
2492
+ });
2493
+ row.append(createIcon("clipboard", "perf-monitor__icon"), label, button);
2494
+ return row;
2495
+ }
2496
+ buildCheckbox(checked, title, onChange) {
2497
+ const checkbox = document.createElement("input");
2498
+ checkbox.type = "checkbox";
2499
+ checkbox.className = "perf-monitor__checkbox";
2500
+ checkbox.checked = checked;
2501
+ checkbox.title = title;
2502
+ checkbox.setAttribute("aria-label", title);
2503
+ checkbox.addEventListener("change", () => onChange(checkbox.checked));
2504
+ return checkbox;
2505
+ }
2506
+ applyInfoClass() {
2507
+ this.element.classList.toggle("perf-monitor--info", this.settings.info);
2508
+ }
2509
+ applyModeClass() {
2510
+ this.element.classList.toggle("perf-monitor--compact", this.mode === "compact");
2511
+ this.element.classList.toggle("perf-monitor--full", this.mode === "full");
2512
+ }
2513
+ resizeCanvases() {
2514
+ const dpr = window.devicePixelRatio || 1;
2515
+ const sizeAll = (graphs) => {
2516
+ for (const graph of graphs) {
2517
+ const { canvas, ctx } = graph;
2518
+ const width = canvas.clientWidth;
2519
+ const height = canvas.clientHeight;
2520
+ if (width === 0 || height === 0) continue;
2521
+ canvas.width = Math.round(width * dpr);
2522
+ canvas.height = Math.round(height * dpr);
2523
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2524
+ graph.width = width;
2525
+ graph.height = height;
2526
+ }
2527
+ };
2528
+ sizeAll(this.graphCanvases.values());
2529
+ sizeAll(this.hudGraphs.values());
2530
+ }
2331
2531
  };
2332
2532
  //#endregion
2333
2533
  //#region src/ui/mount-perf-hud.ts
2334
- const TOUCH_LINGER_MS = 2500;
2335
- const disc = (label, className, icon) => {
2336
- const button = document.createElement("button");
2337
- button.type = "button";
2338
- button.className = `perf-hud__disc ${className}`.trim();
2339
- button.setAttribute("aria-label", label);
2340
- button.title = label;
2341
- button.append(createIcon(icon, "perf-hud__icon"));
2342
- return button;
2343
- };
2344
2534
  const mountPerfHud = (monitor, options = {}) => {
2345
2535
  const storageKey = options.storageKey === void 0 ? DEFAULT_STORAGE_KEY : options.storageKey;
2346
2536
  const settings = options.settings ?? new HudSettings({ storageKey });
2347
2537
  const theme = new HudTheme(options.theme ?? "system");
2348
- const parent = options.parent ?? document.body;
2349
- if (options.injectStyles ?? true) injectStyles(parent.ownerDocument);
2350
- const host = document.createElement("div");
2351
- host.className = "perf-hud";
2352
- host.dataset.edge = "left";
2353
- host.setAttribute("aria-label", options.label ?? "Performance");
2354
- const hotspot = document.createElement("div");
2355
- hotspot.className = "perf-hud__hotspot";
2356
- hotspot.setAttribute("aria-hidden", "true");
2357
- const tools = document.createElement("div");
2358
- tools.className = "perf-hud__tools";
2359
- const grip = disc("Drag performance panel", "perf-hud__drag", "grip");
2360
- const toggle = disc("Toggle full performance metrics", "", "sliders");
2361
- const dim = disc("Dim the panel when the pointer leaves", "perf-hud__dim", "blend");
2362
- dim.addEventListener("click", () => settings.setDim(!settings.dim));
2363
- tools.append(grip, toggle, dim);
2364
2538
  const view = new PerformanceView({
2365
2539
  budgets: options.budgets,
2366
2540
  mode: options.mode ?? "compact",
@@ -2369,74 +2543,39 @@ const mountPerfHud = (monitor, options = {}) => {
2369
2543
  settings,
2370
2544
  theme
2371
2545
  });
2372
- host.append(hotspot, tools, view.element);
2373
- parent.append(host);
2546
+ const panel = mountDevPanel({
2547
+ brand: "three-meter",
2548
+ content: view.element,
2549
+ defaultPlacement: options.defaultPlacement,
2550
+ dim: {
2551
+ get: () => settings.dim,
2552
+ set: (on) => settings.setDim(on),
2553
+ subscribe: (listener) => settings.subscribe(listener)
2554
+ },
2555
+ injectStyles: options.injectStyles,
2556
+ label: options.label ?? "Performance",
2557
+ onToggle: () => setMode(view.getMode() === "compact" ? "full" : "compact"),
2558
+ parent: options.parent,
2559
+ storageKey,
2560
+ theme,
2561
+ toggleLabel: "Toggle full performance metrics"
2562
+ });
2563
+ const host = panel.element;
2374
2564
  const applyMode = () => {
2375
2565
  host.classList.toggle("perf-hud--full", view.getMode() === "full");
2376
2566
  };
2377
- const applyDim = () => {
2378
- host.classList.toggle("perf-hud--dim", settings.dim);
2379
- dim.setAttribute("aria-pressed", String(settings.dim));
2380
- dim.title = `Dim on leave: ${settings.dim ? "on" : "off"}`;
2381
- };
2382
- applyMode();
2383
- applyDim();
2384
- applyTheme(host, theme);
2385
- const unsubscribe = settings.subscribe(applyDim);
2386
- const unsubscribeTheme = theme.subscribe(() => applyTheme(host, theme));
2387
- const dock = dockPanel(host, {
2388
- defaultPlacement: options.defaultPlacement,
2389
- handle: grip,
2390
- storageKey
2391
- });
2392
2567
  const setMode = (mode) => {
2393
2568
  view.setMode(mode);
2394
2569
  applyMode();
2395
- requestAnimationFrame(() => dock.refresh());
2396
- };
2397
- toggle.addEventListener("click", () => {
2398
- setMode(view.getMode() === "compact" ? "full" : "compact");
2399
- });
2400
- let hideTimer = 0;
2401
- const sleep = () => {
2402
- if (host.classList.contains("is-dragging")) return;
2403
- host.classList.remove("is-awake");
2404
- };
2405
- const wake = (linger) => {
2406
- host.classList.add("is-awake");
2407
- window.clearTimeout(hideTimer);
2408
- if (linger) hideTimer = window.setTimeout(sleep, TOUCH_LINGER_MS);
2409
- };
2410
- const onEnter = () => {
2411
- wake(false);
2412
- };
2413
- const onLeave = (event) => {
2414
- if (host.contains(event.relatedTarget)) return;
2415
- if (event.pointerType === "touch" || event.pointerType === "pen") {
2416
- wake(true);
2417
- return;
2418
- }
2419
- sleep();
2420
- };
2421
- const onDown = (event) => {
2422
- wake(event.pointerType !== "mouse");
2570
+ panel.refresh();
2423
2571
  };
2424
- host.addEventListener("pointerenter", onEnter);
2425
- host.addEventListener("pointerleave", onLeave);
2426
- host.addEventListener("pointerdown", onDown);
2572
+ applyMode();
2427
2573
  view.start();
2428
2574
  return {
2429
2575
  dispose: () => {
2430
- window.clearTimeout(hideTimer);
2431
- host.removeEventListener("pointerenter", onEnter);
2432
- host.removeEventListener("pointerleave", onLeave);
2433
- host.removeEventListener("pointerdown", onDown);
2434
- unsubscribe();
2435
- unsubscribeTheme();
2436
- dock.dispose();
2576
+ panel.dispose();
2437
2577
  view.dispose();
2438
2578
  theme.dispose();
2439
- host.remove();
2440
2579
  },
2441
2580
  element: host,
2442
2581
  getMode: () => view.getMode(),
@@ -2450,6 +2589,6 @@ const mountPerfHud = (monitor, options = {}) => {
2450
2589
  };
2451
2590
  };
2452
2591
  //#endregion
2453
- 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 };
2592
+ export { PERF_HUD_STYLES as a, DEFAULT_STORAGE_KEY as c, THEME_MODES as d, isThemeMode as f, mountDevPanel as i, HudSettings as l, PerformanceView as n, injectStyles as o, resolveBudgets as p, formatReport as r, dockPanel as s, mountPerfHud as t, HudTheme as u };
2454
2593
 
2455
- //# sourceMappingURL=mount-perf-hud-U_Xlj0Od.js.map
2594
+ //# sourceMappingURL=mount-perf-hud-DZKJ1L3J.js.map