Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
147 changes: 141 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover so the render reaches under a notch; the interface
is kept clear of it with env(safe-area-inset-*) below. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="An interactive black hole visualizer: real Schwarzschild photon geodesics ray-traced per pixel on the GPU, in your browser."
/>
<title>Black Hole Visualizer</title>
<style>
html,
Expand All @@ -11,6 +22,11 @@
height: 100%;
overflow: hidden;
background: #000;
/* A drag on the canvas is a camera move, never a page scroll or a
pinch zoom. Without this the browser eats the gesture first. */
touch-action: none;
overscroll-behavior: none;
-webkit-text-size-adjust: 100%;
}
#app {
position: fixed;
Expand All @@ -20,18 +36,19 @@
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#hud {
position: fixed;
left: 12px;
bottom: 12px;
left: calc(12px + env(safe-area-inset-left));
bottom: calc(12px + env(safe-area-inset-bottom));
color: #9fd8e8;
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
text-shadow: 0 0 4px #000;
pointer-events: none;
white-space: pre;
}
/* Cinematic mode (H): fade the chrome, leave the render untouched. */
/* Cinematic mode: fade the chrome, leave the render untouched. */
#hud,
.lil-gui {
transition: opacity 260ms ease;
Expand All @@ -44,14 +61,16 @@
#toast {
position: fixed;
left: 50%;
bottom: 44px;
bottom: calc(44px + env(safe-area-inset-bottom));
transform: translateX(-50%);
max-width: min(90vw, 420px);
text-align: center;
padding: 8px 16px;
border-radius: 999px;
background: rgba(10, 12, 22, 0.55);
border: 1px solid rgba(159, 216, 232, 0.18);
color: #cfe8f2;
font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.04em;
opacity: 0;
transition: opacity 400ms ease;
Expand All @@ -61,12 +80,128 @@
#toast.visible {
opacity: 1;
}

/* The interface toggle. A phone has no H key, so the shortcut needs a
button; it is the one control that stays visible in cinematic mode. */
#chrome-toggle {
position: fixed;
right: calc(10px + env(safe-area-inset-right));
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid rgba(159, 216, 232, 0.25);
background: rgba(10, 12, 22, 0.6);
backdrop-filter: blur(6px);
color: #cfe8f2;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
#chrome-toggle:active {
background: rgba(30, 36, 54, 0.75);
}
#chrome-toggle .icon-show {
display: none;
}
body.cinematic #chrome-toggle .icon-hide {
display: none;
}
body.cinematic #chrome-toggle .icon-show {
display: block;
}
/* The one control that must survive cinematic mode: with no keyboard,
it is the only way back. */
body.cinematic #chrome-toggle {
opacity: 0.55;
}
body.touch-ui #chrome-toggle {
display: flex;
/* Top right: the panel owns the bottom of a phone screen, and a
button hidden behind it is a button that does not exist. */
top: calc(10px + env(safe-area-inset-top));
bottom: auto;
}

/* Phone layout. The panel is a sheet along the bottom edge that can be
scrolled, rather than a 245px column covering the middle of the
render, and its rows are tall enough to hit with a thumb. */
body.touch-ui .lil-gui.lil-root {
--width: 100vw;
width: 100vw;
--widget-height: 28px;
--font-size: 14px;
--input-font-size: 15px;
--padding: 8px;
--spacing: 6px;
right: auto;
left: 0;
top: auto;
bottom: 0;
max-height: 62vh;
border-radius: 12px 12px 0 0;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.55);
}
body.touch-ui .lil-gui.lil-root > .lil-children {
max-height: calc(62vh - 44px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-y;
}
body.touch-ui .lil-gui .lil-title {
height: 40px;
line-height: 40px;
font-size: 15px;
}
body.touch-ui .lil-gui button,
body.touch-ui .lil-gui .lil-controller {
min-height: 38px;
}
/* The readout moves to the top too, clear of both the sheet and the
button. */
body.touch-ui #hud {
top: calc(14px + env(safe-area-inset-top));
bottom: auto;
font-size: 11px;
max-width: calc(100vw - 100px);
white-space: pre-wrap;
}
body.touch-ui #toast {
bottom: auto;
top: calc(76px + env(safe-area-inset-top));
}
</style>
</head>
<body>
<div id="app"></div>
<div id="hud"></div>
<div id="toast"></div>
<!-- The button shows the action, not the state: an open eye while the
interface is hidden (tap to bring it back), a struck-through eye while
it is showing (tap to hide it). Inline SVG rather than an emoji, which
renders differently on every platform. -->
<button id="chrome-toggle" type="button" aria-label="Hide the interface">
<svg class="icon-hide" viewBox="0 0 24 24" width="24" height="24" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M3 3l18 18"/>
<path d="M10.6 6.2A9.6 9.6 0 0 1 12 6c5 0 9 6 9 6a15 15 0 0 1-2.7 3.2"/>
<path d="M6.6 6.8A15 15 0 0 0 3 12s4 6 9 6a9.4 9.4 0 0 0 4.1-.9"/>
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>
</svg>
<svg class="icon-show" viewBox="0 0 24 24" width="24" height="24" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M3 12s4-6 9-6 9 6 9 6-4 6-9 6-9-6-9-6z"/>
<circle cx="12" cy="12" r="2.6"/>
</svg>
</button>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
30 changes: 26 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion src/render/starfield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
8 changes: 7 additions & 1 deletion src/ui/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> 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';
Expand All @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions src/ui/device.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading