@zkmake/three-meter 0.9.2 → 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.
- package/README.md +55 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{mount-perf-hud-BL84ddLm.js → mount-perf-hud-DZKJ1L3J.js} +1444 -1089
- package/dist/mount-perf-hud-DZKJ1L3J.js.map +1 -0
- package/dist/{mount-perf-hud-BKpk62Pp.d.ts → mount-perf-hud-MTPYNhG9.d.ts} +29 -9
- package/dist/{performance-monitor-DgHxeQ2X.d.ts → performance-monitor-BoY_-GTt.d.ts} +13 -1
- package/dist/{performance-monitor-DsBp13ts.js → performance-monitor-BqnoiLEs.js} +87 -9
- package/dist/performance-monitor-BqnoiLEs.js.map +1 -0
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/styles.css +121 -8
- package/dist/ui.d.ts +45 -4
- package/dist/ui.js +2 -2
- package/package.json +14 -9
- package/dist/mount-perf-hud-BL84ddLm.js.map +0 -1
- package/dist/performance-monitor-DsBp13ts.js.map +0 -1
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
//#region src/ui/budgets.ts
|
|
2
2
|
const DEFAULT_TARGET_FPS = 60;
|
|
3
|
-
const FLOORS = /* @__PURE__ */ new Set([
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const FLOORS = /* @__PURE__ */ new Set([
|
|
4
|
+
"fps",
|
|
5
|
+
"headroom",
|
|
6
|
+
"low"
|
|
7
|
+
]);
|
|
8
|
+
/** `detectedHz` is the monitor's refresh reading; an explicit `targetFps` wins over it. */
|
|
9
|
+
const resolveBudgets = (budgets, detectedHz = null) => {
|
|
10
|
+
const targetFps = budgets && budgets.targetFps || detectedHz || DEFAULT_TARGET_FPS;
|
|
6
11
|
const frameMs = 1e3 / targetFps;
|
|
7
12
|
if (budgets === false) return {
|
|
8
13
|
frameMs,
|
|
9
|
-
limits: {}
|
|
14
|
+
limits: {},
|
|
15
|
+
targetFps
|
|
10
16
|
};
|
|
11
17
|
const merged = {
|
|
12
18
|
cpu: frameMs,
|
|
13
19
|
fps: targetFps * .95,
|
|
14
20
|
gpu: frameMs,
|
|
21
|
+
headroom: 0,
|
|
15
22
|
low: targetFps / 2,
|
|
16
23
|
p99: frameMs * 1.5,
|
|
17
24
|
...budgets
|
|
@@ -20,7 +27,8 @@ const resolveBudgets = (budgets) => {
|
|
|
20
27
|
for (const [key, limit] of Object.entries(merged)) if (key !== "targetFps" && typeof limit === "number" && Number.isFinite(limit)) limits[key] = limit;
|
|
21
28
|
return {
|
|
22
29
|
frameMs,
|
|
23
|
-
limits
|
|
30
|
+
limits,
|
|
31
|
+
targetFps
|
|
24
32
|
};
|
|
25
33
|
};
|
|
26
34
|
const isFloor = (key) => FLOORS.has(key);
|
|
@@ -486,6 +494,9 @@ const dockPanel = (element, options = {}) => {
|
|
|
486
494
|
//#endregion
|
|
487
495
|
//#region src/ui/icons.ts
|
|
488
496
|
const ICON_MARKUP = {
|
|
497
|
+
eye: "<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
|
|
498
|
+
eyeOff: "<path d=\"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49\"/><path d=\"M14.084 14.158a3 3 0 0 1-4.242-4.242\"/><path d=\"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143\"/><path d=\"m2 2 20 20\"/>",
|
|
499
|
+
hourglass: "<path d=\"M5 22h14\"/><path d=\"M5 2h14\"/><path d=\"M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22\"/><path d=\"M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2\"/>",
|
|
489
500
|
flame: "<path d=\"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z\"/>",
|
|
490
501
|
activity: "<path d=\"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2\"/>",
|
|
491
502
|
box: "<path d=\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"M12 22V12\"/>",
|
|
@@ -526,991 +537,32 @@ const createIcon = (name, className) => {
|
|
|
526
537
|
return svg;
|
|
527
538
|
};
|
|
528
539
|
//#endregion
|
|
529
|
-
//#region
|
|
530
|
-
var name = "@zkmake/three-meter";
|
|
531
|
-
var version = "0.9.2";
|
|
532
|
-
//#endregion
|
|
533
|
-
//#region src/ui/clipboard.ts
|
|
534
|
-
/**
|
|
535
|
-
* Copy text from a click handler. The async Clipboard API needs a secure
|
|
536
|
-
* context and the `clipboard-write` permission; a dev server opened over a LAN
|
|
537
|
-
* IP has neither, and some embeds deny the permission. The fallback is the old
|
|
538
|
-
* select-a-textarea `execCommand("copy")`, still honoured inside a user gesture.
|
|
539
|
-
*/
|
|
540
|
-
const copyWithSelection = (text) => {
|
|
541
|
-
const textarea = document.createElement("textarea");
|
|
542
|
-
textarea.value = text;
|
|
543
|
-
textarea.setAttribute("readonly", "");
|
|
544
|
-
textarea.style.position = "fixed";
|
|
545
|
-
textarea.style.opacity = "0";
|
|
546
|
-
textarea.style.pointerEvents = "none";
|
|
547
|
-
const previous = document.activeElement;
|
|
548
|
-
document.body.append(textarea);
|
|
549
|
-
textarea.select();
|
|
550
|
-
try {
|
|
551
|
-
return document.execCommand("copy");
|
|
552
|
-
} catch {
|
|
553
|
-
return false;
|
|
554
|
-
} finally {
|
|
555
|
-
textarea.remove();
|
|
556
|
-
previous?.focus?.();
|
|
557
|
-
}
|
|
558
|
-
};
|
|
559
|
-
const copyText = async (text) => {
|
|
560
|
-
try {
|
|
561
|
-
await navigator.clipboard.writeText(text);
|
|
562
|
-
return true;
|
|
563
|
-
} catch {
|
|
564
|
-
return copyWithSelection(text);
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
//#endregion
|
|
568
|
-
//#region src/ui/format.ts
|
|
569
|
-
/**
|
|
570
|
-
* Number formatting for the card. Counts stay exact with thousands separators
|
|
571
|
-
* while they fit the compact grid's 6ch value column (`99,999`), then switch
|
|
572
|
-
* to compact notation (`250K`, `1.4M`, `12.3M`, `1.2B`) so a triangle count in
|
|
573
|
-
* the millions can't run into the neighbouring label.
|
|
574
|
-
*/
|
|
575
|
-
const COMPACT_FROM = 1e5;
|
|
576
|
-
const exact = new Intl.NumberFormat("en-US");
|
|
577
|
-
const compact = new Intl.NumberFormat("en-US", {
|
|
578
|
-
maximumFractionDigits: 1,
|
|
579
|
-
notation: "compact"
|
|
580
|
-
});
|
|
581
|
-
const formatCount = (value) => Math.abs(value) < COMPACT_FROM ? exact.format(value) : compact.format(value);
|
|
582
|
-
//#endregion
|
|
583
|
-
//#region src/ui/metric-help.ts
|
|
584
|
-
/**
|
|
585
|
-
* One plain-language line per metric: what it is, and what a bad reading
|
|
586
|
-
* means. Shown as the label's tooltip and, with "explain metrics" on, under
|
|
587
|
-
* each row. Keyed like the stat rows; the graphs reuse `fps`, `cpu`, `gpu`.
|
|
588
|
-
*/
|
|
589
|
-
const METRIC_HELP = {
|
|
590
|
-
calls: "Separate draw commands sent to the GPU this frame. Fewer is better: merge or instance meshes that share a material.",
|
|
591
|
-
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).",
|
|
592
|
-
fps: "Frames drawn per second. Higher is smoother; most displays top out at 60 or 120.",
|
|
593
|
-
geometries: "Geometries held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
|
|
594
|
-
gpu: "Time the graphics card spent drawing the frame. High means shaders, overdraw or resolution are the bottleneck.",
|
|
595
|
-
hitches: "Frames that took over twice as long as usual, in the last 1,000 frames. Each one is a visible stutter.",
|
|
596
|
-
lines: "Line segments drawn this frame.",
|
|
597
|
-
low: "Frame rate during the slowest 1% of frames. Far below FPS means stutter the average hides.",
|
|
598
|
-
p99: "99% of frames finished within this many ms. Above the frame budget (16.7 ms at 60 FPS) means dropped frames.",
|
|
599
|
-
passes: "Times renderer.render() ran this frame. Shadows, post-processing and extra cameras each add passes.",
|
|
600
|
-
points: "Points drawn this frame.",
|
|
601
|
-
shaders: "Compiled shader programs. A jump mid-session usually means a stutter while a new material compiled.",
|
|
602
|
-
textures: "Textures held in GPU memory. Should level off; a steady climb is a leak, so dispose() what you remove.",
|
|
603
|
-
triangles: "Triangles drawn this frame. Mostly GPU cost: use lower-poly models or level of detail to cut it."
|
|
604
|
-
};
|
|
605
|
-
//#endregion
|
|
606
|
-
//#region src/ui/report.ts
|
|
540
|
+
//#region src/ui/styles.ts
|
|
607
541
|
/**
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
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`.
|
|
611
550
|
*/
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const calls = `${formatCount(entry.calls)} ${entry.calls === 1 ? "call" : "calls"}`;
|
|
628
|
-
return `${entry.label}${copies} (${calls}, ${formatCount(entry.triangles)} tris)`;
|
|
629
|
-
}).join(" · ")}`;
|
|
630
|
-
const renderReport = ({ cost, environment, page, sample, stats }) => {
|
|
631
|
-
const { render, resources } = sample;
|
|
632
|
-
const heading = [
|
|
633
|
-
`three-meter v${version}`,
|
|
634
|
-
environment.three ? `three r${environment.three}` : "",
|
|
635
|
-
backendLabel(environment),
|
|
636
|
-
environment.gpu ?? ""
|
|
637
|
-
].filter(Boolean);
|
|
638
|
-
const frames = stats.frames === 0 ? `FPS ${Math.round(sample.fps)}` : [
|
|
639
|
-
`FPS ${Math.round(sample.fps)}`,
|
|
640
|
-
`1% low ${Math.round(stats.lowFps)}`,
|
|
641
|
-
`p99 ${ms(stats.p99Ms)}`,
|
|
642
|
-
`hitches ${formatCount(stats.hitches)} / ${formatCount(stats.frames)} frames`
|
|
643
|
-
].join(" · ");
|
|
644
|
-
const lines = [
|
|
645
|
-
heading.join(" · "),
|
|
646
|
-
frames,
|
|
647
|
-
`CPU ${ms(sample.cpu)} · GPU ${sample.gpu.available ? ms(sample.gpu.ms) : "unavailable"}`,
|
|
648
|
-
[
|
|
649
|
-
`Calls ${formatCount(render.calls)}`,
|
|
650
|
-
`passes ${formatCount(render.passes)}`,
|
|
651
|
-
`triangles ${formatCount(render.triangles)}`,
|
|
652
|
-
`lines ${formatCount(render.lines)}`,
|
|
653
|
-
`points ${formatCount(render.points)}`
|
|
654
|
-
].join(" · "),
|
|
655
|
-
[
|
|
656
|
-
`Geometries ${formatCount(resources.geometries)}`,
|
|
657
|
-
`textures ${formatCount(resources.textures)}`,
|
|
658
|
-
`shaders ${formatCount(resources.programs)}`
|
|
659
|
-
].join(" · ")
|
|
660
|
-
];
|
|
661
|
-
if (cost && cost.meshes.length > 0) lines.push(topMeshes(cost));
|
|
662
|
-
if (page) lines.push(`Viewport ${page.width}×${page.height} @${page.dpr}x`, page.userAgent);
|
|
663
|
-
return [
|
|
664
|
-
"```text",
|
|
665
|
-
...lines,
|
|
666
|
-
"```"
|
|
667
|
-
].join("\n");
|
|
668
|
-
};
|
|
669
|
-
const readPage = () => typeof window === "undefined" ? null : {
|
|
670
|
-
dpr: window.devicePixelRatio || 1,
|
|
671
|
-
height: window.innerHeight,
|
|
672
|
-
userAgent: navigator.userAgent,
|
|
673
|
-
width: window.innerWidth
|
|
674
|
-
};
|
|
675
|
-
/** The report for `monitor` right now, as Markdown ready to paste into an issue. */
|
|
676
|
-
const formatReport = (monitor) => renderReport({
|
|
677
|
-
cost: monitor.getSceneCost(),
|
|
678
|
-
environment: monitor.getEnvironment(),
|
|
679
|
-
page: readPage(),
|
|
680
|
-
sample: monitor.getSample(),
|
|
681
|
-
stats: monitor.getFrameStats()
|
|
682
|
-
});
|
|
683
|
-
//#endregion
|
|
684
|
-
//#region src/ui/sparkline.ts
|
|
685
|
-
function drawSparkline(ctx, width, height, values, style, guide) {
|
|
686
|
-
ctx.clearRect(0, 0, width, height);
|
|
687
|
-
if (values.length < 2) return;
|
|
688
|
-
let max = 0;
|
|
689
|
-
for (const value of values) if (value > max) max = value;
|
|
690
|
-
const range = max * 1.1 || 1;
|
|
691
|
-
const stepX = width / (values.length - 1);
|
|
692
|
-
const toY = (value) => height - value / range * height;
|
|
693
|
-
ctx.beginPath();
|
|
694
|
-
ctx.moveTo(0, toY(values[0]));
|
|
695
|
-
for (let index = 1; index < values.length; index += 1) ctx.lineTo(index * stepX, toY(values[index]));
|
|
696
|
-
ctx.lineWidth = 1;
|
|
697
|
-
ctx.strokeStyle = style.stroke;
|
|
698
|
-
ctx.stroke();
|
|
699
|
-
ctx.lineTo(width, height);
|
|
700
|
-
ctx.lineTo(0, height);
|
|
701
|
-
ctx.closePath();
|
|
702
|
-
ctx.fillStyle = style.fill;
|
|
703
|
-
ctx.fill();
|
|
704
|
-
if (guide && guide.value > 0 && guide.value <= range) {
|
|
705
|
-
const y = Math.round(toY(guide.value)) + .5;
|
|
706
|
-
ctx.save();
|
|
707
|
-
ctx.setLineDash([3, 3]);
|
|
708
|
-
ctx.strokeStyle = guide.color;
|
|
709
|
-
ctx.beginPath();
|
|
710
|
-
ctx.moveTo(0, y);
|
|
711
|
-
ctx.lineTo(width, y);
|
|
712
|
-
ctx.stroke();
|
|
713
|
-
ctx.restore();
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
//#endregion
|
|
717
|
-
//#region src/ui/performance-view.ts
|
|
718
|
-
const TIMINGS = [
|
|
719
|
-
{
|
|
720
|
-
format: (value) => Math.round(value).toString(),
|
|
721
|
-
icon: "gauge",
|
|
722
|
-
label: "FPS",
|
|
723
|
-
metric: "fps",
|
|
724
|
-
style: {
|
|
725
|
-
dark: {
|
|
726
|
-
fill: "rgba(74, 222, 128, 0.15)",
|
|
727
|
-
stroke: "#4ade80"
|
|
728
|
-
},
|
|
729
|
-
light: {
|
|
730
|
-
fill: "rgba(22, 163, 74, 0.12)",
|
|
731
|
-
stroke: "#16a34a"
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
unit: ""
|
|
735
|
-
},
|
|
736
|
-
{
|
|
737
|
-
format: (value) => value.toFixed(1),
|
|
738
|
-
icon: "cpu",
|
|
739
|
-
label: "CPU",
|
|
740
|
-
metric: "cpu",
|
|
741
|
-
style: {
|
|
742
|
-
dark: {
|
|
743
|
-
fill: "rgba(96, 165, 250, 0.15)",
|
|
744
|
-
stroke: "#60a5fa"
|
|
745
|
-
},
|
|
746
|
-
light: {
|
|
747
|
-
fill: "rgba(37, 99, 235, 0.12)",
|
|
748
|
-
stroke: "#2563eb"
|
|
749
|
-
}
|
|
750
|
-
},
|
|
751
|
-
unit: "ms"
|
|
752
|
-
},
|
|
753
|
-
{
|
|
754
|
-
format: (value) => value.toFixed(1),
|
|
755
|
-
icon: "zap",
|
|
756
|
-
label: "GPU",
|
|
757
|
-
metric: "gpu",
|
|
758
|
-
style: {
|
|
759
|
-
dark: {
|
|
760
|
-
fill: "rgba(244, 114, 182, 0.15)",
|
|
761
|
-
stroke: "#f472b6"
|
|
762
|
-
},
|
|
763
|
-
light: {
|
|
764
|
-
fill: "rgba(219, 39, 119, 0.12)",
|
|
765
|
-
stroke: "#db2777"
|
|
766
|
-
}
|
|
767
|
-
},
|
|
768
|
-
unit: "ms"
|
|
769
|
-
}
|
|
770
|
-
];
|
|
771
|
-
const THEME_OPTIONS = [
|
|
772
|
-
{
|
|
773
|
-
icon: "sun",
|
|
774
|
-
label: "Light theme",
|
|
775
|
-
mode: "light"
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
icon: "monitor",
|
|
779
|
-
label: "Match the system theme",
|
|
780
|
-
mode: "system"
|
|
781
|
-
},
|
|
782
|
-
{
|
|
783
|
-
icon: "moon",
|
|
784
|
-
label: "Dark theme",
|
|
785
|
-
mode: "dark"
|
|
786
|
-
}
|
|
787
|
-
];
|
|
788
|
-
/** The monorepo tags each package's releases `@zkmake/three-meter@0.9.2`. */
|
|
789
|
-
const RELEASE_URL = `https://github.com/zkmake/three-kit/releases/tag/${encodeURIComponent(`${name}@${version}`)}`;
|
|
790
|
-
/** How long the report button says `copied` / `failed` before reading `copy` again. */
|
|
791
|
-
const COPY_FEEDBACK_MS = 1500;
|
|
792
|
-
/** Walking the scene graph isn't free; the top-costs list refreshes at 2 Hz while open. */
|
|
793
|
-
const COSTS_INTERVAL_MS = 500;
|
|
794
|
-
const COSTS_ROWS = 5;
|
|
795
|
-
const COST_GROUPS = [{
|
|
796
|
-
group: "meshes",
|
|
797
|
-
label: "meshes"
|
|
798
|
-
}, {
|
|
799
|
-
group: "materials",
|
|
800
|
-
label: "materials"
|
|
801
|
-
}];
|
|
802
|
-
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.";
|
|
803
|
-
/** `×2,000` for instances or copies; nothing for a single object. */
|
|
804
|
-
const copiesOf = (entry) => entry.instances > 1 ? `×${formatCount(entry.instances)}` : "";
|
|
805
|
-
const costTitle = (entry) => {
|
|
806
|
-
const objects = entry.objects.length;
|
|
807
|
-
const shape = entry.instances > objects ? `${formatCount(entry.instances)} instances in ${objects === 1 ? "one object" : `${objects} objects`}` : `${formatCount(objects)} ${objects === 1 ? "object" : "separate objects"}`;
|
|
808
|
-
return `${entry.label}: ${shape}, ${formatCount(entry.calls)} draw ${entry.calls === 1 ? "call" : "calls"}, ${formatCount(entry.triangles)} triangles. Click to log to the console.`;
|
|
809
|
-
};
|
|
810
|
-
const readTiming = (sample, metric) => {
|
|
811
|
-
switch (metric) {
|
|
812
|
-
case "fps": return sample.fps;
|
|
813
|
-
case "cpu": return sample.cpu;
|
|
814
|
-
case "gpu": return sample.gpu.ms;
|
|
815
|
-
}
|
|
816
|
-
};
|
|
817
|
-
const isTimingAvailable = (sample, metric) => metric !== "gpu" || sample.gpu.available;
|
|
818
|
-
const whole = (value) => Math.round(value).toString();
|
|
819
|
-
const tenths = (value) => value.toFixed(1);
|
|
820
|
-
const NUMBERS = [
|
|
821
|
-
{
|
|
822
|
-
format: whole,
|
|
823
|
-
icon: "gauge",
|
|
824
|
-
key: "fps",
|
|
825
|
-
label: "FPS",
|
|
826
|
-
value: (sample) => sample.fps || null
|
|
827
|
-
},
|
|
828
|
-
{
|
|
829
|
-
format: formatCount,
|
|
830
|
-
icon: "layers",
|
|
831
|
-
key: "calls",
|
|
832
|
-
label: "Calls",
|
|
833
|
-
value: (sample) => sample.render.calls
|
|
834
|
-
},
|
|
835
|
-
{
|
|
836
|
-
format: tenths,
|
|
837
|
-
icon: "cpu",
|
|
838
|
-
key: "cpu",
|
|
839
|
-
label: "CPU",
|
|
840
|
-
unit: "ms",
|
|
841
|
-
value: (sample) => sample.cpu
|
|
842
|
-
},
|
|
843
|
-
{
|
|
844
|
-
format: tenths,
|
|
845
|
-
icon: "zap",
|
|
846
|
-
key: "gpu",
|
|
847
|
-
label: "GPU",
|
|
848
|
-
unit: "ms",
|
|
849
|
-
value: (sample) => sample.gpu.available ? sample.gpu.ms : null
|
|
850
|
-
},
|
|
851
|
-
{
|
|
852
|
-
format: whole,
|
|
853
|
-
icon: "trendingDown",
|
|
854
|
-
key: "low",
|
|
855
|
-
label: "1% low",
|
|
856
|
-
value: (_sample, stats) => stats.frames ? stats.lowFps : null
|
|
857
|
-
},
|
|
858
|
-
{
|
|
859
|
-
format: tenths,
|
|
860
|
-
icon: "timer",
|
|
861
|
-
key: "p99",
|
|
862
|
-
label: "Frame p99",
|
|
863
|
-
unit: "ms",
|
|
864
|
-
value: (_sample, stats) => stats.frames ? stats.p99Ms : null
|
|
865
|
-
},
|
|
866
|
-
{
|
|
867
|
-
format: formatCount,
|
|
868
|
-
icon: "activity",
|
|
869
|
-
key: "hitches",
|
|
870
|
-
label: "Hitches",
|
|
871
|
-
value: (_sample, stats) => stats.frames ? stats.hitches : null
|
|
872
|
-
},
|
|
873
|
-
{
|
|
874
|
-
format: formatCount,
|
|
875
|
-
icon: "triangle",
|
|
876
|
-
key: "triangles",
|
|
877
|
-
label: "Triangles",
|
|
878
|
-
value: (sample) => sample.render.triangles
|
|
879
|
-
},
|
|
880
|
-
{
|
|
881
|
-
format: formatCount,
|
|
882
|
-
icon: "spline",
|
|
883
|
-
key: "lines",
|
|
884
|
-
label: "Lines",
|
|
885
|
-
value: (sample) => sample.render.lines
|
|
886
|
-
},
|
|
887
|
-
{
|
|
888
|
-
format: formatCount,
|
|
889
|
-
icon: "circleDot",
|
|
890
|
-
key: "points",
|
|
891
|
-
label: "Points",
|
|
892
|
-
value: (sample) => sample.render.points
|
|
893
|
-
},
|
|
894
|
-
{
|
|
895
|
-
format: formatCount,
|
|
896
|
-
icon: "repeat",
|
|
897
|
-
key: "passes",
|
|
898
|
-
label: "Render passes",
|
|
899
|
-
value: (sample) => sample.render.passes
|
|
900
|
-
},
|
|
901
|
-
{
|
|
902
|
-
format: formatCount,
|
|
903
|
-
icon: "box",
|
|
904
|
-
key: "geometries",
|
|
905
|
-
label: "Geometries",
|
|
906
|
-
value: (sample) => sample.resources.geometries
|
|
907
|
-
},
|
|
908
|
-
{
|
|
909
|
-
format: formatCount,
|
|
910
|
-
icon: "image",
|
|
911
|
-
key: "textures",
|
|
912
|
-
label: "Textures",
|
|
913
|
-
value: (sample) => sample.resources.textures
|
|
914
|
-
},
|
|
915
|
-
{
|
|
916
|
-
format: formatCount,
|
|
917
|
-
icon: "palette",
|
|
918
|
-
key: "shaders",
|
|
919
|
-
label: "Shaders",
|
|
920
|
-
value: (sample) => sample.resources.programs
|
|
921
|
-
}
|
|
922
|
-
];
|
|
923
|
-
const NUMBER_BY_KEY = new Map(NUMBERS.map((config) => [config.key, config]));
|
|
924
|
-
/** Canvas can't read CSS custom properties; the budget line's amber per theme. */
|
|
925
|
-
const GUIDE_COLOR = {
|
|
926
|
-
dark: "rgba(245, 158, 11, 0.75)",
|
|
927
|
-
light: "rgba(180, 83, 9, 0.7)"
|
|
928
|
-
};
|
|
929
|
-
/** `Budget: at most 16.7 ms` / `at least 57`, or `""` without one. */
|
|
930
|
-
const budgetTitle = (budgets, key) => {
|
|
931
|
-
const limit = budgets.limits[key];
|
|
932
|
-
const config = NUMBER_BY_KEY.get(key);
|
|
933
|
-
if (limit === void 0 || !config) return "";
|
|
934
|
-
return `Budget: ${isFloor(key) ? "at least" : "at most"} ${config.format(limit)}${config.unit ? ` ${config.unit}` : ""}`;
|
|
935
|
-
};
|
|
936
|
-
/** The sample value a timing graph shows, `null` when not known. */
|
|
937
|
-
const timingValue = (sample, metric) => {
|
|
938
|
-
if (!isTimingAvailable(sample, metric)) return null;
|
|
939
|
-
const value = readTiming(sample, metric);
|
|
940
|
-
return metric === "fps" && value === 0 ? null : value;
|
|
941
|
-
};
|
|
942
|
-
/**
|
|
943
|
-
* Host-agnostic widget that paints {@link Sample}s. Mount {@link element}
|
|
944
|
-
* anywhere; the view drives its own throttled repaint. Compact is the floating
|
|
945
|
-
* HUD; full is the checkbox list that chooses what compact shows. Styles are
|
|
946
|
-
* not injected here. Call `injectStyles()` or use `mountPerfHud`.
|
|
947
|
-
*/
|
|
948
|
-
var PerformanceView = class {
|
|
949
|
-
element;
|
|
950
|
-
settings;
|
|
951
|
-
theme;
|
|
952
|
-
ownsTheme;
|
|
953
|
-
unsubscribeTheme;
|
|
954
|
-
monitor;
|
|
955
|
-
minIntervalMs;
|
|
956
|
-
mode;
|
|
957
|
-
graphValueEls = /* @__PURE__ */ new Map();
|
|
958
|
-
graphCanvases = /* @__PURE__ */ new Map();
|
|
959
|
-
graphCheckboxes = /* @__PURE__ */ new Map();
|
|
960
|
-
statValueEls = /* @__PURE__ */ new Map();
|
|
961
|
-
statCheckboxes = /* @__PURE__ */ new Map();
|
|
962
|
-
infoCheckbox;
|
|
963
|
-
themeRadios = /* @__PURE__ */ new Map();
|
|
964
|
-
footerThreeEl;
|
|
965
|
-
footerHardwareEl;
|
|
966
|
-
footerBackendEl;
|
|
967
|
-
footerGpuEl;
|
|
968
|
-
lastEnvironmentKey = "";
|
|
969
|
-
hudGraphsEl;
|
|
970
|
-
hudGridEl;
|
|
971
|
-
hudGraphs = /* @__PURE__ */ new Map();
|
|
972
|
-
hudNumberEls = /* @__PURE__ */ new Map();
|
|
973
|
-
budgets;
|
|
974
|
-
rafId = null;
|
|
975
|
-
lastPaintAt = 0;
|
|
976
|
-
resizeObserver;
|
|
977
|
-
unsubscribe;
|
|
978
|
-
costsOpen = false;
|
|
979
|
-
costGroup = "meshes";
|
|
980
|
-
costSort = "calls";
|
|
981
|
-
lastCostsAt = -Infinity;
|
|
982
|
-
costsBodyEl;
|
|
983
|
-
costsListEl;
|
|
984
|
-
costGroupRadios = /* @__PURE__ */ new Map();
|
|
985
|
-
costSortButtons = /* @__PURE__ */ new Map();
|
|
986
|
-
constructor(options) {
|
|
987
|
-
this.monitor = options.monitor;
|
|
988
|
-
this.settings = options.settings ?? new HudSettings();
|
|
989
|
-
this.ownsTheme = !options.theme;
|
|
990
|
-
this.theme = options.theme ?? new HudTheme();
|
|
991
|
-
this.theme.setOverride(this.settings.theme);
|
|
992
|
-
this.mode = options.mode ?? "full";
|
|
993
|
-
this.minIntervalMs = 1e3 / (options.refreshHz ?? 10);
|
|
994
|
-
this.budgets = resolveBudgets(options.budgets);
|
|
995
|
-
this.element = document.createElement("div");
|
|
996
|
-
this.element.className = "perf-monitor";
|
|
997
|
-
this.applyModeClass();
|
|
998
|
-
this.resizeObserver = new ResizeObserver(() => this.resizeCanvases());
|
|
999
|
-
this.build();
|
|
1000
|
-
this.applyInfoClass();
|
|
1001
|
-
this.rebuildHud();
|
|
1002
|
-
this.onThemeChanged();
|
|
1003
|
-
this.unsubscribe = this.settings.subscribe(() => this.onSelectionChanged());
|
|
1004
|
-
this.unsubscribeTheme = this.theme.subscribe(() => this.onThemeChanged());
|
|
1005
|
-
}
|
|
1006
|
-
start() {
|
|
1007
|
-
if (this.rafId === null) this.rafId = requestAnimationFrame(this.frame);
|
|
1008
|
-
}
|
|
1009
|
-
stop() {
|
|
1010
|
-
if (this.rafId !== null) {
|
|
1011
|
-
cancelAnimationFrame(this.rafId);
|
|
1012
|
-
this.rafId = null;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
setMode(mode) {
|
|
1016
|
-
if (mode === this.mode) return;
|
|
1017
|
-
this.mode = mode;
|
|
1018
|
-
this.applyModeClass();
|
|
1019
|
-
this.resizeCanvases();
|
|
1020
|
-
}
|
|
1021
|
-
getMode() {
|
|
1022
|
-
return this.mode;
|
|
1023
|
-
}
|
|
1024
|
-
/** Replace the budgets; `false` turns them all off. Applies on the next paint. */
|
|
1025
|
-
setBudgets(budgets) {
|
|
1026
|
-
this.budgets = resolveBudgets(budgets);
|
|
1027
|
-
this.applyBudgetTitles();
|
|
1028
|
-
}
|
|
1029
|
-
dispose() {
|
|
1030
|
-
this.stop();
|
|
1031
|
-
this.unsubscribe();
|
|
1032
|
-
this.unsubscribeTheme();
|
|
1033
|
-
if (this.ownsTheme) this.theme.dispose();
|
|
1034
|
-
this.resizeObserver.disconnect();
|
|
1035
|
-
this.element.remove();
|
|
1036
|
-
}
|
|
1037
|
-
frame = (now) => {
|
|
1038
|
-
this.rafId = requestAnimationFrame(this.frame);
|
|
1039
|
-
if (now - this.lastPaintAt < this.minIntervalMs) return;
|
|
1040
|
-
this.lastPaintAt = now;
|
|
1041
|
-
this.render();
|
|
1042
|
-
};
|
|
1043
|
-
render() {
|
|
1044
|
-
const sample = this.monitor.getSample();
|
|
1045
|
-
if (this.mode === "compact") {
|
|
1046
|
-
this.renderHud(sample);
|
|
1047
|
-
if (this.settings.info) this.renderFooter();
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
for (const config of TIMINGS) this.paintGraph(config, sample, this.graphValueEls.get(config.metric), this.graphCanvases.get(config.metric), "unavailable");
|
|
1051
|
-
const stats = this.monitor.getFrameStats();
|
|
1052
|
-
for (const config of NUMBERS) this.paintNumber(config, this.statValueEls.get(config.key), sample, stats);
|
|
1053
|
-
if (this.costsOpen && performance.now() - this.lastCostsAt >= COSTS_INTERVAL_MS) this.renderCosts();
|
|
1054
|
-
this.renderFooter();
|
|
1055
|
-
}
|
|
1056
|
-
renderCosts() {
|
|
1057
|
-
this.lastCostsAt = performance.now();
|
|
1058
|
-
const cost = this.monitor.getSceneCost();
|
|
1059
|
-
if (!cost) {
|
|
1060
|
-
this.costsListEl.replaceChildren(this.costsNote("No scene rendered yet."));
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
const entries = [...this.costGroup === "meshes" ? cost.meshes : cost.materials];
|
|
1064
|
-
if (this.costSort === "triangles") entries.sort((a, b) => b.triangles - a.triangles || b.calls - a.calls);
|
|
1065
|
-
if (entries.length === 0) {
|
|
1066
|
-
this.costsListEl.replaceChildren(this.costsNote("Nothing drawn."));
|
|
1067
|
-
return;
|
|
1068
|
-
}
|
|
1069
|
-
const rows = entries.slice(0, COSTS_ROWS).map((entry) => {
|
|
1070
|
-
const row = document.createElement("button");
|
|
1071
|
-
row.type = "button";
|
|
1072
|
-
row.className = "perf-monitor__cost";
|
|
1073
|
-
row.title = costTitle(entry);
|
|
1074
|
-
const name = document.createElement("span");
|
|
1075
|
-
name.className = "perf-monitor__cost-name";
|
|
1076
|
-
name.textContent = entry.label;
|
|
1077
|
-
const copies = copiesOf(entry);
|
|
1078
|
-
if (copies) {
|
|
1079
|
-
const suffix = document.createElement("span");
|
|
1080
|
-
suffix.className = "perf-monitor__cost-copies";
|
|
1081
|
-
suffix.textContent = ` ${copies}`;
|
|
1082
|
-
name.append(suffix);
|
|
1083
|
-
}
|
|
1084
|
-
const calls = document.createElement("span");
|
|
1085
|
-
calls.className = "perf-monitor__cost-value";
|
|
1086
|
-
calls.textContent = formatCount(entry.calls);
|
|
1087
|
-
const triangles = document.createElement("span");
|
|
1088
|
-
triangles.className = "perf-monitor__cost-value";
|
|
1089
|
-
triangles.textContent = formatCount(entry.triangles);
|
|
1090
|
-
row.append(name, calls, triangles);
|
|
1091
|
-
row.addEventListener("click", () => {
|
|
1092
|
-
console.log(`three-meter: ${entry.label}`, entry.objects);
|
|
1093
|
-
});
|
|
1094
|
-
return row;
|
|
1095
|
-
});
|
|
1096
|
-
this.costsListEl.replaceChildren(...rows);
|
|
1097
|
-
}
|
|
1098
|
-
costsNote(text) {
|
|
1099
|
-
const note = document.createElement("div");
|
|
1100
|
-
note.className = "perf-monitor__costs-note";
|
|
1101
|
-
note.textContent = text;
|
|
1102
|
-
return note;
|
|
1103
|
-
}
|
|
1104
|
-
/** The monitor caches the environment once its backend settles; until then this re-reads. */
|
|
1105
|
-
renderFooter() {
|
|
1106
|
-
const environment = this.monitor.getEnvironment();
|
|
1107
|
-
const three = environment.three ? `three r${environment.three}` : "";
|
|
1108
|
-
const backend = backendLabel(environment);
|
|
1109
|
-
const gpu = environment.gpu ?? "";
|
|
1110
|
-
const key = `${three}\n${backend}\n${gpu}`;
|
|
1111
|
-
if (key === this.lastEnvironmentKey) return;
|
|
1112
|
-
this.lastEnvironmentKey = key;
|
|
1113
|
-
this.footerThreeEl.textContent = three;
|
|
1114
|
-
this.footerBackendEl.textContent = backend;
|
|
1115
|
-
this.footerBackendEl.hidden = backend === "";
|
|
1116
|
-
this.footerBackendEl.title = environment.fallback ? "WebGPURenderer couldn't get WebGPU and fell back to WebGL2" : "";
|
|
1117
|
-
this.footerBackendEl.classList.toggle("is-fallback", environment.fallback);
|
|
1118
|
-
this.footerGpuEl.textContent = gpu;
|
|
1119
|
-
this.footerGpuEl.title = gpu;
|
|
1120
|
-
this.footerHardwareEl.hidden = backend === "" && gpu === "";
|
|
1121
|
-
}
|
|
1122
|
-
renderHud(sample) {
|
|
1123
|
-
const stats = this.hudNumberEls.size > 0 ? this.monitor.getFrameStats() : null;
|
|
1124
|
-
for (const [key, element] of this.hudNumberEls) {
|
|
1125
|
-
const config = NUMBER_BY_KEY.get(key);
|
|
1126
|
-
if (config && stats) this.paintNumber(config, element, sample, stats);
|
|
1127
|
-
}
|
|
1128
|
-
for (const [metric, graph] of this.hudGraphs) {
|
|
1129
|
-
const config = TIMINGS.find((timing) => timing.metric === metric);
|
|
1130
|
-
this.paintGraph(config, sample, graph.valueEl, graph, "—");
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
paintNumber(config, element, sample, stats) {
|
|
1134
|
-
const value = config.value(sample, stats);
|
|
1135
|
-
element.textContent = value === null ? "—" : config.format(value);
|
|
1136
|
-
element.classList.toggle("is-over", isOverBudget(this.budgets, config.key, value));
|
|
1137
|
-
}
|
|
1138
|
-
/** Value and sparkline, with the budget line drawn once the series reaches it. */
|
|
1139
|
-
paintGraph(config, sample, valueEl, graph, missing) {
|
|
1140
|
-
const value = timingValue(sample, config.metric);
|
|
1141
|
-
const available = isTimingAvailable(sample, config.metric);
|
|
1142
|
-
const limit = this.budgets.limits[config.metric];
|
|
1143
|
-
valueEl.textContent = available ? this.formatTiming(config, readTiming(sample, config.metric)) : missing;
|
|
1144
|
-
valueEl.classList.toggle("is-over", isOverBudget(this.budgets, config.metric, value));
|
|
1145
|
-
drawSparkline(graph.ctx, graph.width, graph.height, available ? this.monitor.getHistory(config.metric) : [], config.style[this.theme.resolved], limit === void 0 ? void 0 : {
|
|
1146
|
-
color: GUIDE_COLOR[this.theme.resolved],
|
|
1147
|
-
value: limit
|
|
1148
|
-
});
|
|
1149
|
-
}
|
|
1150
|
-
/** Each value's tooltip names its budget, so amber explains itself. */
|
|
1151
|
-
applyBudgetTitles() {
|
|
1152
|
-
for (const [key, element] of this.statValueEls) element.title = budgetTitle(this.budgets, key);
|
|
1153
|
-
for (const [key, element] of this.hudNumberEls) element.title = budgetTitle(this.budgets, key);
|
|
1154
|
-
for (const [metric, element] of this.graphValueEls) element.title = budgetTitle(this.budgets, metric);
|
|
1155
|
-
for (const [metric, graph] of this.hudGraphs) graph.valueEl.title = budgetTitle(this.budgets, metric);
|
|
1156
|
-
}
|
|
1157
|
-
formatTiming(config, value) {
|
|
1158
|
-
return `${config.format(value)}${config.unit ? ` ${config.unit}` : ""}`;
|
|
1159
|
-
}
|
|
1160
|
-
build() {
|
|
1161
|
-
const hud = document.createElement("div");
|
|
1162
|
-
hud.className = "perf-monitor__hud";
|
|
1163
|
-
this.hudGraphsEl = document.createElement("div");
|
|
1164
|
-
this.hudGraphsEl.className = "perf-monitor__hud-graphs";
|
|
1165
|
-
this.hudGridEl = document.createElement("div");
|
|
1166
|
-
this.hudGridEl.className = "perf-monitor__hud-grid";
|
|
1167
|
-
hud.append(this.hudGraphsEl, this.hudGridEl);
|
|
1168
|
-
const options = document.createElement("div");
|
|
1169
|
-
options.className = "perf-monitor__section perf-monitor__options";
|
|
1170
|
-
const themeRow = document.createElement("div");
|
|
1171
|
-
themeRow.className = "perf-monitor__row perf-monitor__row--static";
|
|
1172
|
-
const themeLabel = document.createElement("span");
|
|
1173
|
-
themeLabel.className = "perf-monitor__label";
|
|
1174
|
-
themeLabel.textContent = "theme";
|
|
1175
|
-
const segment = document.createElement("div");
|
|
1176
|
-
segment.className = "perf-monitor__segment";
|
|
1177
|
-
segment.setAttribute("role", "radiogroup");
|
|
1178
|
-
segment.setAttribute("aria-label", "Panel theme");
|
|
1179
|
-
for (const option of THEME_OPTIONS) {
|
|
1180
|
-
const radio = document.createElement("button");
|
|
1181
|
-
radio.type = "button";
|
|
1182
|
-
radio.className = "perf-monitor__segment-option";
|
|
1183
|
-
radio.setAttribute("role", "radio");
|
|
1184
|
-
radio.setAttribute("aria-checked", "false");
|
|
1185
|
-
radio.setAttribute("aria-label", option.label);
|
|
1186
|
-
radio.title = option.label;
|
|
1187
|
-
radio.append(createIcon(option.icon, "perf-monitor__icon"));
|
|
1188
|
-
radio.addEventListener("click", () => this.settings.setTheme(option.mode));
|
|
1189
|
-
this.themeRadios.set(option.mode, radio);
|
|
1190
|
-
segment.append(radio);
|
|
1191
|
-
}
|
|
1192
|
-
themeRow.append(createIcon("contrast", "perf-monitor__icon"), themeLabel, segment);
|
|
1193
|
-
const explainRow = document.createElement("label");
|
|
1194
|
-
explainRow.className = "perf-monitor__row";
|
|
1195
|
-
const explainCheckbox = this.buildCheckbox(false, "Explain each metric", (on) => this.element.classList.toggle("perf-monitor--explain", on));
|
|
1196
|
-
const explainLabel = document.createElement("span");
|
|
1197
|
-
explainLabel.className = "perf-monitor__label";
|
|
1198
|
-
explainLabel.textContent = "explain metrics";
|
|
1199
|
-
explainRow.append(createIcon("help", "perf-monitor__icon"), explainLabel, explainCheckbox);
|
|
1200
|
-
options.append(themeRow, explainRow, this.buildReportRow());
|
|
1201
|
-
const graphs = document.createElement("div");
|
|
1202
|
-
graphs.className = "perf-monitor__graphs";
|
|
1203
|
-
for (const config of TIMINGS) {
|
|
1204
|
-
const graph = document.createElement("div");
|
|
1205
|
-
graph.className = "perf-monitor__graph";
|
|
1206
|
-
const canvas = document.createElement("canvas");
|
|
1207
|
-
canvas.className = "perf-monitor__canvas";
|
|
1208
|
-
const overlay = document.createElement("label");
|
|
1209
|
-
overlay.className = "perf-monitor__graph-overlay perf-monitor__graph-overlay--toggle";
|
|
1210
|
-
const head = document.createElement("span");
|
|
1211
|
-
head.className = "perf-monitor__graph-head";
|
|
1212
|
-
const checkbox = this.buildCheckbox(this.settings.isGraphEnabled(config.metric), `Show ${config.label} graph in HUD`, (on) => this.settings.setGraphEnabled(config.metric, on));
|
|
1213
|
-
this.graphCheckboxes.set(config.metric, checkbox);
|
|
1214
|
-
const label = document.createElement("span");
|
|
1215
|
-
label.className = "perf-monitor__graph-label";
|
|
1216
|
-
label.textContent = config.label;
|
|
1217
|
-
label.title = METRIC_HELP[config.metric] ?? "";
|
|
1218
|
-
const value = document.createElement("span");
|
|
1219
|
-
value.className = "perf-monitor__graph-value";
|
|
1220
|
-
value.textContent = "—";
|
|
1221
|
-
this.graphValueEls.set(config.metric, value);
|
|
1222
|
-
const tail = document.createElement("span");
|
|
1223
|
-
tail.className = "perf-monitor__graph-head";
|
|
1224
|
-
tail.append(value, checkbox);
|
|
1225
|
-
head.append(createIcon(config.icon, "perf-monitor__icon"), label);
|
|
1226
|
-
overlay.append(head, tail);
|
|
1227
|
-
graph.append(canvas, overlay);
|
|
1228
|
-
graphs.append(graph);
|
|
1229
|
-
const ctx = canvas.getContext("2d");
|
|
1230
|
-
if (ctx) {
|
|
1231
|
-
this.graphCanvases.set(config.metric, {
|
|
1232
|
-
canvas,
|
|
1233
|
-
ctx,
|
|
1234
|
-
height: 0,
|
|
1235
|
-
width: 0
|
|
1236
|
-
});
|
|
1237
|
-
this.resizeObserver.observe(graph);
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
const stats = document.createElement("div");
|
|
1241
|
-
stats.className = "perf-monitor__section";
|
|
1242
|
-
for (const config of NUMBERS) {
|
|
1243
|
-
const rowEl = document.createElement("label");
|
|
1244
|
-
rowEl.className = "perf-monitor__row";
|
|
1245
|
-
const checkbox = this.buildCheckbox(this.settings.isNumberEnabled(config.key), `Show ${config.label} in HUD`, (on) => this.settings.setNumberEnabled(config.key, on));
|
|
1246
|
-
this.statCheckboxes.set(config.key, checkbox);
|
|
1247
|
-
const help = METRIC_HELP[config.key] ?? "";
|
|
1248
|
-
const label = document.createElement("span");
|
|
1249
|
-
label.className = "perf-monitor__label";
|
|
1250
|
-
label.textContent = config.label;
|
|
1251
|
-
label.title = help;
|
|
1252
|
-
const value = document.createElement("span");
|
|
1253
|
-
value.className = "perf-monitor__value";
|
|
1254
|
-
value.textContent = "—";
|
|
1255
|
-
this.statValueEls.set(config.key, value);
|
|
1256
|
-
const hint = document.createElement("span");
|
|
1257
|
-
hint.className = "perf-monitor__hint";
|
|
1258
|
-
hint.textContent = help;
|
|
1259
|
-
rowEl.append(createIcon(config.icon, "perf-monitor__icon"), label, value, checkbox, hint);
|
|
1260
|
-
stats.append(rowEl);
|
|
1261
|
-
}
|
|
1262
|
-
const footer = document.createElement("div");
|
|
1263
|
-
footer.className = "perf-monitor__footer";
|
|
1264
|
-
this.infoCheckbox = this.buildCheckbox(this.settings.info, "Show version and environment in HUD", (on) => this.settings.setInfo(on));
|
|
1265
|
-
const footerBody = document.createElement("div");
|
|
1266
|
-
footerBody.className = "perf-monitor__footer-body";
|
|
1267
|
-
const softwareRow = document.createElement("div");
|
|
1268
|
-
softwareRow.className = "perf-monitor__footer-row";
|
|
1269
|
-
const release = document.createElement("a");
|
|
1270
|
-
release.className = "perf-monitor__link";
|
|
1271
|
-
release.href = RELEASE_URL;
|
|
1272
|
-
release.target = "_blank";
|
|
1273
|
-
release.rel = "noopener noreferrer";
|
|
1274
|
-
release.title = `Release notes for v${version}`;
|
|
1275
|
-
release.textContent = `three-meter v${version}`;
|
|
1276
|
-
this.footerThreeEl = document.createElement("span");
|
|
1277
|
-
softwareRow.append(release, this.footerThreeEl);
|
|
1278
|
-
this.footerHardwareEl = document.createElement("div");
|
|
1279
|
-
this.footerHardwareEl.className = "perf-monitor__footer-row";
|
|
1280
|
-
this.footerHardwareEl.hidden = true;
|
|
1281
|
-
this.footerBackendEl = document.createElement("span");
|
|
1282
|
-
this.footerBackendEl.className = "perf-monitor__badge";
|
|
1283
|
-
this.footerGpuEl = document.createElement("span");
|
|
1284
|
-
this.footerGpuEl.className = "perf-monitor__footer-gpu";
|
|
1285
|
-
this.footerHardwareEl.append(this.footerBackendEl, this.footerGpuEl);
|
|
1286
|
-
footerBody.append(softwareRow, this.footerHardwareEl);
|
|
1287
|
-
footer.append(createIcon("info", "perf-monitor__icon"), footerBody, this.infoCheckbox);
|
|
1288
|
-
this.element.append(hud, options, graphs, stats, this.buildCosts(), footer);
|
|
1289
|
-
}
|
|
1290
|
-
rebuildHud() {
|
|
1291
|
-
const { selection } = this.settings;
|
|
1292
|
-
for (const graph of this.hudGraphs.values()) this.resizeObserver.unobserve(graph.canvas.parentElement ?? graph.canvas);
|
|
1293
|
-
this.hudGraphs.clear();
|
|
1294
|
-
this.hudNumberEls.clear();
|
|
1295
|
-
this.hudGraphsEl.replaceChildren();
|
|
1296
|
-
this.hudGridEl.replaceChildren();
|
|
1297
|
-
for (const config of TIMINGS) {
|
|
1298
|
-
if (!selection.graphs.has(config.metric)) continue;
|
|
1299
|
-
const row = document.createElement("div");
|
|
1300
|
-
row.className = "perf-monitor__hud-graph";
|
|
1301
|
-
const canvas = document.createElement("canvas");
|
|
1302
|
-
canvas.className = "perf-monitor__canvas";
|
|
1303
|
-
const overlay = document.createElement("div");
|
|
1304
|
-
overlay.className = "perf-monitor__graph-overlay";
|
|
1305
|
-
const label = document.createElement("span");
|
|
1306
|
-
label.className = "perf-monitor__graph-label";
|
|
1307
|
-
label.textContent = config.label;
|
|
1308
|
-
label.title = METRIC_HELP[config.metric] ?? "";
|
|
1309
|
-
const head = document.createElement("span");
|
|
1310
|
-
head.className = "perf-monitor__graph-head";
|
|
1311
|
-
head.append(createIcon(config.icon, "perf-monitor__icon"), label);
|
|
1312
|
-
const value = document.createElement("span");
|
|
1313
|
-
value.className = "perf-monitor__graph-value";
|
|
1314
|
-
value.textContent = "—";
|
|
1315
|
-
overlay.append(head, value);
|
|
1316
|
-
row.append(canvas, overlay);
|
|
1317
|
-
this.hudGraphsEl.append(row);
|
|
1318
|
-
const ctx = canvas.getContext("2d");
|
|
1319
|
-
if (ctx) {
|
|
1320
|
-
this.hudGraphs.set(config.metric, {
|
|
1321
|
-
canvas,
|
|
1322
|
-
ctx,
|
|
1323
|
-
height: 0,
|
|
1324
|
-
valueEl: value,
|
|
1325
|
-
width: 0
|
|
1326
|
-
});
|
|
1327
|
-
this.resizeObserver.observe(row);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
for (const config of NUMBERS) {
|
|
1331
|
-
if (!selection.numbers.has(config.key)) continue;
|
|
1332
|
-
const item = document.createElement("div");
|
|
1333
|
-
item.className = "perf-monitor__hud-item";
|
|
1334
|
-
const label = document.createElement("span");
|
|
1335
|
-
label.className = "perf-monitor__hud-label";
|
|
1336
|
-
label.title = METRIC_HELP[config.key] ?? "";
|
|
1337
|
-
label.append(createIcon(config.icon, "perf-monitor__icon"), config.label);
|
|
1338
|
-
const value = document.createElement("span");
|
|
1339
|
-
value.className = "perf-monitor__hud-value";
|
|
1340
|
-
value.textContent = "—";
|
|
1341
|
-
item.append(label, value);
|
|
1342
|
-
this.hudGridEl.append(item);
|
|
1343
|
-
this.hudNumberEls.set(config.key, value);
|
|
1344
|
-
}
|
|
1345
|
-
this.applyBudgetTitles();
|
|
1346
|
-
this.resizeCanvases();
|
|
1347
|
-
}
|
|
1348
|
-
onSelectionChanged() {
|
|
1349
|
-
for (const [metric, checkbox] of this.graphCheckboxes) checkbox.checked = this.settings.isGraphEnabled(metric);
|
|
1350
|
-
for (const [key, checkbox] of this.statCheckboxes) checkbox.checked = this.settings.isNumberEnabled(key);
|
|
1351
|
-
this.infoCheckbox.checked = this.settings.info;
|
|
1352
|
-
this.applyInfoClass();
|
|
1353
|
-
this.theme.setOverride(this.settings.theme);
|
|
1354
|
-
this.rebuildHud();
|
|
1355
|
-
}
|
|
1356
|
-
/** Repaint the palette and mark the radio matching what applies (pick, else consumer mode). */
|
|
1357
|
-
onThemeChanged() {
|
|
1358
|
-
applyTheme(this.element, this.theme);
|
|
1359
|
-
for (const [mode, radio] of this.themeRadios) radio.setAttribute("aria-checked", String(mode === this.theme.effective));
|
|
1360
|
-
}
|
|
1361
|
-
/** "top costs": a toggle row, then meshes / materials and a sortable top five. */
|
|
1362
|
-
buildCosts() {
|
|
1363
|
-
const section = document.createElement("div");
|
|
1364
|
-
section.className = "perf-monitor__section perf-monitor__costs";
|
|
1365
|
-
const toggleRow = document.createElement("label");
|
|
1366
|
-
toggleRow.className = "perf-monitor__row";
|
|
1367
|
-
toggleRow.title = COSTS_HELP;
|
|
1368
|
-
const label = document.createElement("span");
|
|
1369
|
-
label.className = "perf-monitor__label";
|
|
1370
|
-
label.textContent = "top costs";
|
|
1371
|
-
const checkbox = this.buildCheckbox(false, "Show the scene's biggest draw costs", (on) => {
|
|
1372
|
-
this.costsOpen = on;
|
|
1373
|
-
this.costsBodyEl.hidden = !on;
|
|
1374
|
-
if (on) this.renderCosts();
|
|
1375
|
-
});
|
|
1376
|
-
toggleRow.append(createIcon("flame", "perf-monitor__icon"), label, checkbox);
|
|
1377
|
-
this.costsBodyEl = document.createElement("div");
|
|
1378
|
-
this.costsBodyEl.className = "perf-monitor__costs-body";
|
|
1379
|
-
this.costsBodyEl.hidden = true;
|
|
1380
|
-
const head = document.createElement("div");
|
|
1381
|
-
head.className = "perf-monitor__cost perf-monitor__cost--head";
|
|
1382
|
-
const segment = document.createElement("div");
|
|
1383
|
-
segment.className = "perf-monitor__segment";
|
|
1384
|
-
segment.setAttribute("role", "radiogroup");
|
|
1385
|
-
segment.setAttribute("aria-label", "Group costs by");
|
|
1386
|
-
for (const option of COST_GROUPS) {
|
|
1387
|
-
const radio = document.createElement("button");
|
|
1388
|
-
radio.type = "button";
|
|
1389
|
-
radio.className = "perf-monitor__segment-option perf-monitor__segment-option--text";
|
|
1390
|
-
radio.setAttribute("role", "radio");
|
|
1391
|
-
radio.setAttribute("aria-checked", String(option.group === this.costGroup));
|
|
1392
|
-
radio.textContent = option.label;
|
|
1393
|
-
radio.addEventListener("click", () => {
|
|
1394
|
-
this.costGroup = option.group;
|
|
1395
|
-
for (const [group, button] of this.costGroupRadios) button.setAttribute("aria-checked", String(group === option.group));
|
|
1396
|
-
this.renderCosts();
|
|
1397
|
-
});
|
|
1398
|
-
this.costGroupRadios.set(option.group, radio);
|
|
1399
|
-
segment.append(radio);
|
|
1400
|
-
}
|
|
1401
|
-
head.append(segment);
|
|
1402
|
-
for (const sort of ["calls", "triangles"]) {
|
|
1403
|
-
const button = document.createElement("button");
|
|
1404
|
-
button.type = "button";
|
|
1405
|
-
button.className = "perf-monitor__cost-sort";
|
|
1406
|
-
button.textContent = sort === "calls" ? "calls" : "tris";
|
|
1407
|
-
button.title = `Sort by ${sort === "calls" ? "draw calls" : "triangles"}`;
|
|
1408
|
-
button.setAttribute("aria-pressed", String(sort === this.costSort));
|
|
1409
|
-
button.addEventListener("click", () => {
|
|
1410
|
-
this.costSort = sort;
|
|
1411
|
-
for (const [key, other] of this.costSortButtons) other.setAttribute("aria-pressed", String(key === sort));
|
|
1412
|
-
this.renderCosts();
|
|
1413
|
-
});
|
|
1414
|
-
this.costSortButtons.set(sort, button);
|
|
1415
|
-
head.append(button);
|
|
1416
|
-
}
|
|
1417
|
-
this.costsListEl = document.createElement("div");
|
|
1418
|
-
this.costsListEl.className = "perf-monitor__costs-list";
|
|
1419
|
-
const hint = document.createElement("span");
|
|
1420
|
-
hint.className = "perf-monitor__hint";
|
|
1421
|
-
hint.textContent = COSTS_HELP;
|
|
1422
|
-
this.costsBodyEl.append(head, this.costsListEl, hint);
|
|
1423
|
-
section.append(toggleRow, this.costsBodyEl);
|
|
1424
|
-
return section;
|
|
1425
|
-
}
|
|
1426
|
-
buildReportRow() {
|
|
1427
|
-
const row = document.createElement("div");
|
|
1428
|
-
row.className = "perf-monitor__row perf-monitor__row--static";
|
|
1429
|
-
const label = document.createElement("span");
|
|
1430
|
-
label.className = "perf-monitor__label";
|
|
1431
|
-
label.textContent = "report";
|
|
1432
|
-
const button = document.createElement("button");
|
|
1433
|
-
button.type = "button";
|
|
1434
|
-
button.className = "perf-monitor__button";
|
|
1435
|
-
button.title = "Copy versions, GPU and current metrics as Markdown for a bug report";
|
|
1436
|
-
button.textContent = "copy";
|
|
1437
|
-
let resetTimer = 0;
|
|
1438
|
-
const flash = (text) => {
|
|
1439
|
-
button.textContent = text;
|
|
1440
|
-
window.clearTimeout(resetTimer);
|
|
1441
|
-
resetTimer = window.setTimeout(() => {
|
|
1442
|
-
button.textContent = "copy";
|
|
1443
|
-
}, COPY_FEEDBACK_MS);
|
|
1444
|
-
};
|
|
1445
|
-
button.addEventListener("click", () => {
|
|
1446
|
-
copyText(formatReport(this.monitor)).then((copied) => flash(copied ? "copied" : "failed"));
|
|
1447
|
-
});
|
|
1448
|
-
row.append(createIcon("clipboard", "perf-monitor__icon"), label, button);
|
|
1449
|
-
return row;
|
|
1450
|
-
}
|
|
1451
|
-
buildCheckbox(checked, title, onChange) {
|
|
1452
|
-
const checkbox = document.createElement("input");
|
|
1453
|
-
checkbox.type = "checkbox";
|
|
1454
|
-
checkbox.className = "perf-monitor__checkbox";
|
|
1455
|
-
checkbox.checked = checked;
|
|
1456
|
-
checkbox.title = title;
|
|
1457
|
-
checkbox.setAttribute("aria-label", title);
|
|
1458
|
-
checkbox.addEventListener("change", () => onChange(checkbox.checked));
|
|
1459
|
-
return checkbox;
|
|
1460
|
-
}
|
|
1461
|
-
applyInfoClass() {
|
|
1462
|
-
this.element.classList.toggle("perf-monitor--info", this.settings.info);
|
|
1463
|
-
}
|
|
1464
|
-
applyModeClass() {
|
|
1465
|
-
this.element.classList.toggle("perf-monitor--compact", this.mode === "compact");
|
|
1466
|
-
this.element.classList.toggle("perf-monitor--full", this.mode === "full");
|
|
1467
|
-
}
|
|
1468
|
-
resizeCanvases() {
|
|
1469
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1470
|
-
const sizeAll = (graphs) => {
|
|
1471
|
-
for (const graph of graphs) {
|
|
1472
|
-
const { canvas, ctx } = graph;
|
|
1473
|
-
const width = canvas.clientWidth;
|
|
1474
|
-
const height = canvas.clientHeight;
|
|
1475
|
-
if (width === 0 || height === 0) continue;
|
|
1476
|
-
canvas.width = Math.round(width * dpr);
|
|
1477
|
-
canvas.height = Math.round(height * dpr);
|
|
1478
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1479
|
-
graph.width = width;
|
|
1480
|
-
graph.height = height;
|
|
1481
|
-
}
|
|
1482
|
-
};
|
|
1483
|
-
sizeAll(this.graphCanvases.values());
|
|
1484
|
-
sizeAll(this.hudGraphs.values());
|
|
1485
|
-
}
|
|
1486
|
-
};
|
|
1487
|
-
//#endregion
|
|
1488
|
-
//#region src/ui/styles.ts
|
|
1489
|
-
/**
|
|
1490
|
-
* The HUD stylesheet as a string, so the package works without a bundler and
|
|
1491
|
-
* injects itself once per document. `.perf-monitor` is the metrics card,
|
|
1492
|
-
* `.perf-hud` the floating host around it. Both namespaced so a host page can
|
|
1493
|
-
* override safely; the `--perf-*` custom properties are the theming surface.
|
|
1494
|
-
*
|
|
1495
|
-
* Both elements carry `data-theme="dark" | "light"` (see `HudTheme`) and each
|
|
1496
|
-
* resolves its own tokens from it, so the card also themes when mounted on
|
|
1497
|
-
* its own, outside a `.perf-hud`.
|
|
1498
|
-
*/
|
|
1499
|
-
const STYLE_ATTRIBUTE = "data-three-meter";
|
|
1500
|
-
const PERF_HUD_STYLES = `
|
|
1501
|
-
.perf-hud,
|
|
1502
|
-
.perf-monitor {
|
|
1503
|
-
--perf-bg: rgba(22, 24, 29, 0.85);
|
|
1504
|
-
--perf-fg: #e6e8eb;
|
|
1505
|
-
--perf-fg-dim: #c5c8ce;
|
|
1506
|
-
--perf-muted: #8b909a;
|
|
1507
|
-
--perf-row: rgba(255, 255, 255, 0.04);
|
|
1508
|
-
--perf-border: rgba(255, 255, 255, 0.12);
|
|
1509
|
-
--perf-accent: #60a5fa;
|
|
1510
|
-
--perf-warn: #f59e0b;
|
|
1511
|
-
--perf-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
|
|
1512
|
-
|
|
1513
|
-
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;
|
|
1514
566
|
}
|
|
1515
567
|
|
|
1516
568
|
.perf-hud[data-theme="light"],
|
|
@@ -1732,11 +784,10 @@ const PERF_HUD_STYLES = `
|
|
|
1732
784
|
}
|
|
1733
785
|
|
|
1734
786
|
.perf-monitor__cost {
|
|
1735
|
-
/* Rows are buttons, which shrink to their content without a width. */
|
|
1736
787
|
box-sizing: border-box;
|
|
1737
788
|
width: 100%;
|
|
1738
789
|
display: grid;
|
|
1739
|
-
grid-template-columns: minmax(0, 1fr) 5ch 6ch;
|
|
790
|
+
grid-template-columns: minmax(0, 1fr) 5ch 6ch 14px;
|
|
1740
791
|
gap: 6px;
|
|
1741
792
|
align-items: baseline;
|
|
1742
793
|
padding: 2px 6px;
|
|
@@ -1746,13 +797,58 @@ const PERF_HUD_STYLES = `
|
|
|
1746
797
|
color: inherit;
|
|
1747
798
|
font: inherit;
|
|
1748
799
|
text-align: left;
|
|
1749
|
-
cursor:
|
|
800
|
+
cursor: default;
|
|
1750
801
|
}
|
|
1751
802
|
|
|
1752
803
|
.perf-monitor__cost:not(.perf-monitor__cost--head):hover {
|
|
1753
804
|
background: var(--perf-row);
|
|
1754
805
|
}
|
|
1755
806
|
|
|
807
|
+
.perf-monitor__cost.is-hidden .perf-monitor__cost-name {
|
|
808
|
+
opacity: 0.6;
|
|
809
|
+
text-decoration: line-through;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/* What hiding saved, across the calls and tris columns. */
|
|
813
|
+
.perf-monitor__cost-saved {
|
|
814
|
+
grid-column: 2 / span 2;
|
|
815
|
+
color: var(--perf-accent);
|
|
816
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
817
|
+
text-align: right;
|
|
818
|
+
white-space: nowrap;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.perf-monitor__cost-eye {
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: center;
|
|
824
|
+
justify-content: center;
|
|
825
|
+
width: 14px;
|
|
826
|
+
height: 14px;
|
|
827
|
+
padding: 0;
|
|
828
|
+
border: 0;
|
|
829
|
+
border-radius: 3px;
|
|
830
|
+
background: transparent;
|
|
831
|
+
color: var(--perf-muted);
|
|
832
|
+
cursor: pointer;
|
|
833
|
+
align-self: center;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.perf-monitor__cost-eye .perf-monitor__icon {
|
|
837
|
+
width: 12px;
|
|
838
|
+
height: 12px;
|
|
839
|
+
color: inherit;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.perf-monitor__cost-eye:hover,
|
|
843
|
+
.perf-monitor__cost-eye[aria-pressed="true"] {
|
|
844
|
+
color: var(--perf-fg);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.perf-monitor__cost-eye:focus-visible {
|
|
848
|
+
outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
|
|
849
|
+
outline-offset: 1px;
|
|
850
|
+
}
|
|
851
|
+
|
|
1756
852
|
.perf-monitor__cost:focus-visible {
|
|
1757
853
|
outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
|
|
1758
854
|
outline-offset: -1px;
|
|
@@ -1763,13 +859,30 @@ const PERF_HUD_STYLES = `
|
|
|
1763
859
|
cursor: default;
|
|
1764
860
|
}
|
|
1765
861
|
|
|
862
|
+
/* The name is a button: a click logs the row's objects. */
|
|
1766
863
|
.perf-monitor__cost-name {
|
|
864
|
+
min-width: 0;
|
|
865
|
+
padding: 0;
|
|
866
|
+
border: 0;
|
|
867
|
+
background: transparent;
|
|
868
|
+
font: inherit;
|
|
869
|
+
text-align: left;
|
|
870
|
+
cursor: pointer;
|
|
1767
871
|
overflow: hidden;
|
|
1768
872
|
text-overflow: ellipsis;
|
|
1769
873
|
white-space: nowrap;
|
|
1770
874
|
color: var(--perf-fg-dim);
|
|
1771
875
|
}
|
|
1772
876
|
|
|
877
|
+
.perf-monitor__cost-name:hover {
|
|
878
|
+
color: var(--perf-fg);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.perf-monitor__cost-name:focus-visible {
|
|
882
|
+
outline: 1px solid color-mix(in srgb, var(--perf-accent) 60%, transparent);
|
|
883
|
+
outline-offset: 1px;
|
|
884
|
+
}
|
|
885
|
+
|
|
1773
886
|
.perf-monitor__cost-copies {
|
|
1774
887
|
color: var(--perf-muted);
|
|
1775
888
|
}
|
|
@@ -1989,21 +1102,24 @@ const PERF_HUD_STYLES = `
|
|
|
1989
1102
|
touch-action: none;
|
|
1990
1103
|
}
|
|
1991
1104
|
|
|
1992
|
-
.perf-hud--full > .perf-monitor {
|
|
1105
|
+
.perf-hud--full .perf-hud__card > .perf-monitor {
|
|
1993
1106
|
width: 16rem;
|
|
1994
1107
|
max-width: 100%;
|
|
1995
1108
|
}
|
|
1996
1109
|
|
|
1997
1110
|
/* Room for the hints, so most fit on two or three lines. Plus padding, this fills the host's 20rem cap. */
|
|
1998
|
-
.perf-hud--full > .perf-monitor--explain {
|
|
1111
|
+
.perf-hud--full .perf-hud__card > .perf-monitor--explain {
|
|
1999
1112
|
width: 19rem;
|
|
2000
1113
|
}
|
|
2001
1114
|
|
|
2002
|
-
|
|
1115
|
+
/* The card every dev panel sits in (\`mountDevPanel\`): the brand label, then the panel's content. */
|
|
1116
|
+
.perf-hud__card {
|
|
2003
1117
|
position: relative;
|
|
2004
1118
|
z-index: 1;
|
|
1119
|
+
display: flex;
|
|
1120
|
+
flex-direction: column;
|
|
2005
1121
|
max-height: 70vh;
|
|
2006
|
-
overflow:
|
|
1122
|
+
overflow: hidden;
|
|
2007
1123
|
background: var(--perf-bg);
|
|
2008
1124
|
border-radius: 6px;
|
|
2009
1125
|
box-shadow: var(--perf-shadow);
|
|
@@ -2012,10 +1128,59 @@ const PERF_HUD_STYLES = `
|
|
|
2012
1128
|
transition: opacity 0.25s ease;
|
|
2013
1129
|
}
|
|
2014
1130
|
|
|
2015
|
-
.perf-
|
|
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 {
|
|
2016
1137
|
opacity: 0.32;
|
|
2017
1138
|
}
|
|
2018
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;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
2019
1184
|
.perf-hud__hotspot {
|
|
2020
1185
|
position: absolute;
|
|
2021
1186
|
z-index: 0;
|
|
@@ -2101,50 +1266,1275 @@ const PERF_HUD_STYLES = `
|
|
|
2101
1266
|
background: var(--perf-accent);
|
|
2102
1267
|
color: var(--perf-bg);
|
|
2103
1268
|
}
|
|
2104
|
-
`;
|
|
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
|
+
};
|
|
2105
1913
|
/**
|
|
2106
|
-
*
|
|
2107
|
-
*
|
|
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`.
|
|
2108
1918
|
*/
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
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
|
+
}
|
|
2115
2531
|
};
|
|
2116
2532
|
//#endregion
|
|
2117
2533
|
//#region src/ui/mount-perf-hud.ts
|
|
2118
|
-
const TOUCH_LINGER_MS = 2500;
|
|
2119
|
-
const disc = (label, className, icon) => {
|
|
2120
|
-
const button = document.createElement("button");
|
|
2121
|
-
button.type = "button";
|
|
2122
|
-
button.className = `perf-hud__disc ${className}`.trim();
|
|
2123
|
-
button.setAttribute("aria-label", label);
|
|
2124
|
-
button.title = label;
|
|
2125
|
-
button.append(createIcon(icon, "perf-hud__icon"));
|
|
2126
|
-
return button;
|
|
2127
|
-
};
|
|
2128
2534
|
const mountPerfHud = (monitor, options = {}) => {
|
|
2129
2535
|
const storageKey = options.storageKey === void 0 ? DEFAULT_STORAGE_KEY : options.storageKey;
|
|
2130
2536
|
const settings = options.settings ?? new HudSettings({ storageKey });
|
|
2131
2537
|
const theme = new HudTheme(options.theme ?? "system");
|
|
2132
|
-
const parent = options.parent ?? document.body;
|
|
2133
|
-
if (options.injectStyles ?? true) injectStyles(parent.ownerDocument);
|
|
2134
|
-
const host = document.createElement("div");
|
|
2135
|
-
host.className = "perf-hud";
|
|
2136
|
-
host.dataset.edge = "left";
|
|
2137
|
-
host.setAttribute("aria-label", options.label ?? "Performance");
|
|
2138
|
-
const hotspot = document.createElement("div");
|
|
2139
|
-
hotspot.className = "perf-hud__hotspot";
|
|
2140
|
-
hotspot.setAttribute("aria-hidden", "true");
|
|
2141
|
-
const tools = document.createElement("div");
|
|
2142
|
-
tools.className = "perf-hud__tools";
|
|
2143
|
-
const grip = disc("Drag performance panel", "perf-hud__drag", "grip");
|
|
2144
|
-
const toggle = disc("Toggle full performance metrics", "", "sliders");
|
|
2145
|
-
const dim = disc("Dim the panel when the pointer leaves", "perf-hud__dim", "blend");
|
|
2146
|
-
dim.addEventListener("click", () => settings.setDim(!settings.dim));
|
|
2147
|
-
tools.append(grip, toggle, dim);
|
|
2148
2538
|
const view = new PerformanceView({
|
|
2149
2539
|
budgets: options.budgets,
|
|
2150
2540
|
mode: options.mode ?? "compact",
|
|
@@ -2153,74 +2543,39 @@ const mountPerfHud = (monitor, options = {}) => {
|
|
|
2153
2543
|
settings,
|
|
2154
2544
|
theme
|
|
2155
2545
|
});
|
|
2156
|
-
|
|
2157
|
-
|
|
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;
|
|
2158
2564
|
const applyMode = () => {
|
|
2159
2565
|
host.classList.toggle("perf-hud--full", view.getMode() === "full");
|
|
2160
2566
|
};
|
|
2161
|
-
const applyDim = () => {
|
|
2162
|
-
host.classList.toggle("perf-hud--dim", settings.dim);
|
|
2163
|
-
dim.setAttribute("aria-pressed", String(settings.dim));
|
|
2164
|
-
dim.title = `Dim on leave: ${settings.dim ? "on" : "off"}`;
|
|
2165
|
-
};
|
|
2166
|
-
applyMode();
|
|
2167
|
-
applyDim();
|
|
2168
|
-
applyTheme(host, theme);
|
|
2169
|
-
const unsubscribe = settings.subscribe(applyDim);
|
|
2170
|
-
const unsubscribeTheme = theme.subscribe(() => applyTheme(host, theme));
|
|
2171
|
-
const dock = dockPanel(host, {
|
|
2172
|
-
defaultPlacement: options.defaultPlacement,
|
|
2173
|
-
handle: grip,
|
|
2174
|
-
storageKey
|
|
2175
|
-
});
|
|
2176
2567
|
const setMode = (mode) => {
|
|
2177
2568
|
view.setMode(mode);
|
|
2178
2569
|
applyMode();
|
|
2179
|
-
|
|
2180
|
-
};
|
|
2181
|
-
toggle.addEventListener("click", () => {
|
|
2182
|
-
setMode(view.getMode() === "compact" ? "full" : "compact");
|
|
2183
|
-
});
|
|
2184
|
-
let hideTimer = 0;
|
|
2185
|
-
const sleep = () => {
|
|
2186
|
-
if (host.classList.contains("is-dragging")) return;
|
|
2187
|
-
host.classList.remove("is-awake");
|
|
2188
|
-
};
|
|
2189
|
-
const wake = (linger) => {
|
|
2190
|
-
host.classList.add("is-awake");
|
|
2191
|
-
window.clearTimeout(hideTimer);
|
|
2192
|
-
if (linger) hideTimer = window.setTimeout(sleep, TOUCH_LINGER_MS);
|
|
2193
|
-
};
|
|
2194
|
-
const onEnter = () => {
|
|
2195
|
-
wake(false);
|
|
2196
|
-
};
|
|
2197
|
-
const onLeave = (event) => {
|
|
2198
|
-
if (host.contains(event.relatedTarget)) return;
|
|
2199
|
-
if (event.pointerType === "touch" || event.pointerType === "pen") {
|
|
2200
|
-
wake(true);
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
sleep();
|
|
2204
|
-
};
|
|
2205
|
-
const onDown = (event) => {
|
|
2206
|
-
wake(event.pointerType !== "mouse");
|
|
2570
|
+
panel.refresh();
|
|
2207
2571
|
};
|
|
2208
|
-
|
|
2209
|
-
host.addEventListener("pointerleave", onLeave);
|
|
2210
|
-
host.addEventListener("pointerdown", onDown);
|
|
2572
|
+
applyMode();
|
|
2211
2573
|
view.start();
|
|
2212
2574
|
return {
|
|
2213
2575
|
dispose: () => {
|
|
2214
|
-
|
|
2215
|
-
host.removeEventListener("pointerenter", onEnter);
|
|
2216
|
-
host.removeEventListener("pointerleave", onLeave);
|
|
2217
|
-
host.removeEventListener("pointerdown", onDown);
|
|
2218
|
-
unsubscribe();
|
|
2219
|
-
unsubscribeTheme();
|
|
2220
|
-
dock.dispose();
|
|
2576
|
+
panel.dispose();
|
|
2221
2577
|
view.dispose();
|
|
2222
2578
|
theme.dispose();
|
|
2223
|
-
host.remove();
|
|
2224
2579
|
},
|
|
2225
2580
|
element: host,
|
|
2226
2581
|
getMode: () => view.getMode(),
|
|
@@ -2234,6 +2589,6 @@ const mountPerfHud = (monitor, options = {}) => {
|
|
|
2234
2589
|
};
|
|
2235
2590
|
};
|
|
2236
2591
|
//#endregion
|
|
2237
|
-
export {
|
|
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 };
|
|
2238
2593
|
|
|
2239
|
-
//# sourceMappingURL=mount-perf-hud-
|
|
2594
|
+
//# sourceMappingURL=mount-perf-hud-DZKJ1L3J.js.map
|