quickBar.ts

examples/lcc/quickBar.ts · raw · open the example · all examples · site home

Exports: makeQuickBarformatCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// The bar at the top left, after examples/splats/quickBar.ts: a sample
// scene, Open a scene folder or a meta.lcc URL, the LoD level (auto or one
// level everywhere), spherical harmonics, the environment (background
// splats), then method, sort, key bits,
// camera mode (orbit, fly or walk) and pixel ratio as segmented buttons, and fps with the splats
// drawn (its "?" lists the camera controls). The frame loop calls
// refresh(), so it follows changes made elsewhere (the panel, the F key).
import type { CameraControls, CameraControlsMode, ProjectionMethod, SortKeyBits, SplatRenderer } from "@sparkjsdev/radix";
import { SAMPLES, type LccSample } from "./catalog.js";
import type { LccStreamerSettings } from "./LccStreamer.js";
import type { ViewerState } from "./main.js";

export function makeQuickBar(bar: HTMLElement, { state, settings, webgpu, splats, controls, sample, levels, hasSh, hasEnvironment, walk, actions }: {
  state: ViewerState;
  settings: LccStreamerSettings;
  webgpu: boolean;
  splats: SplatRenderer;
  controls: CameraControls;
  // The sample shown, if the scene is one
  sample(): LccSample | undefined;
  // The scene's level count and whether it has SH and an environment
  levels(): number;
  hasSh(): boolean;
  hasEnvironment(): boolean;
  // Walk mode: on, possible (the scene has collision), switched
  walk: { active(): boolean; available(): boolean; set(on: boolean): void };
  actions: { pickFolder(): void; openUrl(url: string): void; openSample(name: string): void };
}) {
  const refreshers: (() => void)[] = [];

  const group = (label = "") => {
    const element = Object.assign(document.createElement("div"), { className: "bar-group" });
    if (label) {
      element.append(Object.assign(document.createElement("span"), { className: "bar-label", textContent: label }));
    }
    bar.append(element);
    return element;
  };

  // Buttons for a few choices; the one matching get() is lit
  const segmented = <T>(element: HTMLElement, choices: [T, string][], get: () => T, set: (value: T) => void, disabled = () => false) => {
    const buttons = choices.map(([value, textContent]) => {
      const button = Object.assign(document.createElement("button"), { textContent, onclick: () => set(value) });
      element.append(button);
      return { button, value };
    });
    refreshers.push(() => buttons.forEach(({ button, value }) => {
      button.classList.toggle("active", value === get());
      button.disabled = disabled();
    }));
  };

  const openButton = Object.assign(document.createElement("button"), {
    textContent: "Open folder...", title: "Open an LCC or LCC2 scene folder (or drop one anywhere)", onclick: () => actions.pickFolder(),
  });
  const url = Object.assign(document.createElement("input"), { type: "url", placeholder: ".lcc / .lcc2 URL + Enter" });
  url.onkeydown = (event) => {
    if (event.key === "Enter" && url.value.trim()) {
      actions.openUrl(url.value.trim());
      url.value = "";
      url.blur();
    }
  };
  group().append(openButton, url);

  // Blank while the scene shown is not a sample
  const select = Object.assign(document.createElement("select"), { title: "Sample scenes" });
  select.append(...Object.entries(SAMPLES).map(([name, sample]) => new Option(sample.label, name)));
  select.onchange = () => {
    actions.openSample(select.value);
    select.blur();
  };
  group("scene").append(select);
  refreshers.push(() => {
    const shown = sample();
    const index = shown ? Object.values(SAMPLES).indexOf(shown) : -1;
    if (select.selectedIndex !== index) {
      select.selectedIndex = index;
    }
  });

  // One button per level of the scene shown, rebuilt when the count changes
  const levelGroup = group("LoD");
  let levelButtons: { button: HTMLButtonElement; value: "auto" | number }[] = [];
  refreshers.push(() => {
    const count = levels();
    if (levelButtons.length !== count + 1) {
      levelGroup.querySelectorAll("button").forEach((button) => button.remove());
      levelButtons = (["auto", ...Array.from({ length: count }, (_, i) => i)] as ("auto" | number)[]).map((value) => {
        const button = Object.assign(document.createElement("button"), {
          textContent: value === "auto" ? "auto" : `L${value}`,
          title: value === "auto" ? "Per cell, by distance" : `Level ${value} everywhere`,
          onclick: () => (settings.level = value),
        });
        levelGroup.append(button);
        return { button, value };
      });
    }
    levelButtons.forEach(({ button, value }) => button.classList.toggle("active", value === settings.level));
  });

  segmented(group("SH"), [[true, "on"], [false, "off"]], () => state.sh.enabled && hasSh(), (v) => (state.sh.enabled = v), () => !hasSh());
  const env = group("env");
  env.title = "environment.bin: background splats outside the grid";
  segmented(env, [[true, "on"], [false, "off"]], () => settings.environment && hasEnvironment(), (v) => (settings.environment = v), () => !hasEnvironment());

  const methods: ProjectionMethod[] = ["ewa", "3dgut", "htgs", "raygs"];
  segmented(group("method"), methods.map((m) => [m, m]), () => splats.method, (v) => (splats.method = v));
  const sort = group("sort");
  sort.title = webgpu ? "gpu: WgpuSplatSorter; cpu: the WebGL2 fallback's worker sort" : "The GPU sort needs WebGPU";
  segmented<"gpu" | "cpu">(sort, [["gpu", "gpu"], ["cpu", "cpu"]], () => splats.sortMode, (v) => (splats.sortMode = v), () => !webgpu);
  segmented<SortKeyBits>(group("bits"), [[16, "16"], [24, "24"], [32, "32"]], () => splats.sortBits, (v) => (splats.sortBits = v));
  const camera = group("camera");
  camera.title = "F switches orbit / fly, G walks (scenes with collision)";
  segmented<CameraControlsMode | "walk">(camera, [["orbit", "orbit"], ["fly", "fly"]], () => (walk.active() ? "walk" : controls.mode), (v) => {
    walk.set(false);
    controls.mode = v as CameraControlsMode;
  });
  const walkButton = Object.assign(document.createElement("button"), { textContent: "walk", onclick: () => walk.set(!walk.active()) });
  camera.append(walkButton);
  refreshers.push(() => {
    walkButton.classList.toggle("active", walk.active());
    walkButton.disabled = !walk.available();
    walkButton.title = walk.available() ? "Walk on the collision mesh (G); Space jumps, Shift runs" : "This scene has no collision mesh";
  });
  const px = group("px");
  px.title = `Pixel ratio: 1 or the device's (${devicePixelRatio})`;
  segmented(px, [[false, "1x"], [true, "device"]], () => state.devicePixels, (v) => (state.devicePixels = v));

  const stats = Object.assign(document.createElement("span"), { className: "bar-stats" });
  const help = Object.assign(document.createElement("span"), {
    className: "bar-label",
    textContent: "?",
    title: [
      "drag: look (fly) or orbit (orbit); F switches",
      "double press, then drag: slide",
      "right drag / two fingers: pan",
      "wheel / middle drag / pinch: forward and back (fly) or zoom (orbit)",
      "WASD / arrows: move, Q E: down / up, Shift fast, Ctrl slow, Caps Lock on: 10x",
      "G: walk on the collision mesh (scenes that have one), Space: jump, Shift: run",
    ].join("\n"),
  });
  group().append(stats, help);

  return {
    refresh: () => refreshers.forEach((refresh) => refresh()),
    monitor(fps: number, count: number) {
      stats.textContent = `${Math.round(fps)} fps · ${formatCount(count)} splats`;
    },
  };
}

export function formatCount(v: number) {
  return v >= 1e6 ? `${(v / 1e6).toFixed(2)} M` : v >= 1e4 ? `${(v / 1e3).toFixed(1)} k` : v.toFixed(0);
}