terainia/web/index.html
Maximus Gorog bb006839cc Runtime perf: cheap fog, sky overdraw kill, fewer cloud octaves, in-game FPS HUD
The features added in Rounds A–D were correct but expensive. The hot
path per frame was sky_color() called from apply_fog for EVERY distant
pixel — 4-octave cloud fbm + star hash + sun/moon disc per fragment,
hundreds of thousands of pixels per frame. Profile-driven cuts that
keep all features but stop paying for them in the wrong places:

1. apply_fog now mixes terrain toward sky_dome (cheap gradient) not
   sky_color (gradient + clouds + sun + moon + stars). Distant terrain
   still fades to the right-direction sky color at every time of day;
   the per-pixel cost drops by ~80%. Full sky_color still runs for
   the SKY BACKGROUND pass where it's actually paid for.

2. Sky pipeline draws AFTER terrain with depth_compare = LessEqual.
   The full-screen sky was previously written first then over-painted
   by terrain — sky's expensive fragment shader ran on every screen
   pixel. Now it only runs on pixels with no terrain in front of them
   (depth = 1.0 cleared), which on most views is 30–60% of the screen
   instead of 100%.

3. fbm2 reduced from 4 → 3 octaves. Negligible visual change at the
   scales we sample, ~25% cheaper per cloud-pixel.

4. Cloud branch skips entirely when day_strength < 0.05 (full night).
   Clouds invisible at night anyway, fbm + smoothstep + mix skipped.

5. In-game FPS HUD (top-right corner):
   - Telemetry struct gains frame_dt_ms (EMA-smoothed in app.rs
     with coefficient 0.85 so the number is readable, not flickery).
   - wasm bridge: get_frame_dt_ms().
   - main.js setupFpsHud() polls it at 5Hz, color-coded:
       green ≤ 18ms (≥55fps), amber 18-33ms, red beyond.
   - Reads what THE GAME measures, not the browser's
     requestAnimationFrame which gets throttled to 1 Hz on
     unfocused windows.

No features removed. God rays, FXAA, ACES tonemap, bounce baking,
specular materials, leaf translucency — all still there. Tests:
63 passing. Wasm release clean.
2026-05-24 15:44:14 -06:00

664 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Voxel Game</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<style>
:root {
--ui-bg: rgba(0,0,0,0.45);
--ui-fg: #f0f0f0;
}
html, body {
margin: 0;
height: 100%;
background: #1a1a1a;
color: var(--ui-fg);
font-family: system-ui, sans-serif;
overflow: hidden;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: none;
overscroll-behavior: none;
}
#game-canvas {
display: block;
width: 100vw;
height: 100dvh;
cursor: crosshair;
image-rendering: pixelated;
touch-action: none;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
background: var(--ui-bg);
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
pointer-events: none;
}
/* Frame-time HUD — top-right, always-on, fed from
window.voxel_game.get_frame_dt_ms() so the number reflects what
the game itself is measuring (not the browser's throttled rAF).
Colors: green ≤ 18ms, amber ≤ 33ms, red beyond. */
#fps {
position: fixed;
top: 12px;
right: 12px;
background: var(--ui-bg);
padding: 6px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, monospace;
pointer-events: none;
z-index: 20;
min-width: 60px;
text-align: right;
color: #cfc;
}
#fps.warn { color: #ffd86a; }
#fps.bad { color: #ff7a6a; }
#hud kbd {
background: #333;
border-radius: 3px;
padding: 1px 5px;
border: 1px solid #555;
font-size: 11px;
}
#crosshair {
position: fixed;
left: 50%; top: 50%;
width: 14px; height: 14px;
margin-left: -7px; margin-top: -7px;
pointer-events: none;
}
#crosshair::before, #crosshair::after {
content: "";
position: absolute;
background: #fff;
mix-blend-mode: difference;
}
#crosshair::before { left: 6px; top: 0; width: 2px; height: 14px; }
#crosshair::after { top: 6px; left: 0; height: 2px; width: 14px; }
/* Connection status / name */
#net-status {
position: fixed;
top: 12px; right: 12px;
background: var(--ui-bg);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
display: flex;
gap: 8px;
align-items: center;
}
#net-status .dot {
width: 9px; height: 9px; border-radius: 50%;
background: #d33; transition: background 0.3s;
}
#net-status.connected .dot { background: #6c6; }
#gp-indicator {
display: none;
font-size: 14px;
line-height: 1;
filter: grayscale(40%);
opacity: 0.8;
}
#gp-indicator.active { display: inline; filter: none; opacity: 1; }
#net-status input {
background: rgba(0,0,0,0.3);
border: 1px solid #555;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 2px 6px;
width: 110px;
font-family: inherit;
}
/* HP bar */
#hp {
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
width: 210px;
height: 18px;
background: rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.25);
border-radius: 4px;
overflow: hidden;
}
#hp-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #e64141, #f0a040);
transition: width 0.2s;
}
#hp-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
text-shadow: 0 1px 2px #000;
}
/* Hotbar — must sit above the touch overlay (z 5) so taps land on slots
instead of being intercepted by the look pad. */
#hotbar {
position: fixed;
bottom: max(14px, env(safe-area-inset-bottom, 0));
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
background: var(--ui-bg);
padding: 5px;
border-radius: 8px;
pointer-events: auto;
z-index: 30;
touch-action: manipulation;
}
.slot {
width: 44px;
height: 44px;
border: 2px solid #555;
border-radius: 6px;
display: flex;
align-items: end;
justify-content: center;
padding-bottom: 3px;
font-size: 10px;
text-align: center;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.85);
cursor: pointer;
user-select: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) inset;
}
.slot.active {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255,255,255,0.30) inset, 0 0 8px rgba(255,255,255,0.35);
}
/* Narrow viewports: shrink slots so all 10 fit in one row. */
@media (max-width: 620px) {
.slot { width: 34px; height: 34px; font-size: 8px; padding-bottom: 2px; }
#hotbar { gap: 3px; padding: 4px; }
}
@media (max-width: 400px) {
.slot { width: 30px; height: 30px; font-size: 7px; padding-bottom: 2px; }
#hotbar { gap: 2px; padding: 3px; }
}
/* Menu */
#menu {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
z-index: 60;
}
body.menu-open #menu { display: flex; }
#menu-card {
background: rgba(28, 28, 32, 0.95);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 28px 32px;
width: 360px;
max-width: 92vw;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
color: #f0f0f0;
font-size: 14px;
}
#menu-card h2 {
margin: 0 0 18px;
font-size: 22px;
letter-spacing: 1px;
text-align: center;
}
.menu-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
gap: 12px;
}
.menu-row label { flex: 1; }
.menu-row .value { width: 56px; text-align: right; opacity: 0.85; }
.menu-row input[type="range"] {
flex: 2;
accent-color: #8aa;
}
.menu-row input[type="text"] {
flex: 2;
background: rgba(0,0,0,0.3);
border: 1px solid #444;
border-radius: 4px;
color: inherit;
padding: 4px 8px;
font-family: inherit;
font-size: 13px;
}
.seg-toggle {
flex: 2;
display: flex;
gap: 0;
border: 1px solid #444;
border-radius: 5px;
overflow: hidden;
}
.seg-toggle button {
flex: 1;
background: rgba(0,0,0,0.3);
color: #ccc;
border: none;
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.seg-toggle button + button { border-left: 1px solid #444; }
.seg-toggle button.active {
background: #6a9;
color: #001;
font-weight: 700;
}
.menu-actions {
display: flex;
gap: 10px;
margin-top: 18px;
}
.menu-actions button {
flex: 1;
padding: 10px;
background: #6a9;
color: #001;
border: none;
border-radius: 5px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.menu-actions button.secondary {
background: #444;
color: #ddd;
}
.menu-actions button:active { transform: translateY(1px); }
#menu-tip {
margin-top: 14px;
font-size: 11px;
opacity: 0.55;
text-align: center;
}
#menu-btn {
/* Must outrank #touch-overlay (z 5) — otherwise on mobile the look pad
sits on top of the button and eats the tap. */
position: fixed;
top: 12px;
right: 280px;
background: var(--ui-bg);
border: 1px solid rgba(255,255,255,0.35);
color: #fff;
width: 44px;
height: 44px;
border-radius: 6px;
font-size: 22px;
line-height: 1;
cursor: pointer;
display: none;
z-index: 30;
touch-action: manipulation;
-webkit-user-select: none;
user-select: none;
}
#menu-btn:active { background: rgba(255,255,255,0.25); }
body.touch #menu-btn {
display: flex;
align-items: center;
justify-content: center;
right: 12px;
top: 60px;
}
/* Gamepad debug overlay — must outrank the settings menu (z 60) since
"Test gamepad…" is opened from inside the menu and we need to actually
see the overlay land on top of it. */
#gptest {
position: fixed;
right: 12px;
bottom: 80px;
z-index: 70;
width: 320px;
max-width: 92vw;
}
#gptest-card {
background: rgba(20, 20, 24, 0.94);
border: 1px solid rgba(255,255,255,0.18);
border-radius: 8px;
padding: 12px 14px;
color: #e7e7e7;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
#gptest button {
background: #555; color: #eee; border: none;
padding: 4px 10px; border-radius: 4px;
font-family: inherit; font-size: 11px; cursor: pointer;
}
.gp-btn {
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(80,80,80,0.5);
border: 1px solid rgba(255,255,255,0.15);
display: flex; align-items: center; justify-content: center;
font-size: 9px; color: #999;
}
.gp-btn.on {
background: #6c6; color: #001; font-weight: 700;
}
.gp-stick {
width: 60px; height: 60px;
border-radius: 50%;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.25);
position: relative;
}
.gp-stick-dot {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: #ddd;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
transition: transform 0.05s linear;
}
.gp-stick-label {
font-size: 9px; opacity: 0.55; text-align: center; margin-top: 3px;
}
/* Death screen */
#death {
position: fixed;
inset: 0;
background: rgba(120, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 24px;
z-index: 100;
}
body.dead #death { display: flex; }
/* Settings menu always wins over the death overlay — when the player
pops the menu they need to interact with sliders / respawn /
settings, not stare at a red death curtain. The z-index alone
wouldn't be enough because the death overlay also has a
backdrop-filter that visibly tints anything underneath. */
body.menu-open #death { display: none; }
#death h1 {
font-size: 64px;
margin: 0;
color: #fff;
text-shadow: 0 4px 12px #000;
letter-spacing: 4px;
}
#death button {
padding: 12px 36px;
font-size: 18px;
background: #fff;
color: #200;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
#death button:active { transform: translateY(1px); }
/* Touch UI — MCPE-style: joystick left, action stack right */
body.touch #hud { display: none; }
#touch-overlay {
position: fixed;
inset: 0;
pointer-events: none; /* children opt in */
z-index: 5;
display: none;
}
body.touch #touch-overlay { display: block; }
body.touch #crosshair { display: block; }
/* Look pad sits underneath everything else, full screen, transparent.
pointer events on stick/buttons land on them first because of z-index. */
#look-pad {
position: absolute;
inset: 0;
background: transparent;
pointer-events: auto;
touch-action: none;
z-index: 1;
}
#stick {
position: absolute;
left: max(28px, env(safe-area-inset-left, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
width: 160px; height: 160px;
border-radius: 50%;
background: rgba(0,0,0,0.22);
border: 2px solid rgba(255,255,255,0.28);
pointer-events: auto;
touch-action: none;
z-index: 2;
box-shadow: 0 4px 18px rgba(0,0,0,0.35);
}
#stick-knob {
position: absolute;
width: 64px; height: 64px;
border-radius: 50%;
background: rgba(255,255,255,0.55);
left: 50%; top: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition: background 0.15s;
box-shadow: 0 2px 6px rgba(0,0,0,0.3) inset;
}
#stick.engaged #stick-knob { background: rgba(255,255,255,0.85); }
#touch-actions {
position: absolute;
right: max(20px, env(safe-area-inset-right, 0));
bottom: max(28px, env(safe-area-inset-bottom, 0));
display: flex;
flex-direction: column-reverse; /* jump on the bottom (thumb), break/place above */
gap: 14px;
pointer-events: none;
z-index: 2;
}
.tbtn {
width: 72px; height: 72px;
border-radius: 50%;
background: rgba(0,0,0,0.32);
border: 2px solid rgba(255,255,255,0.32);
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center; justify-content: center;
pointer-events: auto;
touch-action: none;
user-select: none;
box-shadow: 0 3px 12px rgba(0,0,0,0.3);
}
.tbtn:active, .tbtn.pressed {
background: rgba(255,255,255,0.32);
transform: translateY(1px);
}
#btn-jump { width: 90px; height: 90px; font-size: 28px; }
#btn-break { background: rgba(180,40,40,0.40); }
#btn-place { background: rgba(40,140,40,0.40); }
#btn-sprint { background: rgba(80,80,140,0.40); font-size: 18px; }
/* Hide the top-right name input on touch — name lives in the menu. */
body.touch #net-status #player-name { display: none; }
</style>
</head>
<body>
<canvas id="game-canvas" width="1280" height="720" tabindex="0"></canvas>
<div id="crosshair"></div>
<div id="hud">
<strong>Voxel Game</strong><br/>
<kbd>WASD</kbd> move &nbsp; <kbd>Space</kbd> jump &nbsp; <kbd>Ctrl</kbd> sprint<br/>
Click canvas to lock mouse · <kbd>LMB</kbd> break · <kbd>RMB</kbd> place<br/>
<kbd>1</kbd><kbd>6</kbd> pick block
</div>
<div id="net-status">
<span class="dot"></span>
<span id="net-text">offline</span>
<span id="gp-indicator" title="Gamepad detected">🎮</span>
<input id="player-name" placeholder="name" maxlength="24" />
</div>
<div id="hp">
<div id="hp-fill"></div>
<div id="hp-label">20 / 20</div>
</div>
<div id="hotbar">
<div class="slot" data-b="1" style="background:#5fbb52">grass</div>
<div class="slot" data-b="2" style="background:#8c5c33">dirt</div>
<div class="slot active" data-b="3" style="background:#808284">stone</div>
<div class="slot" data-b="4" style="background:#e0d18c">sand</div>
<div class="slot" data-b="5" style="background:#6b4d2d">wood</div>
<div class="slot" data-b="6" style="background:#338b39">leaves</div>
<div class="slot" data-b="7" style="background:#6b6b73">cobble</div>
<div class="slot" data-b="8" style="background:#a64d38">brick</div>
<div class="slot" data-b="9" style="background:#f2f4f7;color:#222;text-shadow:none">snow</div>
<div class="slot" data-b="10" style="background:#9ed1f0;color:#1a3344;text-shadow:none">ice</div>
</div>
<div id="touch-overlay" aria-hidden="true">
<div id="look-pad"></div>
<div id="stick"><div id="stick-knob"></div></div>
<div id="touch-actions">
<button id="btn-jump" class="tbtn" type="button"></button>
<button id="btn-place" class="tbtn" type="button"></button>
<button id="btn-break" class="tbtn" type="button"></button>
<button id="btn-sprint" class="tbtn" type="button">»</button>
</div>
</div>
<div id="death">
<h1>YOU DIED</h1>
<button id="respawn-btn">RESPAWN</button>
</div>
<button id="menu-btn" title="Menu (Esc)"></button>
<div id="fps">— fps</div>
<div id="gptest" style="display:none;">
<div id="gptest-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<strong>Gamepad debug</strong>
<button id="gptest-close" type="button">close</button>
</div>
<div id="gptest-info" style="font-size:11px; opacity:0.7; margin-bottom:8px;">no controller detected — press any button or move a stick</div>
<div style="display:flex; gap:14px; align-items:center; margin-bottom:8px;">
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-l"></div></div>
<div class="gp-stick-label" id="gp-stick-l-label">L stick</div>
</div>
<div>
<div class="gp-stick"><div class="gp-stick-dot" id="gp-stick-r"></div></div>
<div class="gp-stick-label" id="gp-stick-r-label">R stick</div>
</div>
<div style="flex:1; font-family:monospace; font-size:10px; line-height:1.4;" id="gptest-axes"></div>
</div>
<div id="gptest-buttons" style="display:flex; flex-wrap:wrap; gap:4px;"></div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); font-size: 11px;">
<div style="opacity:0.7; margin-bottom: 5px;">
Stick calibration — push stick fully in the named direction, then click.
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-cal-lx" type="button">L stick ←→</button>
<button id="gp-cal-ly" type="button">L stick ↑↓</button>
<button id="gp-cal-rx" type="button">R stick ←→</button>
<button id="gp-cal-ry" type="button">R stick ↑↓</button>
</div>
<div id="gp-mapping" style="margin-top: 6px; font-family:monospace; opacity: 0.75;"></div>
<button id="gp-cal-reset" type="button" style="margin-top: 6px;">reset to defaults (0,1,2,3)</button>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
<div style="opacity:0.7; margin-bottom: 4px;">
Path verification — these bypass the gamepad and send input straight to the engine.
If clicking these moves you, the game itself is fine; the problem is upstream (Steam Input, etc).
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<button id="gp-test-fwd" type="button">▲ walk fwd 1s</button>
<button id="gp-test-jump" type="button">▼ jump once</button>
</div>
</div>
<div id="gp-kbd-state" style="margin-top: 8px; font-family:monospace; opacity:0.7;">kbd: (waiting)</div>
</div>
</div>
</div>
<div id="menu">
<div id="menu-card">
<h2>SETTINGS</h2>
<div class="menu-row">
<label>Input mode</label>
<div class="seg-toggle">
<button id="mode-pc" type="button">PC</button>
<button id="mode-mobile" type="button">Mobile</button>
</div>
</div>
<div class="menu-row">
<label for="set-name">Player name</label>
<input id="set-name" type="text" maxlength="24" />
</div>
<div class="menu-row">
<label for="set-sens">Mouse sensitivity</label>
<input id="set-sens" type="range" min="0.001" max="0.02" step="0.0005" />
<span class="value" id="set-sens-val"></span>
</div>
<div class="menu-row">
<label for="set-fov">FOV</label>
<input id="set-fov" type="range" min="40" max="110" step="1" />
<span class="value" id="set-fov-val"></span>
</div>
<div class="menu-row">
<label for="set-dist">Render distance</label>
<input id="set-dist" type="range" min="64" max="800" step="16" />
<span class="value" id="set-dist-val"></span>
</div>
<div class="menu-row">
<label for="set-tscale">Time of day speed</label>
<input id="set-tscale" type="range" min="0" max="8" step="0.25" />
<span class="value" id="set-tscale-val"></span>
</div>
<div class="menu-actions">
<button id="menu-resume">RESUME</button>
<button id="menu-respawn" class="secondary">Respawn</button>
</div>
<div class="menu-actions" style="margin-top: 8px;">
<button id="menu-gptest" class="secondary">Test gamepad…</button>
</div>
<div id="menu-tip">Esc to toggle · settings save automatically</div>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>