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()); };