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