diff --git a/README.md b/README.md
index aa16228..7296e30 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,7 @@ npm run preview # serve the production build
| Sound | **Sound → Enabled** (browsers require one click on the page first) |
| Change the sky | **Deep sky** folder: star density, nebulae, galaxies, or reseed the whole thing |
| Art-direction knobs | append `?debug=1` to the URL for the hidden tuning folder |
+| On a phone | the panel is a bottom sheet: tap its bar to open it, and the eye button hides the whole interface. Quality starts at `low` and the render resolution is capped, because a phone GPU is roughly a tenth of a desktop card at three times the pixel density. |
Every control carries a hover description, and each folder has a reset button.
diff --git a/index.html b/index.html
index 05f85f7..be72042 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,18 @@
-
+
+
+
+
+
Black Hole Visualizer
+
+
diff --git a/src/main.ts b/src/main.ts
index fa1b7c1..4dfe71c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -23,6 +23,7 @@ import { nextWaveState, restingWave } from './sim/gravitationalWave';
import { clearBody, createWorld, placeBinary, placeBody, resetScene, stepWorld } from './sim/world';
import type { Body } from './sim/types';
import { CinematicMode, isTypingIntoControl } from './ui/chrome';
+import { isMobile, mobileRenderBudget } from './ui/device';
import { buildPanel } from './ui/panel';
import type { Preset } from './ui/presets';
import { PlacementController } from './ui/placement';
@@ -57,6 +58,15 @@ function requireElement(id: string): HTMLElement {
const app = requireElement('app');
const hud = requireElement('hud');
const toast = requireElement('toast');
+const chromeToggle = requireElement('chrome-toggle');
+
+// A finger-driven, small screen gets a different layout and a smaller render
+// budget. Decided once at boot: a phone does not become a desktop mid-session,
+// and re-laying-out the panel on every orientation change would be worse than
+// the sizes being slightly off in landscape.
+const mobile = isMobile();
+if (mobile) document.body.classList.add('touch-ui');
+const budget = mobileRenderBudget();
const renderer = new THREE.WebGLRenderer({
antialias: false,
@@ -66,13 +76,14 @@ if (!renderer.capabilities.isWebGL2) {
hud.textContent = 'This visualizer needs WebGL2, which this browser does not provide.';
throw new Error('WebGL2 required');
}
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, mobile ? budget.pixelRatio : 2));
app.appendChild(renderer.domElement);
const settings = defaultSettings();
+if (mobile) settings.quality = 'low';
const world = createWorld();
-const starfield = new Starfield(renderer, settings.sky);
+const starfield = new Starfield(renderer, settings.sky, mobile ? budget.skyFaceSize : undefined);
const rig = new CameraRig(window.innerWidth / window.innerHeight, renderer.domElement);
const bhPass = new BlackHolePass(starfield.texture, settings.quality);
const pipeline = new RenderPipeline(renderer, rig.camera, bhPass, settings.quality);
@@ -134,7 +145,18 @@ const aiming = new AimingController(
},
);
-const cinematic = new CinematicMode(document.body, toast);
+const cinematic = new CinematicMode(
+ document.body,
+ toast,
+ mobile ? 'tap the button to bring them back' : 'press H for the controls',
+);
+chromeToggle.addEventListener('click', () => {
+ cinematic.toggle();
+ chromeToggle.setAttribute(
+ 'aria-label',
+ cinematic.isActive ? 'Show the interface' : 'Hide the interface',
+ );
+});
/** A re-bake invalidates the converged idle frame, so accumulation restarts. */
function rebakeSky(): void {
@@ -224,7 +246,7 @@ const panel = buildPanel(
rebakeSky();
},
},
- new URLSearchParams(window.location.search).has('debug'),
+ { debug: new URLSearchParams(window.location.search).has('debug'), compact: mobile },
);
audio.setVolume(settings.volume);
diff --git a/src/render/starfield.ts b/src/render/starfield.ts
index 1a843fa..cd5fde5 100644
--- a/src/render/starfield.ts
+++ b/src/render/starfield.ts
@@ -26,8 +26,11 @@ export class Starfield {
constructor(
private readonly renderer: THREE.WebGLRenderer,
sky: SkySettings,
+ /** Smaller on phones: six faces of a heavy noise shader at full size is a
+ * visible stall on a mobile GPU, and the stars are tiny there anyway. */
+ faceSize: number = SKY_TUNING.faceSize,
) {
- this.target = new THREE.WebGLCubeRenderTarget(SKY_TUNING.faceSize, {
+ this.target = new THREE.WebGLCubeRenderTarget(faceSize, {
type: THREE.HalfFloatType,
generateMipmaps: false,
});
diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts
index f2548d1..29bf57c 100644
--- a/src/ui/chrome.ts
+++ b/src/ui/chrome.ts
@@ -2,6 +2,10 @@
* Cinematic mode: the control panel and HUD fade away so the render fills the
* screen like a wallpaper. One class on drives the CSS; a toast (which
* stays visible in either mode) says how to get the controls back.
+ *
+ * On a touch device the same state is driven by a floating button instead of
+ * the H key, and that button is deliberately the one piece of interface that
+ * never fades: with no keyboard there has to be a way back.
*/
const CINEMATIC_CLASS = 'cinematic';
@@ -14,6 +18,8 @@ export class CinematicMode {
constructor(
private readonly body: HTMLElement,
private readonly toast: HTMLElement,
+ /** How the user gets the interface back, in their own input terms. */
+ private readonly restoreHint: string,
) {}
/** True while the panel and HUD are hidden. */
@@ -41,7 +47,7 @@ export class CinematicMode {
private apply(): void {
this.body.classList.toggle(CINEMATIC_CLASS, this.hidden);
- this.flash(this.hidden ? 'cinematic mode · press H for the controls' : 'controls restored');
+ this.flash(this.hidden ? `cinematic mode · ${this.restoreHint}` : 'controls restored');
}
private flash(message: string): void {
diff --git a/src/ui/device.ts b/src/ui/device.ts
new file mode 100644
index 0000000..da71144
--- /dev/null
+++ b/src/ui/device.ts
@@ -0,0 +1,37 @@
+/**
+ * What kind of device is this, in the only terms the app actually cares about.
+ *
+ * Deliberately not "is it a phone". The questions worth asking are whether the
+ * pointer is a finger (so hover text and a keyboard shortcut are useless) and
+ * whether the screen is small enough that a 245px panel would swallow it.
+ */
+
+/** A finger or stylus rather than a mouse: no hover, no keyboard shortcuts. */
+export function hasCoarsePointer(): boolean {
+ return window.matchMedia('(pointer: coarse)').matches;
+}
+
+/** Narrow enough that the control panel would cover most of the render. */
+export function hasCompactScreen(): boolean {
+ return Math.min(window.innerWidth, window.innerHeight) < 620;
+}
+
+/**
+ * Treat as mobile when both are true. A touchscreen laptop keeps the desktop
+ * layout, and a narrow desktop window keeps its keyboard shortcuts; only a
+ * device that is both small and finger-driven gets the phone treatment.
+ */
+export function isMobile(): boolean {
+ return hasCoarsePointer() && hasCompactScreen();
+}
+
+/**
+ * Render budget for this device. Phone GPUs are perhaps a tenth of a desktop
+ * card, and a phone screen reports a device pixel ratio of 3, so an
+ * unthrottled raymarch renders nine times the pixels on a tenth of the
+ * hardware. Cap the ratio and start at the cheapest preset; the existing
+ * auto-degrade handles anything still too slow.
+ */
+export function mobileRenderBudget(): { pixelRatio: number; skyFaceSize: number } {
+ return { pixelRatio: 1.25, skyFaceSize: 512 };
+}
diff --git a/src/ui/panel.ts b/src/ui/panel.ts
index eedeb9f..f303a87 100644
--- a/src/ui/panel.ts
+++ b/src/ui/panel.ts
@@ -65,12 +65,34 @@ export interface ControlPanel {
refreshDisplays(): void;
}
+export interface PanelOptions {
+ /** Show the hidden art-direction folder (`?debug=1`). */
+ debug: boolean;
+ /**
+ * Phone layout: the panel is a bottom sheet, so it starts collapsed to its
+ * title bar. Opening it covers most of a phone screen, which is fine when
+ * asked for and intolerable by default.
+ */
+ compact: boolean;
+}
+
export function buildPanel(
settings: Settings,
actions: PanelActions,
- debug: boolean,
+ { debug, compact }: PanelOptions,
): ControlPanel {
- const gui = new GUI({ title: 'Black Hole · H hides this' });
+ const gui = new GUI({ title: 'Black Hole' });
+ if (compact) {
+ // The title bar is the only thing on screen while the sheet is shut, so it
+ // has to say what tapping it does, and stay honest once it is open.
+ const describeState = (): void => {
+ gui.title(gui._closed ? 'Black Hole · tap to open' : 'Black Hole · tap to close');
+ };
+ gui.onOpenClose(describeState);
+ describeState();
+ } else {
+ gui.title('Black Hole · H hides this');
+ }
const shipped = defaultSettings();
const presets = gui.addFolder('0 · Presets');
@@ -154,8 +176,8 @@ export function buildPanel(
'Ends the flight and hands the camera back to mouse control.',
);
explain(
- camera.add(actions, 'toggleCinematic').name('Hide the interface (H)'),
- 'Cinematic mode: fades this panel and the readout for a clean, wallpaper-like frame. Press H again to bring them back.',
+ camera.add(actions, 'toggleCinematic').name(compact ? 'Hide the interface' : 'Hide the interface (H)'),
+ 'Cinematic mode: fades this panel and the readout for a clean, wallpaper-like frame.',
);
camera.close();
@@ -312,6 +334,14 @@ export function buildPanel(
);
render.close();
+ if (compact) {
+ // Presets are the one folder worth having open on a phone: they are the
+ // fastest way to something worth looking at.
+ scene.close();
+ playback.close();
+ gui.close();
+ }
+
const refreshDisplays = (): void => {
gui.controllersRecursive().forEach((controller) => controller.updateDisplay());
};