0 fps
diff --git a/src/engine/Clouds.ts b/src/engine/Clouds.ts
index f82a084..8b3303e 100644
--- a/src/engine/Clouds.ts
+++ b/src/engine/Clouds.ts
@@ -54,7 +54,17 @@ export class Clouds {
private lastCenterX = Infinity;
private lastCenterZ = Infinity;
+ /** Scratch fog-range vector, reused each frame to avoid a per-frame allocation. */
+ private readonly _fogRange = new Vector2(0, 0);
+
private enabled = true;
+ /**
+ * "Simple" tier skips the cloud TOP faces — the player is almost always below
+ * the cloud layer (CLOUD_HEIGHT=130 vs player ~y=40) so tops are rarely seen,
+ * and culling them roughly halves the cloud triangle count. Toggled by the
+ * graphics settings (simple vs fancy clouds).
+ */
+ private simple = false;
// Preallocated geometry buffers, reused across rebuilds (no per-build GC).
// RGBA colors (4 floats/vertex) — required by Babylon's vertex-color path.
@@ -74,7 +84,7 @@ export class Clouds {
this.scene = scene;
this.noise = new Noise(seed || "voxl");
- this.cTop = Color3.FromHexString("#ffffff");
+ this.cTop = Color3.FromHexString("#e2eaf2"); // soft cool white, not pure #ffffff
// side1 (N/S walls) and side2 (E/W walls) get progressively more shadow,
// bottom gets full shadow — gives the slabs simple directional depth.
this.cSide1 = SHADOW.scale(0.25).add(new Color3(0.75, 0.75, 0.75));
@@ -125,6 +135,17 @@ export class Clouds {
this.mesh.setEnabled(enabled);
}
+ /**
+ * Toggle the simple tier (skips cloud top faces for ~half the triangles).
+ * Forces a rebuild so the change is immediate rather than waiting for drift.
+ */
+ setSimple(simple: boolean): void {
+ if (this.simple === simple) return;
+ this.simple = simple;
+ this.lastCenterX = Infinity;
+ this.lastCenterZ = Infinity;
+ }
+
setSeed(seed: string): void {
this.noise = new Noise(seed || "voxl");
this.lastCenterX = Infinity;
@@ -198,14 +219,14 @@ export class Clouds {
const addQuad = (
ax: number, ay: number, az: number,
bx: number, by: number, bz: number,
- c: number, cy: number, cz: number,
+ cx2: number, cy2: number, cz2: number,
dx: number, dy: number, dz: number,
color: Color3,
): void => {
const o = v * 3;
pos[o] = ax; pos[o + 1] = ay; pos[o + 2] = az;
pos[o + 3] = bx; pos[o + 4] = by; pos[o + 5] = bz;
- pos[o + 6] = c; pos[o + 7] = cy; pos[o + 8] = cz;
+ pos[o + 6] = cx2; pos[o + 7] = cy2; pos[o + 8] = cz2;
pos[o + 9] = dx; pos[o + 10] = dy; pos[o + 11] = dz;
const co = v * 4;
for (let i = 0; i < 4; i++) {
@@ -233,8 +254,9 @@ export class Clouds {
const filledW = inArea(xi - 1, zi) && grid[gi(xi - 1, zi)] === 1;
// Top and bottom faces (DoubleSide + flat shading via vertex colours,
- // so winding is irrelevant here).
- addQuad(x0, top, z1, x1, top, z1, x1, top, z0, x0, top, z0, this.cTop);
+ // so winding is irrelevant here). Simple tier skips tops — the player is
+ // below the layer and the tops are almost never visible.
+ if (!this.simple) addQuad(x0, top, z1, x1, top, z1, x1, top, z0, x0, top, z0, this.cTop);
addQuad(x0, bot, z0, x1, bot, z0, x1, bot, z1, x0, bot, z1, this.cBottom);
// Side walls only where the neighbour is open (Minetest-style culling).
@@ -263,10 +285,23 @@ export class Clouds {
/** Per-frame fog binding (called from Sky.update). */
bindFog(color: Color3, start: number, end: number, cameraPos: Vector3): void {
this.material.setColor3("fogColor", color);
- this.material.setVector2("fogRange", new Vector2(start, end));
+ this._fogRange.set(start, end);
+ this.material.setVector2("fogRange", this._fogRange);
this.material.setVector3("cameraPos", cameraPos);
}
+ /**
+ * Apply a day/night brightness factor to the cloud layer (0..1). Clouds are
+ * otherwise unlit vertex colours, so without this they'd glow white at night.
+ * Pushed each frame by the lighting system via Sky.
+ */
+ setDayFactor(dayFactor: number): void {
+ // Bright at midday (1.0), dim but not black at midnight (~0.32) so clouds
+ // read as grey shapes against the night sky.
+ const light = 0.32 + 0.68 * dayFactor;
+ this.material.setFloat("uCloudLight", light);
+ }
+
dispose(): void {
this.material.dispose();
this.mesh.dispose();
@@ -306,19 +341,24 @@ function makeCloudMaterial(scene: Scene): ShaderMaterial {
uniform vec3 fogColor;
uniform vec2 fogRange;
uniform vec3 cameraPos;
+ uniform float uCloudLight;
void main() {
float dist = length(vPositionW - cameraPos);
float fog = clamp((fogRange.y - dist) / (fogRange.y - fogRange.x), 0.0, 1.0);
- vec3 col = mix(fogColor, vColor.rgb, fog);
+ // Clouds are unlit by the voxel system, so apply a day/night brightness
+ // factor here so they dim to grey at night instead of glowing white.
+ vec3 lit = vColor.rgb * uCloudLight;
+ vec3 col = mix(fogColor, lit, fog);
gl_FragColor = vec4(col, vColor.a);
}
`,
},
{
attributes: ["position", "color"],
- uniforms: ["world", "worldViewProjection", "fogColor", "fogRange", "cameraPos"],
+ uniforms: ["world", "worldViewProjection", "fogColor", "fogRange", "cameraPos", "uCloudLight"],
},
);
material.backFaceCulling = false;
+ material.setFloat("uCloudLight", 1.0);
return material;
}
diff --git a/src/engine/Input.ts b/src/engine/Input.ts
index 252ae7a..b765c5e 100644
--- a/src/engine/Input.ts
+++ b/src/engine/Input.ts
@@ -108,6 +108,7 @@ export class Input {
return;
}
if (e.code === "Space") e.preventDefault();
+ if (e.code === "F3") e.preventDefault(); // dev perf overlay (avoid browser find-bar)
if (!e.repeat) {
this.onKey?.(e.code, true);
if (e.code === "Space") {
diff --git a/src/engine/Sky.ts b/src/engine/Sky.ts
index e742db8..97fea60 100644
--- a/src/engine/Sky.ts
+++ b/src/engine/Sky.ts
@@ -122,8 +122,20 @@ export class Sky {
this.domeMat.setVector3("bottomColor", this._horVec);
}
- setCloudsEnabled(enabled: boolean): void {
+ /** Push the day/night brightness factor to the cloud layer. */
+ setCloudDayFactor(dayFactor: number): void {
+ this.clouds.setDayFactor(dayFactor);
+ }
+
+ /**
+ * Apply the cloud quality tier in one call:
+ * enabled = false → clouds off entirely
+ * enabled = true, simple = true → simple tier (top faces skipped)
+ * enabled = true, simple = false → full fancy clouds
+ */
+ setClouds(enabled: boolean, simple: boolean): void {
this.clouds.setEnabled(enabled);
+ this.clouds.setSimple(enabled && simple);
}
setCloudSeed(seed: string): void {
diff --git a/src/game/ChunkMesher.ts b/src/game/ChunkMesher.ts
index 76fdb80..417919d 100644
--- a/src/game/ChunkMesher.ts
+++ b/src/game/ChunkMesher.ts
@@ -176,9 +176,10 @@ function pushFace(
const du = uv.u1 - uv.u0;
const dv = uv.v1 - uv.v0;
const base = b.vertexCount;
- // Water pass keeps a single scalar brightness (its material is Standard). The
- // opaque/cutout pass bakes two channels: r=shadedSun g=shadedBlock b=sunLevel
- // a=blockLevel, consumed by the VoxelTerrainMaterial shader.
+ // All faces currently bake the same four-channel colour data
+ // (r=shadedSun, g=shadedBlock, b=sunLevel, a=blockLevel) consumed by the
+ // VoxelTerrainMaterial shader. The water (transparent) pass's colours are
+ // discarded later (World strips them) since water uses a plain StandardMaterial.
let cr: number, cg: number, cb: number, ca: number;
if (twoChannel) {
cr = sample.sunBright;
@@ -304,9 +305,9 @@ export function buildChunkGeometry(
// exposed to (the neighbour air/space), combined with face shade.
const sample = sampleBrightness(nwx, nwy, nwz, FACE_BRIGHTNESS[f]);
const isWaterTop = def.liquid && n[1] === 1;
- // Water keeps a single scalar brightness; opaque/cutout bake two
- // light channels for the VoxelTerrainMaterial shader.
- pushFace(builder, f, wx, wy, wz, def.tiles[f], sample, !def.liquid, isWaterTop);
+ // All faces bake four-channel light colours; the water pass discards
+ // them (World strips the colour kind) since it uses a StandardMaterial.
+ pushFace(builder, f, wx, wy, wz, def.tiles[f], sample, true, isWaterTop);
}
}
}
diff --git a/src/game/Game.ts b/src/game/Game.ts
index 6f8b393..db4821e 100644
--- a/src/game/Game.ts
+++ b/src/game/Game.ts
@@ -3,6 +3,7 @@ import {
Color4,
DynamicTexture,
LinesMesh,
+ AbstractMesh,
Mesh,
MeshBuilder,
Scene,
@@ -41,6 +42,10 @@ import { Menus } from "../ui/Menus";
import { InventoryUI } from "../ui/InventoryUI";
import { loadSettings, saveSettings } from "../state/Settings";
import { LightingSystem } from "./lighting/LightingSystem";
+import { GraphicsController, MAX_RENDER_DISTANCE, MIN_RENDER_DISTANCE, presetRenderDistance } from "./graphics/GraphicsController";
+import { graphicsFromPreset, type GraphicsPreset, type GraphicsSettings } from "./graphics/GraphicsSettings";
+import { PerfOverlay, type PerfSnapshot } from "../ui/PerfOverlay";
+import { ChunkBorderOverlay } from "../ui/ChunkBorderOverlay";
import { dbg, dbgWarn } from "../state/Debug";
const SPAWN_PREGEN_RADIUS = 2;
@@ -77,11 +82,16 @@ export class Game {
private readonly inventory = new Inventory(INVENTORY_SIZE, HOTBAR_SIZE);
private readonly stats = new PlayerState();
private readonly invUI: InventoryUI;
+ private readonly graphics: GraphicsController;
+ private readonly perf: PerfOverlay;
+ private readonly chunkBorders: ChunkBorderOverlay;
private selectedIndex = 0;
private last = performance.now();
private fpsEma = 60;
+ private frameMsEma = 16.7;
private hudTimer = 0;
+ private perfTimer = 0;
private running = false;
private inventoryOpen = false;
@@ -95,6 +105,8 @@ export class Game {
private cactusT = 0;
private saveTimer = 0;
private lastTargetKey = "";
+ /** Render-debug wireframe overlay (terrain + water). Dev only. */
+ private wireframe = false;
constructor(host: HTMLElement) {
this.settings = loadSettings();
@@ -131,12 +143,19 @@ export class Game {
() => this.settings.mode,
);
+ // Graphics pipeline: owns render scale + post-processing, and binds per-world
+ // material/shadow/cloud settings when a world is created. Apply once now so
+ // render scale / AA / clouds are correct on the main menu too.
+ this.graphics = new GraphicsController(this.renderer.engine, scene, this.sky, this.player.camera);
+ this.perf = new PerfOverlay();
+ this.chunkBorders = new ChunkBorderOverlay(scene);
+ this.graphics.apply(this.settings.graphics);
+
this.highlight = makeHighlight(scene);
const { mesh, material } = makeBreakOverlay(scene);
this.breakOverlay = mesh;
this.breakMaterial = material;
- this.sky.setCloudsEnabled(this.settings.clouds);
this.hud.setFpsVisible(this.settings.showFps);
this.screens.setMenuSeed(this.settings.seed);
@@ -158,6 +177,7 @@ export class Game {
this.menus.onResume = () => this.resume();
this.menus.onQuit = () => this.quitToMenu();
this.menus.onSettingsChange = (patch) => this.applySettings(patch);
+ this.menus.onGraphicsPreset = (preset) => this.applyGraphicsPreset(preset);
this.menus.onRegenerate = (seed) => this.regenerate(seed);
this.invUI.onModeChange = (mode) => this.setMode(mode);
@@ -176,6 +196,15 @@ export class Game {
if (!down) return;
if (code === "KeyP") void this.takeScreenshot();
if (code === "KeyF") this.selectSlot(this.selectedIndex + 1);
+ if (code === "F3") {
+ const on = this.perf.toggle();
+ this.hud.showToast(on ? "Perf overlay: on" : "Perf overlay: off");
+ }
+ if (code === "KeyN") this.toggleWireframe();
+ if (code === "KeyB") {
+ const on = this.chunkBorders.toggle();
+ this.hud.showToast(on ? "Chunk borders: on" : "Chunk borders: off");
+ }
if (code === "KeyE" && (this.state === "playing" || this.inventoryOpen)) this.toggleInventory();
if (code === "Escape") {
if (this.inventoryOpen) this.closeInventory();
@@ -190,6 +219,17 @@ export class Game {
this.renderer.canvas.addEventListener("click", () => {
if (this.state === "playing" && !this.input.locked && !this.inventoryOpen) this.input.requestLock();
});
+ // WebGL context loss/recovery: Babylon auto-restores GL state, but surface a
+ // toast so the player knows a hiccup happened (common when switching tabs on
+ // integrated GPUs). No data is lost — chunk meshes are re-uploaded by Babylon.
+ this.renderer.engine.onContextLostObservable.add(() => {
+ dbgWarn("WebGL context lost — Babylon will attempt to restore");
+ this.hud.showToast("WebGL context lost — restoring…");
+ });
+ this.renderer.engine.onContextRestoredObservable.add(() => {
+ dbg("WebGL context restored");
+ this.hud.showToast("WebGL context restored");
+ });
}
private handleResize = (): void => {
@@ -197,18 +237,33 @@ export class Game {
const h = window.innerHeight;
this.renderer.setSize(w, h);
this.player.setAspect(w / h);
+ // DPR can change when the window moves between displays; keep render scale correct.
+ this.graphics.refreshRenderScale();
};
// ----------------------------------------------------------- settings ---
private applySettings(patch: Partial
): void {
+ // Clamp render distance into a browser-safe range. Copy the patch first so
+ // we never mutate the caller's object (it may be reused, e.g. by Menus).
+ if (patch.viewDistance !== undefined) {
+ patch = {
+ ...patch,
+ viewDistance: Math.max(MIN_RENDER_DISTANCE, Math.min(MAX_RENDER_DISTANCE, Math.round(patch.viewDistance))),
+ };
+ }
this.settings = { ...this.settings, ...patch };
saveSettings(this.settings);
this.screens.setMenuSeed(this.settings.seed);
if (patch.fov !== undefined) this.player.setFov(patch.fov);
- if (patch.clouds !== undefined) this.sky.setCloudsEnabled(patch.clouds);
if (patch.showFps !== undefined) this.hud.setFpsVisible(patch.showFps);
if (patch.viewDistance !== undefined) this.updateFog();
+ // Graphics settings are applied reactively (render scale, AA, shadows,
+ // clouds, water, foliage) — no page reload needed.
+ if (patch.graphics !== undefined) {
+ this.graphics.apply(this.settings.graphics);
+ this.updateFog(); // fog toggle / view-distance interplay
+ }
if (patch.mode !== undefined) {
this.player.canFly = patch.mode === "creative";
if (patch.mode === "survival") this.player.flying = false;
@@ -218,6 +273,17 @@ export class Game {
this.menus.updateCurrent(this.settings);
}
+ /**
+ * Switch to a built-in preset (low/medium/high): applies the full graphics
+ * config AND nudges the render distance to the preset's recommended value,
+ * so a "Low" preset is actually faster (shorter view) and "High" reaches
+ * further. Used by the settings UI.
+ */
+ applyGraphicsPreset(preset: GraphicsPreset): void {
+ const graphics: GraphicsSettings = graphicsFromPreset(preset);
+ this.applySettings({ graphics, viewDistance: presetRenderDistance(preset) });
+ }
+
// ------------------------------------------------------ game states ---
private startGame(): void {
@@ -273,6 +339,9 @@ export class Game {
this.sky.setCloudSeed(seed);
// Wire the lighting system into the new world + the sky's Babylon lights.
this.lighting = new LightingSystem(this.world, this.sky, this.scene);
+ // Re-bind the graphics controller to the new world so material/shadow/cloud
+ // settings apply to it (the controller re-applies the full config).
+ this.graphics.attachWorld(this.world, this.lighting);
}
/** Load inventory+vitals for this seed, or seed a fresh starter kit. */
@@ -390,12 +459,12 @@ export class Game {
if (this.state === "playing") {
if (this.inventoryOpen) {
// Freeze the action but keep the world streaming + rendering.
- this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance);
+ this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance, dt * 1000);
} else {
this.update(dt);
}
} else if (this.state === "paused") {
- this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance);
+ this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance, dt * 1000);
}
// Advance day/night + position sun/moon + push terrain uniforms even while
@@ -413,13 +482,22 @@ export class Game {
this.sky.update(dt, this.player.camera.position);
this.scene.render();
this.updateFps(dt);
+ this.updatePerf(dt);
+ // Chunk-border debug overlay: rebuilt only when the player crosses a chunk
+ // boundary, so the per-frame cost is negligible when open.
+ this.chunkBorders.update(
+ this.player.position.x,
+ this.player.position.z,
+ this.player.position.y,
+ (cb) => this.world?.forEachChunkCoord(cb),
+ );
};
private update(dt: number): void {
const world = this.world!;
const mode = this.settings.mode;
this.player.update(dt, world, this.input, this.settings);
- world.update(this.player.position.x, this.player.position.z, this.settings.viewDistance);
+ world.update(this.player.position.x, this.player.position.z, this.settings.viewDistance, dt * 1000);
if (this.breakCooldown > 0) this.breakCooldown -= dt;
@@ -751,18 +829,99 @@ export class Game {
// --------------------------------------------------------- screens ---
private updateFog(): void {
- const far = this.settings.viewDistance * 16 * 1.7;
+ // Fog is the primary tool for hiding chunk pop-in. The terrain shader
+ // replicates Babylon's linear fog manually (driven by uFogStart/uFogEnd
+ // pushed each frame from these scene values), and the water/StandardMaterial
+ // pass uses scene fog directly. End the fog a bit beyond the render distance
+ // so the freshest chunks fade in under cover instead of popping.
+ if (!this.settings.graphics.fog) {
+ // Disabling fog: push the range out so the fog mix ≈ 1 (no tint) while
+ // keeping the uniforms consistent for both shader paths.
+ this.scene.fogStart = 0;
+ this.scene.fogEnd = 1e6;
+ return;
+ }
+ const far = this.settings.viewDistance * 16 * 2.0;
+ // Higher presets get a later fog start → clearer mid-distance terrain and
+ // less of the washed-out haze. Low keeps an earlier start to hide the
+ // pop-in that comes with its shorter render distance.
+ const startFrac =
+ this.settings.graphics.preset === "high" ? 0.6 :
+ this.settings.graphics.preset === "low" ? 0.42 : 0.5;
this.scene.fogEnd = far;
- this.scene.fogStart = far * 0.4;
+ this.scene.fogStart = far * startFrac;
}
private updateFps(dt: number): void {
if (dt <= 0) return;
const instant = 1 / dt;
this.fpsEma += (instant - this.fpsEma) * 0.1;
+ this.frameMsEma += (dt * 1000 - this.frameMsEma) * 0.1;
if (this.settings.showFps) this.hud.setFps(Math.round(this.fpsEma));
}
+ /** Throttled (~10 Hz) perf-overlay refresh. Cheap on its own; the per-frame
+ * scene counters come from Babylon's active-mesh tracking. */
+ private updatePerf(dt: number): void {
+ this.perfTimer += dt;
+ if (this.perfTimer < 0.1) return;
+ this.perfTimer = 0;
+ if (!this.perf.isOpen) return;
+ this.perf.update(this.buildPerfSnapshot());
+ }
+
+ private buildPerfSnapshot(): PerfSnapshot {
+ const scene = this.scene;
+ const active = scene.getActiveMeshes();
+ const activeSet = new Set();
+ for (let i = 0; i < active.length; i++) activeSet.add(active.data[i]);
+ const stats = this.world
+ ? this.world.chunkStats(activeSet)
+ : { loaded: 0, meshed: 0, dirty: 0, visible: 0 };
+ const shadowsEnabled = !!this.lighting?.shadowsEnabled;
+ const casters = shadowsEnabled ? this.lighting!.shadows.casterCount : 0;
+ const g = this.settings.graphics;
+ const eng = this.renderer.engine;
+ const gl = eng.getGlInfo();
+ const dn = this.lighting?.dayNight;
+ // JS heap is Chrome-only; report null elsewhere rather than misleading 0.
+ const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
+ return {
+ fps: this.fpsEma,
+ frameMs: this.frameMsEma,
+ activeMeshes: active.length,
+ totalMeshes: scene.meshes.length,
+ triangles: Math.round(scene.getActiveIndices() / 3),
+ drawEstimate: active.length + casters,
+ loadedChunks: stats.loaded,
+ meshedChunks: stats.meshed,
+ visibleChunks: stats.visible,
+ culledChunks: Math.max(0, stats.meshed - stats.visible),
+ meshQueue: stats.dirty,
+ lightQueue: this.world?.lightDirtyCount ?? 0,
+ shadowCasters: casters,
+ shadowsEnabled,
+ waterMeshes: this.world?.waterMeshCount ?? 0,
+ preset: g.preset,
+ viewDistance: this.settings.viewDistance,
+ renderScale: g.renderScale,
+ dpr: Math.min(window.devicePixelRatio || 1, g.dprCap),
+ renderWidth: eng.getRenderWidth(),
+ renderHeight: eng.getRenderHeight(),
+ heapUsedMB: mem ? mem.usedJSHeapSize / 1_048_576 : null,
+ gpuRenderer: gl?.renderer || null,
+ timeOfDay: dn?.timeOfDay ?? 0.5,
+ fogStart: scene.fogStart,
+ fogEnd: scene.fogEnd,
+ ambientIntensity: dn?.ambientIntensity ?? 0,
+ sunIntensity: dn?.sunIntensity ?? 0,
+ dayFactor: dn?.dayFactor ?? 1,
+ waterAlpha: this.world?.waterShader.currentAlpha ?? 1,
+ waterQuality: this.world?.waterShader.currentQuality ?? "—",
+ antiAliasing: g.antiAliasing,
+ };
+ }
+
// ------------------------------------------------------ screenshot ---
async takeScreenshot(): Promise {
@@ -784,6 +943,7 @@ export class Game {
this.running = false;
window.removeEventListener("resize", this.handleResize);
this.input.dispose();
+ this.graphics.dispose();
this.lighting?.dispose();
this.world?.dispose();
this.sky.dispose();
@@ -863,6 +1023,144 @@ export class Game {
}
}
+ /**
+ * Render-debug wireframe overlay: switches the shared terrain + water
+ * materials to wireframe rendering. Useful for spotting hidden faces,
+ * overdraw and mesh efficiency. Toggle from the keyboard (N) or the console
+ * (`__voxl.wireframe()`).
+ */
+ _setWireframe(on: boolean): void {
+ this.wireframe = on;
+ if (!this.world) return;
+ this.world.terrainOpaque.material.wireframe = on;
+ this.world.terrainCutout.material.wireframe = on;
+ this.world.waterShader.material.wireframe = on;
+ }
+
+ toggleWireframe(): void {
+ this._setWireframe(!this.wireframe);
+ this.hud.showToast(this.wireframe ? "Wireframe: on" : "Wireframe: off");
+ }
+
+ /**
+ * Debug: toggle back-face culling on the OPAQUE terrain material. OFF by
+ * default (the known-correct setting for this scene's face winding). Provided
+ * so culling can be re-tested safely; if it reintroduces holes, leave it off.
+ */
+ _setTerrainCulling(on: boolean): void {
+ if (!this.world) return;
+ this.world.terrainOpaque.material.backFaceCulling = on;
+ }
+
+ /**
+ * Debug: force every loaded chunk mesh to render regardless of frustum
+ * culling (sets `alwaysSelectAsActiveMesh`). Use to confirm whether missing
+ * terrain is a frustum-culling problem vs a mesh/material problem. Affects
+ * existing + future chunk meshes.
+ */
+ _setRenderAllChunks(on: boolean): void {
+ this.world?.setRenderAllChunks(on);
+ this.hud.showToast(on ? "Frustum cull: BYPASS (render all)" : "Frustum cull: normal");
+ }
+
+ /**
+ * Debug: conservative "safe mode" to isolate terrain — disables shadows,
+ * simplifies clouds, disables foliage distance-cull, and bypasses frustum
+ * culling. Lets you confirm the terrain mesh alone renders correctly. Call
+ * again with `false` to restore the player's settings.
+ */
+ _safeMode(on: boolean): void {
+ if (on) {
+ this._safeModePrev = {
+ graphics: { ...this.settings.graphics },
+ };
+ this.applySettings({
+ graphics: {
+ ...this.settings.graphics,
+ shadows: "off",
+ clouds: "off",
+ foliage: "high",
+ antiAliasing: false,
+ },
+ });
+ this.world?.setRenderAllChunks(true);
+ this.perf.setVisible(true);
+ } else if (this._safeModePrev) {
+ this.applySettings({ graphics: this._safeModePrev.graphics });
+ this.world?.setRenderAllChunks(false);
+ this._safeModePrev = null;
+ }
+ this.hud.showToast(on ? "Safe mode ON (terrain isolation)" : "Safe mode OFF");
+ }
+ private _safeModePrev: { graphics: GraphicsSettings } | null = null;
+
+ /** Toggle the performance overlay from the console (`__voxl.perf()`). */
+ _togglePerf(on?: boolean): void {
+ this.perf.setVisible(on ?? !this.perf.isOpen);
+ }
+
+ /** Toggle the chunk-border debug overlay from the console. */
+ _toggleChunkBorders(on?: boolean): void {
+ this.chunkBorders.setVisible(on ?? !this.chunkBorders.isOpen);
+ }
+
+ // ---- Per-layer isolation toggles (for diagnosing patches/artifacts) ----
+
+ /** Debug: show/hide the entire water layer. */
+ _setWater(on: boolean): void {
+ this.world?.setWaterEnabled(on);
+ this.hud.showToast(on ? "Water: on" : "Water: off");
+ }
+
+ /** Debug: force water fully opaque + flat (isolate the water color). */
+ _setWaterOpaque(on: boolean): void {
+ this.world?.waterShader.setDebugOpaque(on);
+ this.hud.showToast(on ? "Water: opaque debug" : "Water: normal");
+ }
+
+ /** Debug: toggle distance fog. */
+ _setFog(on: boolean): void {
+ this.applySettings({ graphics: { ...this.settings.graphics, fog: on } });
+ this.hud.showToast(on ? "Fog: on" : "Fog: off");
+ }
+
+ /** Debug: toggle FXAA post-processing. */
+ _setPost(on: boolean): void {
+ this.applySettings({ graphics: { ...this.settings.graphics, antiAliasing: on } });
+ this.hud.showToast(on ? "Post (FXAA): on" : "Post (FXAA): off");
+ }
+
+ /** Debug: toggle real-time shadows. Routes through applySettings so the
+ * shadow tier, the settings UI, and localStorage all stay in sync (terrain
+ * can't receive shadows yet, so this currently only changes GPU cost, not the
+ * look — useful to confirm that). */
+ _setShadows(on: boolean): void {
+ const current = this.settings.graphics.shadows;
+ const shadows = on ? (current !== "off" ? current : "medium") : "off";
+ this.applySettings({ graphics: { ...this.settings.graphics, shadows } });
+ this.hud.showToast(on ? "Shadows: on" : "Shadows: off");
+ }
+
+ /** Debug: log every chunk mesh's name + material + vertex count. */
+ _dumpMaterials(): void {
+ this.world?.dumpChunkMaterials();
+ this.hud.showToast("Dumped chunk materials (see console)");
+ }
+
+ /**
+ * Water audit for the console (`__voxl.waterStats()`): counts loaded chunks
+ * containing water and the total water block count, confirming whether the
+ * world actually generated oceans/lakes near the player. On-demand only (it
+ * scans every loaded block).
+ */
+ _waterStats(): unknown {
+ if (!this.world) return { error: "no world" };
+ const s = this.world.waterStats();
+ // eslint-disable-next-line no-console
+ console.log("[water]", s);
+ return s;
+ }
+
/** TEMP debug: inspect interaction state. */
_debugInfo(): Record {
const t = this.player.getTarget();
diff --git a/src/game/World.ts b/src/game/World.ts
index 28c8447..769371b 100644
--- a/src/game/World.ts
+++ b/src/game/World.ts
@@ -1,11 +1,12 @@
import {
+ AbstractMesh,
Color3,
Material,
Mesh,
Scene,
- StandardMaterial,
Texture,
TransformNode,
+ Vector3,
VertexData,
} from "@babylonjs/core";
import { CHUNK_SIZE, CHUNK_HEIGHT, MAX_CHUNK_GEN_PER_FRAME, MAX_CHUNK_MESH_PER_FRAME } from "../constants";
@@ -15,12 +16,16 @@ import { buildChunkGeometry } from "./ChunkMesher";
import { TerrainGenerator, findGroundY } from "./TerrainGenerator";
import { VoxelLightEngine, lightKey } from "./lighting/VoxelLightEngine";
import { VoxelTerrainMaterial } from "./lighting/VoxelTerrainMaterial";
+import { WaterMaterial } from "./lighting/WaterMaterial";
import {
LIGHT_MAX,
MAX_CHUNK_LIGHT_PER_FRAME,
lightToBrightness,
type LightDebugMode,
} from "./lighting/LightingConfig";
+import type { FoliageDensity, WaterQuality } from "./graphics/GraphicsSettings";
+import { dbg } from "../state/Debug";
+import { WATER_BLOCK } from "./Blocks";
function key(cx: number, cz: number): string {
return `${cx},${cz}`;
@@ -30,6 +35,10 @@ interface ChunkMeshes {
opaque?: Mesh;
cutout?: Mesh;
transparent?: Mesh;
+ /** Numeric chunk coords cached at creation so hot loops (foliage cull, shadow
+ * caster list) don't re-parse the map key every frame. */
+ cx: number;
+ cz: number;
}
/**
@@ -40,12 +49,20 @@ interface ChunkMeshes {
export class World {
readonly root: TransformNode;
/**
- * Opaque + cutout terrain use a custom two-channel shader (sun channel ×
- * dayFactor, block channel unaffected by night) so the day/night cycle never
- * forces a chunk remesh. Water stays on a plain StandardMaterial.
+ * Two terrain material instances sharing one shader. Both are currently
+ * DOUBLE-SIDED (back-face culling OFF): an earlier attempt enabled culling on
+ * the opaque pass, but this mesher's face winding does not match Babylon's
+ * front-face convention, so culling removed visible faces and punched holes
+ * through the terrain. Culling stays off until the winding is fixed; a debug
+ * toggle (`__voxl.terrainCulling(on)`) re-enables it for testing.
+ * - {@link terrainOpaque} — terrain cube faces.
+ * - {@link terrainCutout} — plant crosses (must be double-sided anyway).
+ * Day/night + fog + debug uniforms are forwarded to BOTH each frame.
*/
- readonly terrainMaterial: VoxelTerrainMaterial;
- readonly waterMaterial: StandardMaterial;
+ readonly terrainOpaque: VoxelTerrainMaterial;
+ readonly terrainCutout: VoxelTerrainMaterial;
+ /** Animated water shader (shared). {@link waterMaterial} exposes the raw material. */
+ readonly waterShader: WaterMaterial;
readonly generator: TerrainGenerator;
/** Atlas must have hasAlpha=true for the cutout pass to alpha-test. */
readonly atlasHasAlpha: boolean;
@@ -62,6 +79,32 @@ export class World {
/** Active light debug overlay (applied as a material uniform — no remesh). */
private lightDebugMode: LightDebugMode = "off";
+ /**
+ * Debug: when false, all water (transparent) meshes are hidden. Use to
+ * confirm whether a suspect patch is the water layer. Read at mesh creation;
+ * toggling also updates existing meshes via {@link setWaterEnabled}.
+ */
+ private waterEnabled = true;
+
+ /**
+ * Debug: when true, all chunk meshes get `alwaysSelectAsActiveMesh = true` so
+ * Babylon never frustum-culls them — useful to confirm whether missing terrain
+ * is a culling issue. Read at mesh creation; toggling also updates existing
+ * meshes via {@link setRenderAllChunks}.
+ */
+ private forceRenderAll = false;
+
+ // --- Graphics-quality state (driven by GraphicsController, render-time only;
+ // never changes world generation, so worlds stay deterministic). ---
+ /** Current water quality tier. */
+ private waterQuality: WaterQuality = "medium";
+ /**
+ * Max distance (blocks) at which plantlike (cutout) meshes are drawn. Beyond
+ * this, chunk cutout meshes are hidden to save fill/cutout-fragment cost.
+ * Foliage density is controlled purely at draw time (no generation change).
+ */
+ private foliageCutoutDistance = Infinity;
+
constructor(seed: string, atlas: Texture, scene: Scene) {
this.scene = scene;
this.root = new TransformNode("world-root", scene);
@@ -75,21 +118,50 @@ export class World {
// instance: opaque tiles have alpha=1 (never discarded), plant tiles have
// alpha=0 backgrounds (discarded by the alpha test), so a single 0.5 cutoff
// handles both cube faces and the plantlike X-cross.
- this.terrainMaterial = new VoxelTerrainMaterial(scene, { texture: atlas, alphaCutOff: 0.5 });
+ // Two terrain material instances sharing one shader. NOTE: both are
+ // DOUBLE-SIDED (back-face culling OFF). A previous change enabled culling on
+ // the opaque pass for fragment savings, but this scene's face winding does
+ // NOT match Babylon's front-face convention, so culling removed visible
+ // faces and produced massive sky-coloured holes through the terrain.
+ // Culling is therefore OFF until the winding is explicitly verified; a debug
+ // toggle (`__voxl.terrainCulling(on)`) lets you experiment safely.
+ this.terrainOpaque = new VoxelTerrainMaterial(scene, { texture: atlas, alphaCutOff: 0.5, doubleSided: true });
+ this.terrainCutout = new VoxelTerrainMaterial(scene, { texture: atlas, alphaCutOff: 0.5, doubleSided: true });
+
+ // Transparent pass: a plain StandardMaterial water surface (solid blue tint
+ // + emissive floor + specular, alpha-blended, no depth write, double-sided).
+ // Day/night + fog come from the scene lights/fog; the LightingSystem calls
+ // on waterShader are no-ops retained for API compatibility.
+ this.waterShader = new WaterMaterial(scene, { texture: atlas });
+ }
+
+ /** The opaque ShaderMaterial (shared, double-sided until the winding is fixed). */
+ get opaqueMaterial(): Material { return this.terrainOpaque.material; }
+ /** The cutout ShaderMaterial (shared, double-sided for plant crosses). */
+ get cutoutMaterial(): Material { return this.terrainCutout.material; }
+ /** The raw water Babylon material (shared). */
+ get waterMaterial(): Material { return this.waterShader.material; }
+
+ // -- Terrain lighting/debug forwarding: push uniforms to BOTH terrain
+ // materials so the opaque + cutout passes stay in lock-step. --
- // Transparent pass: water (alpha-blended, no depth write, double-sided).
- this.waterMaterial = new StandardMaterial("voxel-water", scene);
- this.waterMaterial.diffuseTexture = atlas;
- this.waterMaterial.specularColor = new Color3(0, 0, 0);
- this.waterMaterial.alpha = 0.72;
- this.waterMaterial.backFaceCulling = false;
- this.waterMaterial.disableDepthWrite = true;
- this.waterMaterial.transparencyMode = Material.MATERIAL_ALPHABLEND;
+ /** Live day/night state for both terrain passes (call every frame). */
+ setTerrainDayNight(dayFactor: number, moonFloor: number): void {
+ this.terrainOpaque.setDayNight(dayFactor, moonFloor);
+ this.terrainCutout.setDayNight(dayFactor, moonFloor);
}
- /** The opaque + cutout ShaderMaterial (shared). */
- get opaqueMaterial(): Material { return this.terrainMaterial.material; }
- get cutoutMaterial(): Material { return this.terrainMaterial.material; }
+ /** Fog + camera for both terrain passes (call every frame). */
+ setTerrainFog(cameraPosition: Vector3, color: Color3, start: number, end: number): void {
+ this.terrainOpaque.setFog(cameraPosition, color, start, end);
+ this.terrainCutout.setFog(cameraPosition, color, start, end);
+ }
+
+ /** Debug overlay mode for both terrain passes (no remesh). */
+ private setTerrainDebugMode(code: number, tint: Color3): void {
+ this.terrainOpaque.setDebugMode(code, tint);
+ this.terrainCutout.setDebugMode(code, tint);
+ }
private spiral(radius: number): Array<{ dx: number; dz: number; d: number }> {
const cached = this.spiralCache.get(radius);
@@ -232,7 +304,7 @@ export class World {
mode === "sun" ? new Color3(1.0, 0.85, 0.4) :
mode === "block" ? new Color3(1.0, 0.7, 0.35) :
new Color3(1, 1, 1);
- this.terrainMaterial.setDebugMode(code, tint);
+ this.setTerrainDebugMode(code, tint);
}
getLightDebugMode(): LightDebugMode {
@@ -244,6 +316,55 @@ export class World {
return this.lightDirty.size;
}
+ /** Set the water quality tier (applied to the shared water material). */
+ setWaterQuality(quality: WaterQuality): void {
+ this.waterQuality = quality;
+ this.applyWaterQuality();
+ }
+
+ get currentWaterQuality(): WaterQuality {
+ return this.waterQuality;
+ }
+
+ /**
+ * Apply the water tier to the shared water material. Centralised so the water
+ * upgrade and a world recreate both call it. The full animated shader is
+ * layered on top in the water-rendering pass; here we keep a sensible baseline
+ * on the existing material so all tiers render correctly from the start.
+ */
+ private applyWaterQuality(): void {
+ this.waterShader.setQuality(this.waterQuality);
+ }
+
+ /** Set the foliage (cutout) render-distance tier. Draw-time only. */
+ setFoliageDensity(density: FoliageDensity): void {
+ this.foliageCutoutDistance = density === "low" ? 48 : density === "medium" ? 96 : Infinity;
+ }
+
+ /**
+ * Snapshot of chunk streaming state for the performance overlay. `active` is
+ * the set of meshes Babylon considers visible this frame (from
+ * scene.getActiveMeshes()); we count a chunk as visible if any of its meshes
+ * is in that set.
+ */
+ chunkStats(active: Set): { loaded: number; meshed: number; dirty: number; visible: number } {
+ let meshed = 0;
+ let visible = 0;
+ let dirty = 0;
+ for (const entry of this.meshes.values()) {
+ if (entry.opaque || entry.cutout || entry.transparent) meshed++;
+ if (
+ (entry.opaque !== undefined && active.has(entry.opaque)) ||
+ (entry.cutout !== undefined && active.has(entry.cutout)) ||
+ (entry.transparent !== undefined && active.has(entry.transparent))
+ ) {
+ visible++;
+ }
+ }
+ for (const chunk of this.chunks.values()) if (chunk.dirty) dirty++;
+ return { loaded: this.chunks.size, meshed, dirty, visible };
+ }
+
/** Highest non-air, non-water block at a column (for spawn placement). */
groundHeight(wx: number, wz: number): number {
const cx = Math.floor(wx / CHUNK_SIZE);
@@ -272,14 +393,28 @@ export class World {
return chunk;
}
- /** Per-frame streaming: generate + mesh chunks around the player. */
- update(playerX: number, playerZ: number, viewDistance: number): void {
+ /**
+ * Per-frame streaming: generate + mesh chunks around the player.
+ *
+ * `frameMs` (last frame time) drives adaptive budgets: when the frame is
+ * healthy we allow extra catch-up work, when it stutters we throttle to the
+ * minimum so chunk gen/remesh never compounds a frame spike. This keeps frame
+ * pacing stable while flying across chunk boundaries.
+ */
+ update(playerX: number, playerZ: number, viewDistance: number, frameMs = 16): void {
const pcx = Math.floor(playerX / CHUNK_SIZE);
const pcz = Math.floor(playerZ / CHUNK_SIZE);
const order = this.spiral(viewDistance);
- let genBudget = MAX_CHUNK_GEN_PER_FRAME;
- let meshBudget = MAX_CHUNK_MESH_PER_FRAME;
+ // Adaptive budget factor from the last frame time:
+ // ≤16ms (60fps): 1.5× — catch up faster when there's headroom
+ // ≤22ms (~45fps): 1.0× — baseline
+ // ≤35ms (~28fps): 0.5× — ease off
+ // >35ms: 0.25× — barely trickle, don't add to the stall
+ const f = frameMs <= 16 ? 1.5 : frameMs <= 22 ? 1 : frameMs <= 35 ? 0.5 : 0.25;
+ let genBudget = Math.max(1, Math.round(MAX_CHUNK_GEN_PER_FRAME * f));
+ let meshBudget = Math.max(1, Math.round(MAX_CHUNK_MESH_PER_FRAME * f));
+ const lightBudget = Math.max(1, Math.round(MAX_CHUNK_LIGHT_PER_FRAME * f));
// Generate missing chunks (closest first), respecting the budget.
for (const off of order) {
@@ -307,7 +442,7 @@ export class World {
// Propagate queued light updates (closest-first budget) BEFORE meshing so
// meshes always read fresh light values.
- this.processLightDirty(MAX_CHUNK_LIGHT_PER_FRAME);
+ this.processLightDirty(lightBudget);
// Mesh dirty chunks (closest first), respecting the budget.
for (const off of order) {
@@ -334,6 +469,27 @@ export class World {
this.lightDirty.delete(k);
}
}
+
+ // Foliage (cutout) render-distance cull: hide plantlike meshes beyond the
+ // configured distance so grass/flowers don't cost fill rate at range. This
+ // is a draw-time decision only — chunks stay fully generated/deterministic.
+ // Reads the numeric coords cached on ChunkMeshes (no per-frame key parsing).
+ const cutDist = this.foliageCutoutDistance;
+ if (cutDist !== Infinity) {
+ const cutSq = cutDist * cutDist;
+ for (const entry of this.meshes.values()) {
+ const m = entry.cutout;
+ if (!m) continue;
+ const dx = entry.cx * 16 + 8 - playerX;
+ const dz = entry.cz * 16 + 8 - playerZ;
+ m.setEnabled(dx * dx + dz * dz <= cutSq);
+ }
+ } else {
+ // Ensure all cutout meshes are enabled when set back to full distance.
+ for (const entry of this.meshes.values()) {
+ if (entry.cutout && !entry.cutout.isEnabled()) entry.cutout.setEnabled(true);
+ }
+ }
}
private rebuildMesh(chunk: Chunk): void {
@@ -344,19 +500,35 @@ export class World {
);
const k = key(chunk.cx, chunk.cz);
const existing = this.meshes.get(k);
+ dbg(
+ "rebuildMesh",
+ k,
+ "opaque=" + (result.opaque ? "y" : "n"),
+ "cutout=" + (result.cutout ? "y" : "n"),
+ "water=" + (result.transparent ? "y" : "n"),
+ );
+
+ // The water (transparent) pass uses a StandardMaterial with a UNIFORM blue
+ // tint + scene lights/fog. Strip its baked vertex colours so per-chunk
+ // voxel-light values can't modulate the surface — otherwise relight
+ // differences between neighbouring chunks show up as a grid of seams across
+ // a lake. With no colour kind, the material supplies one continuous tint.
+ if (result.transparent) result.transparent.colors = null;
// Opaque
- this.applyMesh(k, "opaque", result.opaque, this.opaqueMaterial, existing);
+ this.applyMesh(k, chunk.cx, chunk.cz, "opaque", result.opaque, this.opaqueMaterial, existing);
// Cutout (plantlike decorations)
- this.applyMesh(k, "cutout", result.cutout, this.cutoutMaterial, existing);
+ this.applyMesh(k, chunk.cx, chunk.cz, "cutout", result.cutout, this.cutoutMaterial, existing);
// Transparent (water)
- this.applyMesh(k, "transparent", result.transparent, this.waterMaterial, existing);
+ this.applyMesh(k, chunk.cx, chunk.cz, "transparent", result.transparent, this.waterMaterial, existing);
chunk.dirty = false;
}
private applyMesh(
k: string,
+ cx: number,
+ cz: number,
slot: "opaque" | "cutout" | "transparent",
vd: VertexData | null,
material: Material,
@@ -364,11 +536,12 @@ export class World {
): void {
let entry = this.meshes.get(k);
if (!entry) {
- entry = {};
+ entry = { cx, cz };
this.meshes.set(k, entry);
}
const prev = entry[slot];
if (prev) {
+ dbg("disposeMesh", slot, k);
prev.dispose();
entry[slot] = undefined;
}
@@ -381,6 +554,19 @@ export class World {
// alpha-blended. Either way, nothing here receives Babylon shadow maps.
mesh.receiveShadows = false;
vd.applyToMesh(mesh, false);
+ // Debug: optionally bypass frustum culling for every chunk mesh.
+ mesh.alwaysSelectAsActiveMesh = this.forceRenderAll;
+ // The water (transparent) pass uses a StandardMaterial with a uniform blue
+ // tint; disable the mesh's baked vertex colours so they can't darken it.
+ if (slot === "transparent") {
+ mesh.useVertexColors = false;
+ mesh.setEnabled(this.waterEnabled);
+ }
+ // Chunk geometry lives in world space (vertices include the chunk origin)
+ // and the world root never moves, so the world matrix is constant. Freeze
+ // it once: Babylon skips the per-frame parent×local matrix multiply for
+ // every static chunk mesh — a large saving with hundreds of chunks.
+ mesh.freezeWorldMatrix();
entry[slot] = mesh;
}
}
@@ -390,13 +576,60 @@ export class World {
* manager to keep the shadow render list limited to nearby casters.
*/
forEachOpaqueMesh(cb: (cx: number, cz: number, mesh: Mesh) => void): void {
- for (const [k, entry] of this.meshes) {
+ for (const entry of this.meshes.values()) {
const m = entry.opaque;
if (!m) continue;
- const comma = k.indexOf(",");
- const cx = parseInt(k.slice(0, comma), 10);
- const cz = parseInt(k.slice(comma + 1), 10);
- cb(cx, cz, m);
+ cb(entry.cx, entry.cz, m);
+ }
+ }
+
+ /**
+ * Iterate every loaded chunk's grid coordinates (regardless of mesh state).
+ * Used by the chunk-border debug overlay to show the loaded-chunk footprint.
+ */
+ forEachChunkCoord(cb: (cx: number, cz: number) => void): void {
+ for (const chunk of this.chunks.values()) cb(chunk.cx, chunk.cz);
+ }
+
+ /**
+ * Debug: toggle forced rendering of every chunk mesh (bypass frustum culling).
+ * Updates existing meshes and the flag read at mesh creation.
+ */
+ setRenderAllChunks(on: boolean): void {
+ this.forceRenderAll = on;
+ for (const entry of this.meshes.values()) {
+ for (const slot of ["opaque", "cutout", "transparent"] as const) {
+ const m = entry[slot];
+ if (m) m.alwaysSelectAsActiveMesh = on;
+ }
+ }
+ }
+
+ /**
+ * Debug: show/hide the entire water layer (all transparent meshes). Use to
+ * isolate whether a patch is the water surface vs the terrain beneath.
+ */
+ setWaterEnabled(on: boolean): void {
+ this.waterEnabled = on;
+ for (const entry of this.meshes.values()) {
+ const m = entry.transparent;
+ if (m) m.setEnabled(on);
+ }
+ }
+
+ /**
+ * Debug: dump every chunk mesh's name, material name, and triangle count to
+ * the console — for correlating a visible patch with the mesh/material that
+ * produces it.
+ */
+ dumpChunkMaterials(): void {
+ for (const [k, entry] of this.meshes) {
+ for (const slot of ["opaque", "cutout", "transparent"] as const) {
+ const m = entry[slot];
+ if (!m) continue;
+ const vd = m.getVerticesData?.("position");
+ dbg(slot, k, "mat=" + (m.material?.name ?? "null"), "verts=" + (vd ? vd.length / 3 : 0));
+ }
}
}
@@ -415,13 +648,39 @@ export class World {
return this.chunks.size;
}
+ /** Number of chunk entries with a transparent (water) mesh — cheap diagnostic. */
+ get waterMeshCount(): number {
+ let n = 0;
+ for (const entry of this.meshes.values()) if (entry.transparent) n++;
+ return n;
+ }
+
+ /**
+ * Full water audit (for the console `__voxl.waterStats()` only — iterates
+ * every loaded block, so do not call per-frame). Reports how many loaded
+ * chunks contain water and the total water block count, to confirm the world
+ * actually generated oceans/lakes near the player.
+ */
+ waterStats(): { chunksWithWater: number; waterBlocks: number; loaded: number } {
+ let chunksWithWater = 0;
+ let waterBlocks = 0;
+ for (const chunk of this.chunks.values()) {
+ let local = 0;
+ const b = chunk.blocks;
+ for (let i = 0; i < b.length; i++) if (b[i] === WATER_BLOCK) local++;
+ if (local > 0) { chunksWithWater++; waterBlocks += local; }
+ }
+ return { chunksWithWater, waterBlocks, loaded: this.chunks.size };
+ }
+
dispose(): void {
for (const k of [...this.meshes.keys()]) this.disposeMeshes(k);
this.chunks.clear();
this.lightDirty.clear();
this.lighting.dispose();
- this.terrainMaterial.dispose();
- this.waterMaterial.dispose();
+ this.terrainOpaque.dispose();
+ this.terrainCutout.dispose();
+ this.waterShader.dispose();
this.root.dispose();
}
}
diff --git a/src/game/graphics/GraphicsController.ts b/src/game/graphics/GraphicsController.ts
new file mode 100644
index 0000000..bfda87e
--- /dev/null
+++ b/src/game/graphics/GraphicsController.ts
@@ -0,0 +1,202 @@
+// Applies {@link GraphicsSettings} to the live Babylon engine/scene so changes
+// take effect at runtime without a reload. The controller owns the post-process
+// pipeline and render-scale; per-world objects (materials, shadows, clouds) are
+// bound via {@link attachWorld} when a world is created.
+//
+// Everything here is idempotent and short-circuits when nothing changed, so it
+// is safe to call {@link apply} every time settings change.
+
+import {
+ DefaultRenderingPipeline,
+ type Engine,
+ type Scene,
+} from "@babylonjs/core";
+import type { Sky } from "../../engine/Sky";
+import type { World } from "../World";
+import type { LightingSystem } from "../lighting/LightingSystem";
+import type { UniversalCamera } from "@babylonjs/core";
+import {
+ GRAPHICS_PRESETS,
+ type GraphicsSettings,
+ type ShadowQuality,
+} from "./GraphicsSettings";
+import type { ShadowConfig } from "../lighting/LightingConfig";
+
+/** Resolve a shadow-quality tier to a concrete shadow-map configuration. */
+export function shadowConfigForQuality(quality: ShadowQuality): {
+ enabled: boolean;
+ config: Partial;
+} {
+ switch (quality) {
+ case "low":
+ return { enabled: true, config: { mapSize: 1024, casterRadius: 40, frustum: 72, blur: false } };
+ case "medium":
+ return { enabled: true, config: { mapSize: 2048, casterRadius: 48, frustum: 80, blur: true } };
+ case "high":
+ return { enabled: true, config: { mapSize: 4096, casterRadius: 64, frustum: 96, blur: true } };
+ case "off":
+ default:
+ return { enabled: false, config: {} };
+ }
+}
+
+/** Maximum supported render distance (chunk radius), capped for browser safety. */
+export const MAX_RENDER_DISTANCE = 12;
+/** Minimum render distance — below this the world feels empty. */
+export const MIN_RENDER_DISTANCE = 2;
+
+export class GraphicsController {
+ private readonly engine: Engine;
+ private readonly scene: Scene;
+ private readonly sky: Sky;
+ private readonly camera: UniversalCamera;
+ private world: World | null = null;
+ private lighting: LightingSystem | null = null;
+
+ private pipeline: DefaultRenderingPipeline | null = null;
+ private last: GraphicsSettings | null = null;
+
+ constructor(engine: Engine, scene: Scene, sky: Sky, camera: UniversalCamera) {
+ this.engine = engine;
+ this.scene = scene;
+ this.sky = sky;
+ this.camera = camera;
+ }
+
+ /** Bind the per-world objects (re-called whenever a new world is created). */
+ attachWorld(world: World, lighting: LightingSystem): void {
+ this.world = world;
+ this.lighting = lighting;
+ // Reapply the full config so a freshly created world inherits current settings.
+ if (this.last) {
+ const g = this.last;
+ this.last = null;
+ this.apply(g);
+ }
+ }
+
+ detachWorld(): void {
+ this.world = null;
+ this.lighting = null;
+ }
+
+ /** Apply (or reapply) a graphics config. Idempotent + short-circuiting. */
+ apply(g: GraphicsSettings): void {
+ if (this.last && shallowEqual(this.last, g) && this.world) return;
+ this.last = { ...g };
+ this.applyRenderScale(g);
+ this.applyAntiAliasing(g);
+ this.applyFog(g);
+ this.applyShadows(g);
+ this.applyClouds(g);
+ this.applyWater(g);
+ this.applyFoliage(g);
+ }
+
+ get current(): GraphicsSettings | null {
+ return this.last;
+ }
+
+ /** Re-apply render scale only (called on resize — DPR can change between displays). */
+ refreshRenderScale(): void {
+ if (this.last) this.applyRenderScale(this.last);
+ }
+
+ // ----------------------------------------------------------- passes ---
+
+ private applyRenderScale(g: GraphicsSettings): void {
+ const dpr = window.devicePixelRatio || 1;
+ const cappedDpr = Math.min(dpr, g.dprCap);
+ // setHardwareScalingLevel(s) renders at (canvas size / s). To render at
+ // (cappedDpr × renderScale) device pixels per CSS pixel, s = 1 / that factor.
+ const factor = Math.max(0.05, cappedDpr * g.renderScale);
+ this.engine.setHardwareScalingLevel(1 / factor);
+ }
+
+ private applyAntiAliasing(g: GraphicsSettings): void {
+ if (g.antiAliasing) {
+ if (!this.pipeline) {
+ // Create the post-process pipeline once and reuse it. A disabled
+ // pipeline still costs a fullscreen RTT pass, so we dispose it entirely
+ // when AA is off (below) rather than just disabling FXAA.
+ this.pipeline = new DefaultRenderingPipeline("fxaa", true, this.scene, [this.camera]);
+ this.pipeline.fxaaEnabled = true;
+ this.pipeline.bloomEnabled = false;
+ this.pipeline.sharpenEnabled = false;
+ // IMPORTANT: keep the pipeline a PURE FXAA pass. The custom terrain/water
+ // shaders already author final sRGB colours, so enabling imageProcessing
+ // here re-applies tone mapping / gamma and washes the whole scene out
+ // (this was the High-preset washout bug). FXAA is a separate post-process
+ // and runs fine without it.
+ this.pipeline.imageProcessingEnabled = false;
+ this.pipeline.samples = 1; // rely on FXAA, not MSAA-in-pipeline
+ }
+ this.pipeline.fxaaEnabled = true;
+ } else if (this.pipeline) {
+ // AA off → tear down the pipeline so the scene renders straight to the
+ // default framebuffer (no fullscreen pass). Engine-level MSAA (set at
+ // context creation) remains as the baseline edge AA.
+ this.pipeline.dispose();
+ this.pipeline = null;
+ }
+ }
+
+ private applyFog(g: GraphicsSettings): void {
+ // Scene-level fog is always available; the custom terrain shader replicates
+ // it manually (fogEnabled=false on the material), driven by LightingSystem.
+ // Toggling fog here just clamps the fog range to the camera so distant
+ // terrain isn't artificially hidden when the player disables it.
+ this.scene.fogEnabled = g.fog;
+ }
+
+ private applyShadows(g: GraphicsSettings): void {
+ if (!this.lighting) return;
+ const { enabled, config } = shadowConfigForQuality(g.shadows);
+ this.lighting.shadows.configure({ enabled, ...config });
+ }
+
+ private applyClouds(g: GraphicsSettings): void {
+ if (g.clouds === "off") this.sky.setClouds(false, false);
+ else this.sky.setClouds(true, g.clouds === "simple");
+ }
+
+ private applyWater(g: GraphicsSettings): void {
+ this.world?.setWaterQuality(g.water);
+ }
+
+ private applyFoliage(g: GraphicsSettings): void {
+ this.world?.setFoliageDensity(g.foliage);
+ }
+
+ dispose(): void {
+ this.pipeline?.dispose();
+ this.pipeline = null;
+ this.last = null;
+ }
+}
+
+/** Render-distance (chunk radius) recommended for a preset. */
+export function presetRenderDistance(preset: GraphicsSettings["preset"]): number {
+ switch (preset) {
+ case "low": return 4;
+ case "high": return 8;
+ case "medium":
+ default: return 6;
+ }
+}
+
+export { GRAPHICS_PRESETS };
+
+function shallowEqual(a: GraphicsSettings, b: GraphicsSettings): boolean {
+ return (
+ a.preset === b.preset &&
+ a.renderScale === b.renderScale &&
+ a.dprCap === b.dprCap &&
+ a.antiAliasing === b.antiAliasing &&
+ a.shadows === b.shadows &&
+ a.water === b.water &&
+ a.foliage === b.foliage &&
+ a.clouds === b.clouds &&
+ a.fog === b.fog
+ );
+}
diff --git a/src/game/graphics/GraphicsSettings.ts b/src/game/graphics/GraphicsSettings.ts
new file mode 100644
index 0000000..2625d5a
--- /dev/null
+++ b/src/game/graphics/GraphicsSettings.ts
@@ -0,0 +1,156 @@
+// Graphics settings: scalable, preset-driven rendering configuration for a
+// browser voxel game. Three presets (Low/Medium/High) cover the common cases;
+// individual toggles let advanced players tune without drowning in options.
+//
+// Everything here is plain data. {@link GraphicsController} turns it into real
+// engine/scene/material changes; {@link Settings} persists it in localStorage.
+
+export type GraphicsPreset = "low" | "medium" | "high" | "custom";
+
+/** Shadow map quality. "off" disables Babylon shadows entirely (voxel light stays). */
+export type ShadowQuality = "off" | "low" | "medium" | "high";
+
+/** Water rendering tier. */
+export type WaterQuality = "low" | "medium" | "high";
+
+/** Foliage (plant-cross) density / render distance tier. */
+export type FoliageDensity = "low" | "medium" | "high";
+
+/** Cloud rendering tier. */
+export type CloudsQuality = "off" | "simple" | "fancy";
+
+export interface GraphicsSettings {
+ /** Active preset. Becomes "custom" once any individual value is changed. */
+ preset: GraphicsPreset;
+ /**
+ * Render scale as a fraction of device-pixel resolution (0.5..1).
+ * 1.0 = native device pixels; 0.75 = 75% (cheaper, slightly softer).
+ */
+ renderScale: number;
+ /** Device-pixel-ratio ceiling (1.5..2). Lower = cheaper on retina screens. */
+ dprCap: number;
+ /** Browser-friendly anti-aliasing (FXAA post-process when on). */
+ antiAliasing: boolean;
+ /** Real-time shadow quality (off by default; voxel lighting is the baseline). */
+ shadows: ShadowQuality;
+ /** Water visual tier. */
+ water: WaterQuality;
+ /** Foliage density / render distance tier. */
+ foliage: FoliageDensity;
+ /** Cloud tier. */
+ clouds: CloudsQuality;
+ /** Distance fog (hides chunk pop-in; almost always on). */
+ fog: boolean;
+}
+
+export const GRAPHICS_PRESETS: Record, GraphicsSettings> = {
+ low: {
+ preset: "low",
+ renderScale: 0.75,
+ dprCap: 1.5,
+ antiAliasing: false,
+ shadows: "off",
+ water: "low",
+ foliage: "low",
+ clouds: "simple",
+ fog: true,
+ },
+ medium: {
+ preset: "medium",
+ renderScale: 1.0,
+ dprCap: 2,
+ antiAliasing: false,
+ shadows: "low",
+ water: "medium",
+ foliage: "medium",
+ clouds: "fancy",
+ fog: true,
+ },
+ high: {
+ preset: "high",
+ renderScale: 1.0,
+ dprCap: 2,
+ antiAliasing: true,
+ shadows: "medium",
+ water: "high",
+ foliage: "high",
+ clouds: "fancy",
+ fog: true,
+ },
+};
+
+/** Default graphics settings — conservative Medium, tuned down for browser safety. */
+export function defaultGraphicsSettings(): GraphicsSettings {
+ return detectLowEndDevice()
+ ? { ...GRAPHICS_PRESETS.low }
+ : { ...GRAPHICS_PRESETS.medium };
+}
+
+/** A sensible default render distance (chunks) matching the device class. */
+export function defaultRenderDistance(): number {
+ return detectLowEndDevice() ? 4 : 6;
+}
+
+/**
+ * Best-effort low-end device detection. Uses the hints browsers actually expose
+ * (CPU cores, device memory, mobile UA, DPR) and errs on the side of "low" so
+ * the game starts smoothly on weak hardware; players can raise it in settings.
+ */
+export function detectLowEndDevice(): boolean {
+ try {
+ const nav = navigator as Navigator & { deviceMemory?: number };
+ const cores = nav.hardwareConcurrency ?? 8;
+ const mem = nav.deviceMemory ?? 8;
+ const ua = nav.userAgent || "";
+ const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
+ // Mobile, ≤4 cores, or ≤4 GB reported memory → treat as low end.
+ if (mobile) return true;
+ if (cores <= 4 && mem <= 4) return true;
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Recursively fill any missing fields of a (possibly stale, loaded) graphics
+ * object against the current defaults, so old saved settings never break on a
+ * schema change.
+ */
+export function migrateGraphics(parsed: Partial | undefined): GraphicsSettings {
+ const base = defaultGraphicsSettings();
+ if (!parsed) return base;
+ // Migrate the legacy boolean `clouds` (pre-graphics-settings) into the 3-state
+ // clouds tier. Only accept a string for the new field — a stale save could
+ // carry graphics: { clouds: true }, which must not leak through as a boolean.
+ const legacy = parsed as Partial & { clouds?: boolean };
+ let clouds: CloudsQuality | undefined =
+ typeof parsed.clouds === "string" ? parsed.clouds : undefined;
+ if (clouds === undefined && typeof legacy.clouds === "boolean") {
+ clouds = legacy.clouds ? "fancy" : "off";
+ }
+ return {
+ preset: parsed.preset ?? base.preset,
+ renderScale: clamp(parsed.renderScale ?? base.renderScale, 0.5, 1),
+ dprCap: clamp(parsed.dprCap ?? base.dprCap, 1.5, 2),
+ antiAliasing: parsed.antiAliasing ?? base.antiAliasing,
+ shadows: parsed.shadows ?? base.shadows,
+ water: parsed.water ?? base.water,
+ foliage: parsed.foliage ?? base.foliage,
+ clouds: clouds ?? base.clouds,
+ fog: parsed.fog ?? base.fog,
+ };
+}
+
+/** Resolve a preset name to a full graphics config. */
+export function graphicsFromPreset(preset: GraphicsPreset): GraphicsSettings {
+ if (preset === "custom") {
+ // "custom" is sticky; callers should not request it from here.
+ return { ...GRAPHICS_PRESETS.medium, preset: "custom" };
+ }
+ return { ...GRAPHICS_PRESETS[preset] };
+}
+
+function clamp(v: number, lo: number, hi: number): number {
+ return v < lo ? lo : v > hi ? hi : v;
+}
diff --git a/src/game/lighting/LightingConfig.ts b/src/game/lighting/LightingConfig.ts
index 659c41f..068f265 100644
--- a/src/game/lighting/LightingConfig.ts
+++ b/src/game/lighting/LightingConfig.ts
@@ -13,9 +13,13 @@ export const LIGHT_MAX = MAX_LIGHT;
* between. Matches the Minetest/Minecraft "ambient occlusion by face normal"
* convention and gives cube edges readable definition.
*
+ * The spread is deliberately wide (top 1.0 → bottom 0.45, sides ~0.78/0.82) so
+ * terrain reads with clear depth and overhangs/caves stay visibly darker,
+ * instead of the uniformly-lit "flat" look a tighter spread produces.
+ *
* Index order matches FACE in Blocks.ts: [PX, NX, PY, NY, PZ, NZ].
*/
-export const FACE_SHADE = [0.8, 0.8, 1.0, 0.5, 0.86, 0.86] as const;
+export const FACE_SHADE = [0.76, 0.76, 1.0, 0.45, 0.82, 0.82] as const;
/** Brightness multiplier for plantlike (X-cross) decorations. */
export const PLANT_SHADE = 0.95;
diff --git a/src/game/lighting/LightingSystem.ts b/src/game/lighting/LightingSystem.ts
index edab9a5..c51aade 100644
--- a/src/game/lighting/LightingSystem.ts
+++ b/src/game/lighting/LightingSystem.ts
@@ -54,18 +54,27 @@ export class LightingSystem {
// Visuals: sun/moon discs + sky dome gradient.
this.celestial.update(cameraPosition, dn);
this.sky.setDomeColours(dn.skyZenith, dn.skyHorizon);
+ // Clouds are unlit, so push the day/night factor so they dim at night.
+ this.sky.setCloudDayFactor(dn.dayFactor);
// Terrain shader uniforms: sun channel × dayFactor (+ moonlight floor),
- // block channel untouched. Fog tracks the horizon colour.
+ // block channel untouched. Fog tracks the horizon colour. Pushed to BOTH the
+ // opaque and cutout terrain materials so the two passes stay in lock-step.
const fogColor: Color3 = dn.skyHorizon;
- this.world.terrainMaterial.setDayNight(dn.dayFactor, dn.moonFactor);
- this.world.terrainMaterial.setFog(
+ this.world.setTerrainDayNight(dn.dayFactor, dn.moonFactor);
+ this.world.setTerrainFog(
cameraPosition,
fogColor,
this.scene.fogStart,
this.scene.fogEnd,
);
+ // Water uses a plain StandardMaterial, so day/night + fog are handled by
+ // the scene lights/fog (not custom uniforms). These calls are retained as
+ // no-ops for API compatibility (WaterMaterial ignores them).
+ this.world.waterShader.setDayNight(dn.dayFactor, dn.moonFactor);
+ this.world.waterShader.setFog(cameraPosition, fogColor, this.scene.fogStart, this.scene.fogEnd);
+
// Shadows are dormant unless explicitly enabled (terrain uses voxel sunlight).
this.shadows.update(playerX, playerY, playerZ);
}
diff --git a/src/game/lighting/ShadowManager.ts b/src/game/lighting/ShadowManager.ts
index 91a697f..b390955 100644
--- a/src/game/lighting/ShadowManager.ts
+++ b/src/game/lighting/ShadowManager.ts
@@ -27,6 +27,9 @@ export class ShadowManager {
private readonly sun: DirectionalLight;
private readonly world: World;
private generator: ShadowGenerator | null = null;
+ /** Snapshot of the config the live generator was built with, so configure()
+ * can skip an expensive teardown/rebuild when nothing actually changed. */
+ private appliedConfig: ShadowConfig | null = null;
/** Current caster list, rebuilt each frame from nearby chunk meshes. */
private casters: Mesh[] = [];
private playerX = 0;
@@ -71,6 +74,7 @@ export class ShadowManager {
rt.renderParticles = false;
}
this.generator = sg;
+ this.appliedConfig = { ...this.config };
}
/**
@@ -124,6 +128,35 @@ export class ShadowManager {
}
}
+ /**
+ * Reconfigure shadow quality at runtime. Merges a partial config, then either
+ * tears down the generator (disabled), builds it (newly enabled), or rebuilds
+ * it (quality params like mapSize/casterRadius/blur changed). Skips the
+ * teardown/rebuild entirely when the resolved config matches the live
+ * generator's config — GraphicsController.applyShadows() runs on every graphics
+ * change (e.g. render scale), so without this guard an unrelated tweak would
+ * needlessly rebuild the shadow generator.
+ */
+ configure(patch: Partial): void {
+ Object.assign(this.config, patch);
+ const wantEnabled = this.config.enabled;
+ const hasGen = this.generator !== null;
+ if (!wantEnabled) {
+ if (hasGen) {
+ this.dispose();
+ this.config.enabled = false;
+ }
+ return;
+ }
+ // Already enabled with an identical config → nothing to do.
+ if (hasGen && this.appliedConfig && shadowConfigEquals(this.appliedConfig, this.config)) {
+ return;
+ }
+ // wantEnabled === true: ensure a correctly-configured generator exists.
+ if (hasGen) this.dispose();
+ this.setup();
+ }
+
/** Toggle and return the new enabled state. */
toggle(): boolean {
this.setEnabled(!this.enabled);
@@ -134,6 +167,11 @@ export class ShadowManager {
return this.generator !== null;
}
+ /** Number of meshes currently in the shadow render list (perf overlay). */
+ get casterCount(): number {
+ return this.casters.length;
+ }
+
/**
* Dump every mesh in the shadow render list (name, position, bounds,
* visibility) plus the light/frustum state. For diagnosing shadow artifacts.
@@ -176,10 +214,30 @@ export class ShadowManager {
dispose(): void {
this.generator?.dispose();
this.generator = null;
+ this.appliedConfig = null;
this.casters = [];
}
}
+/**
+ * Structural equality over the shadow-relevant config fields, used by
+ * {@link ShadowManager.configure} to skip a generator rebuild when the resolved
+ * config is unchanged.
+ */
+function shadowConfigEquals(a: ShadowConfig, b: ShadowConfig): boolean {
+ return (
+ a.enabled === b.enabled &&
+ a.mapSize === b.mapSize &&
+ a.frustum === b.frustum &&
+ a.casterRadius === b.casterRadius &&
+ a.blur === b.blur &&
+ a.bias === b.bias &&
+ a.frustumEdgeFalloff === b.frustumEdgeFalloff &&
+ a.shadowMinZ === b.shadowMinZ &&
+ a.shadowMaxZ === b.shadowMaxZ
+ );
+}
+
/**
* Membership-equal compare of two mesh lists without allocating (the caster
* list order isn't stable across frames, so this is an O(n²) membership check —
diff --git a/src/game/lighting/VoxelTerrainMaterial.ts b/src/game/lighting/VoxelTerrainMaterial.ts
index 46e9936..33ee1c3 100644
--- a/src/game/lighting/VoxelTerrainMaterial.ts
+++ b/src/game/lighting/VoxelTerrainMaterial.ts
@@ -72,6 +72,16 @@ export interface VoxelTerrainMaterialOptions {
texture: Texture;
/** Alpha-test threshold. Use ~0.5 for cutout pass, 0.0 (disabled) for opaque. */
alphaCutOff?: number;
+ /**
+ * Whether to render double-sided (i.e. back-face culling OFF). The CUTOUT
+ * pass (plant crosses) must be double-sided. The OPAQUE pass *could* cull
+ * back faces to halve fragment work, but this mesher's winding does not match
+ * Babylon's front-face convention, so culling currently removes visible faces
+ * — therefore BOTH passes pass `doubleSided: true` (culling disabled). This
+ * option exists so culling can be re-enabled per-pass once the winding is
+ * verified. Default true (culling OFF).
+ */
+ doubleSided?: boolean;
}
/**
@@ -129,9 +139,12 @@ export class VoxelTerrainMaterial {
mat.setFloat("uFogStart", 60);
mat.setFloat("uFogEnd", 220);
mat.setVector3("uCameraPos", new Vector3(0, 0, 0));
- // Match the prior StandardMaterial flags: double-sided, opaque (alpha-test
- // via `discard`, not blending) so the atlas works for both cube and plant faces.
- mat.backFaceCulling = false;
+ // Back-face culling. Culling is currently DISABLED for both terrain passes
+ // (doubleSided defaults to true): this mesher's winding does not match
+ // Babylon's front-face convention, so culling removes visible faces. Pass
+ // `doubleSided: false` (and fix the winding) to re-enable the fragment
+ // savings of discarding cube interiors.
+ mat.backFaceCulling = !(options.doubleSided ?? true);
mat.options.needAlphaBlending = false;
// We compute fog manually (uFogColor/uFogStart/uFogEnd + uCameraPos). Disable
// Babylon's fog pipeline on this material — otherwise it injects its fog
diff --git a/src/game/lighting/WaterMaterial.ts b/src/game/lighting/WaterMaterial.ts
new file mode 100644
index 0000000..8c11f53
--- /dev/null
+++ b/src/game/lighting/WaterMaterial.ts
@@ -0,0 +1,102 @@
+import { Color3, Material, Scene, StandardMaterial, Texture, Vector3 } from "@babylonjs/core";
+import type { WaterQuality } from "../graphics/GraphicsSettings";
+
+/**
+ * Water surface material.
+ *
+ * IMPORTANT HISTORY: this was previously a custom GLSL `ShaderMaterial`. Despite
+ * setting `transparencyMode = MATERIAL_ALPHABLEND` and pushing alpha all the way
+ * to 0.92, the water stayed nearly invisible — a `ShaderMaterial` transparency
+ * edge case (alpha combining against an unbound texture alpha) defeated every
+ * tuning attempt. To make water RELIABLY visible, we render it with a plain
+ * `StandardMaterial`, which is what made water visible originally and which
+ * Babylon handles transparently (pun intended) with zero ambiguity.
+ *
+ * Properties:
+ * - solid blue diffuse + a small emissive floor (readable even in shadow/night)
+ * - subtle specular → a sun glint that reads as "water"
+ * - lit by the scene's Babylon lights (so it dims at night) and fogged by the
+ * scene fog (so it blends with terrain at distance — no more vanishing lakes)
+ * - alpha-blended, double-sided, no depth write
+ * - no atlas texture → no tiling repetition
+ *
+ * Quality tiers only change alpha (lower tiers slightly more opaque = clearer).
+ * The mesh's baked vertex colours are disabled (see World.applyMesh) so they
+ * can't darken the surface — the StandardMaterial supplies a uniform tint.
+ */
+export interface WaterMaterialOptions {
+ /** Unused (kept for API compatibility). Water is a solid colour, not textured. */
+ texture?: Texture;
+}
+
+export class WaterMaterial {
+ readonly material: StandardMaterial;
+ private quality: WaterQuality = "medium";
+ private alpha = 0.78;
+
+ constructor(scene: Scene, _options?: WaterMaterialOptions) {
+ void _options;
+ const mat = new StandardMaterial("voxel-water", scene);
+ mat.diffuseColor = Color3.FromHexString("#1f86d8"); // clear blue
+ mat.emissiveColor = Color3.FromHexString("#0b3a6b"); // subtle blue glow floor
+ mat.specularColor = new Color3(0.35, 0.4, 0.5); // sun glint
+ mat.specularPower = 96;
+ mat.backFaceCulling = false; // see the surface from below
+ mat.disableDepthWrite = true; // don't occlude submerged terrain
+ mat.transparencyMode = Material.MATERIAL_ALPHABLEND;
+ mat.fogEnabled = true; // blend into the scene fog at distance (matches terrain)
+ this.material = mat;
+ this.setQuality(this.quality);
+ }
+
+ setQuality(quality: WaterQuality): void {
+ this.quality = quality;
+ // All tiers stay clearly readable as water. Higher tiers are slightly more
+ // transparent (livelier) but never so much that the surface disappears.
+ this.alpha = quality === "low" ? 0.85 : quality === "high" ? 0.72 : 0.78;
+ this.material.alpha = this.alpha;
+ }
+
+ get currentQuality(): WaterQuality {
+ return this.quality;
+ }
+
+ get currentAlpha(): number {
+ return this.alpha;
+ }
+
+ /**
+ * Debug: force the water fully opaque + flat bright blue, bypassing lighting
+ * and transparency. Use `__voxl.waterOpaque()` to confirm whether a suspect
+ * patch is the water layer (it turns into a solid blue slab) vs terrain.
+ */
+ setDebugOpaque(on: boolean): void {
+ if (on) {
+ this.material.alpha = 1.0;
+ this.material.emissiveColor = Color3.FromHexString("#1f9fe0");
+ this.material.diffuseColor = Color3.FromHexString("#1f9fe0");
+ } else {
+ this.material.diffuseColor = Color3.FromHexString("#1f86d8");
+ this.material.emissiveColor = Color3.FromHexString("#0b3a6b");
+ this.setQuality(this.quality);
+ }
+ }
+
+ // Day/night + fog are handled by the StandardMaterial (scene lights + scene
+ // fog), so these are kept as no-ops for API compatibility. (A setTime/animation
+ // hook is intentionally omitted until water animation is actually implemented.)
+ setDayNight(_dayFactor: number, _moonFloor: number): void {
+ void _dayFactor;
+ void _moonFloor;
+ }
+ setFog(_cameraPosition: Vector3, _color: Color3, _start: number, _end: number): void {
+ void _cameraPosition;
+ void _color;
+ void _start;
+ void _end;
+ }
+
+ dispose(): void {
+ this.material.dispose();
+ }
+}
diff --git a/src/main.ts b/src/main.ts
index 32b0468..3f7261a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -24,6 +24,20 @@ function boot(): void {
// Lighting debug surface (see LightingSystem).
lighting?: () => unknown;
debugInfo?: () => Record;
+ // Render-debug toggles.
+ wireframe?: (on?: boolean) => void;
+ perf?: (on?: boolean) => void;
+ waterStats?: () => unknown;
+ chunkBorders?: (on?: boolean) => void;
+ terrainCulling?: (on?: boolean) => void;
+ renderAllChunks?: (on?: boolean) => void;
+ safeMode?: (on?: boolean) => void;
+ water?: (on?: boolean) => void;
+ waterOpaque?: (on?: boolean) => void;
+ fog?: (on?: boolean) => void;
+ post?: (on?: boolean) => void;
+ shadows?: (on?: boolean) => void;
+ dumpMaterials?: () => void;
}
(window as unknown as { __voxl?: VoxlAutomation }).__voxl = {
beginPlay: () => game.beginPlay(),
@@ -35,6 +49,19 @@ function boot(): void {
loadedChunks: () => game._loadedChunks(),
lighting: () => game._lightingDebug(),
debugInfo: () => game._debugInfo(),
+ wireframe: (on) => game._setWireframe(on ?? true),
+ perf: (on) => game._togglePerf(on),
+ waterStats: () => game._waterStats(),
+ chunkBorders: (on) => game._toggleChunkBorders(on),
+ terrainCulling: (on) => game._setTerrainCulling(on ?? true),
+ renderAllChunks: (on) => game._setRenderAllChunks(on ?? true),
+ safeMode: (on) => game._safeMode(on ?? true),
+ water: (on) => game._setWater(on ?? true),
+ waterOpaque: (on) => game._setWaterOpaque(on ?? true),
+ fog: (on) => game._setFog(on ?? true),
+ post: (on) => game._setPost(on ?? true),
+ shadows: (on) => game._setShadows(on ?? true),
+ dumpMaterials: () => game._dumpMaterials(),
};
}
diff --git a/src/state/Settings.ts b/src/state/Settings.ts
index f226388..e1c763b 100644
--- a/src/state/Settings.ts
+++ b/src/state/Settings.ts
@@ -1,28 +1,42 @@
import type { Settings } from "../types";
import { DEFAULT_SEED } from "../constants";
+import {
+ defaultGraphicsSettings,
+ defaultRenderDistance,
+ migrateGraphics,
+ type GraphicsSettings,
+} from "../game/graphics/GraphicsSettings";
const STORAGE_KEY = "voxl.settings.v1";
export const DEFAULT_SETTINGS: Settings = {
- viewDistance: 6,
+ viewDistance: defaultRenderDistance(),
mouseSensitivity: 1,
fov: 75,
showFps: false,
- clouds: true,
seed: DEFAULT_SEED,
mode: "creative",
+ graphics: defaultGraphicsSettings(),
};
export function loadSettings(): Settings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
- const parsed = JSON.parse(raw) as Partial;
+ // `clouds` was a boolean before graphics settings existed; pull it out of the
+ // parsed blob so it never lands on the Settings object, then fold it into the
+ // new 3-state clouds tier during graphics migration.
+ const { clouds: legacyClouds, graphics: savedGraphics, ...rest } =
+ JSON.parse(raw) as Partial & { clouds?: boolean };
const mode =
- parsed.mode === "survival" || parsed.mode === "creative"
- ? parsed.mode
+ rest.mode === "survival" || rest.mode === "creative"
+ ? rest.mode
: DEFAULT_SETTINGS.mode;
- return { ...DEFAULT_SETTINGS, ...parsed, mode };
+ const graphics = migrateGraphics(savedGraphics);
+ if (legacyClouds !== undefined && savedGraphics?.clouds === undefined) {
+ graphics.clouds = legacyClouds ? "fancy" : "off";
+ }
+ return { ...DEFAULT_SETTINGS, ...rest, mode, graphics };
} catch {
return { ...DEFAULT_SETTINGS };
}
@@ -35,3 +49,5 @@ export function saveSettings(settings: Settings): void {
// ignore storage failures (private mode, etc.)
}
}
+
+export type { GraphicsSettings };
diff --git a/src/types.ts b/src/types.ts
index 2b5614b..8613048 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,14 @@
// Shared type definitions for VOXL.
+import type {
+ GraphicsSettings,
+ GraphicsPreset,
+ ShadowQuality,
+ WaterQuality,
+ FoliageDensity,
+ CloudsQuality,
+} from "./game/graphics/GraphicsSettings";
+
/** Numeric block id. 0 = air. See Blocks.ts for the registry. */
export type BlockId = number;
@@ -33,12 +42,23 @@ export interface Settings {
mouseSensitivity: number; // multiplier
fov: number; // degrees
showFps: boolean;
- clouds: boolean;
seed: string;
/** Survival vs creative game mode (global preference; saved per-world too). */
mode: "survival" | "creative";
+ /** Scalable graphics configuration (presets + individual toggles). */
+ graphics: GraphicsSettings;
}
+// Re-exported here so the rest of the app imports graphics types from one place.
+export type {
+ GraphicsPreset,
+ ShadowQuality,
+ WaterQuality,
+ FoliageDensity,
+ CloudsQuality,
+ GraphicsSettings,
+};
+
/** Result of a voxel DDA raycast. */
export interface RaycastHit {
/** Integer coords of the hit block. */
diff --git a/src/ui/ChunkBorderOverlay.ts b/src/ui/ChunkBorderOverlay.ts
new file mode 100644
index 0000000..118dfbc
--- /dev/null
+++ b/src/ui/ChunkBorderOverlay.ts
@@ -0,0 +1,132 @@
+import { Color3, LinesMesh, Scene, SubMesh, VertexData } from "@babylonjs/core";
+import { MAX_RENDER_DISTANCE } from "../game/graphics/GraphicsController";
+
+/**
+ * Debug overlay that draws the XZ footprint of every loaded chunk as a thin
+ * grid of squares at the player's feet height. Pure diagnostic — toggled from
+ * the console (`__voxl.chunkBorders()`) or a key, and rebuilt only when the
+ * player crosses a chunk boundary so it costs nothing per frame.
+ *
+ * Implementation: one updatable `LinesMesh` is created once with a worst-case
+ * position pool + a static line-list index. Each rebuild just overwrites the
+ * active region of the pool and refreshes the SubMesh range — no per-rebuild
+ * allocations and no dispose/recreate (which would GC-spike while flying).
+ */
+// Worst case loaded-chunk count. World.update() unloads chunks at
+// viewDistance + 2, so the loaded set is the (2*(viewDistance+2)+1)^2 square,
+// not the view-distance square. Size for the unload radius so the overlay can
+// draw borders for every loaded chunk at the max view-distance slider value.
+const MAX_CHUNKS = (2 * (MAX_RENDER_DISTANCE + 2) + 1) * (2 * (MAX_RENDER_DISTANCE + 2) + 1) + 32;
+const SEGMENTS_PER_CHUNK = 4;
+const VERTS_PER_SEGMENT = 2;
+const MAX_VERTS = MAX_CHUNKS * SEGMENTS_PER_CHUNK * VERTS_PER_SEGMENT;
+
+export class ChunkBorderOverlay {
+ private readonly lines: LinesMesh;
+ /** Reused position pool (3 floats/vertex). Writes only the active region. */
+ private readonly positions: Float32Array;
+ private visible = false;
+ private lastPcx = Number.NaN;
+ private lastPcz = Number.NaN;
+
+ constructor(scene: Scene) {
+ this.positions = new Float32Array(MAX_VERTS * 3);
+ // Static line-list index: segment k uses vertices (2k, 2k+1) → indices are
+ // just [0,1,2,3,…]. Built once; the SubMesh range limits what is drawn.
+ const indices = new Uint32Array(MAX_VERTS);
+ for (let i = 0; i < MAX_VERTS; i++) indices[i] = i;
+
+ this.lines = new LinesMesh("chunk-borders", scene);
+ const vd = new VertexData();
+ vd.positions = this.positions;
+ vd.indices = indices;
+ vd.applyToMesh(this.lines, true); // updatable → updateVerticesData later
+
+ this.lines.color = new Color3(0.2, 0.3, 0.5);
+ this.lines.alpha = 0.5;
+ this.lines.isPickable = false;
+ this.lines.applyFog = false;
+ this.lines.receiveShadows = false;
+ this.lines.alwaysSelectAsActiveMesh = true; // never frustum-cull the overlay
+ this.lines.isVisible = false;
+ // Start with an empty draw range.
+ this.setDrawRange(0);
+ }
+
+ setVisible(visible: boolean): void {
+ this.visible = visible;
+ this.lines.isVisible = visible;
+ // Force a rebuild on re-enable so stale geometry never shows.
+ if (visible) {
+ this.lastPcx = Number.NaN;
+ this.lastPcz = Number.NaN;
+ }
+ }
+
+ toggle(): boolean {
+ this.setVisible(!this.visible);
+ return this.visible;
+ }
+
+ get isOpen(): boolean {
+ return this.visible;
+ }
+
+ /**
+ * Rebuild the grid if the player has crossed into a new chunk since last call.
+ * `forEachChunkCoord` is World.forEachChunkCoord. Cheap to call every frame:
+ * it bails out immediately unless the player's chunk changed.
+ */
+ update(
+ playerX: number,
+ playerZ: number,
+ playerY: number,
+ forEachChunkCoord: (cb: (cx: number, cz: number) => void) => void,
+ ): void {
+ if (!this.visible) return;
+ const pcx = Math.floor(playerX / 16);
+ const pcz = Math.floor(playerZ / 16);
+ if (pcx === this.lastPcx && pcz === this.lastPcz) return;
+ this.lastPcx = pcx;
+ this.lastPcz = pcz;
+
+ const y = playerY;
+ const pos = this.positions;
+ let v = 0; // vertex cursor
+ forEachChunkCoord((cx, cz) => {
+ if (v + SEGMENTS_PER_CHUNK * VERTS_PER_SEGMENT > MAX_VERTS) return; // pool guard
+ const x0 = cx * 16;
+ const z0 = cz * 16;
+ const x1 = x0 + 16;
+ const z1 = z0 + 16;
+ // Four independent segments forming the chunk's XZ outline, at feet height.
+ seg(pos, v, x0, y, z0, x1, y, z0); v += 2;
+ seg(pos, v, x1, y, z0, x1, y, z1); v += 2;
+ seg(pos, v, x1, y, z1, x0, y, z1); v += 2;
+ seg(pos, v, x0, y, z1, x0, y, z0); v += 2;
+ });
+
+ // Push the active region to the GPU (one upload, no realloc) and trim the
+ // SubMesh to exactly the active vertices.
+ this.lines.updateVerticesData("position", this.positions, false);
+ this.setDrawRange(v);
+ }
+
+ /** Set the SubMesh to draw `vertexCount` vertices starting at 0. */
+ private setDrawRange(vertexCount: number): void {
+ const drawVerts = Math.min(MAX_VERTS, vertexCount);
+ this.lines.subMeshes.length = 0;
+ new SubMesh(0, 0, drawVerts, 0, drawVerts, this.lines, undefined, false);
+ }
+
+ dispose(): void {
+ this.lines.dispose();
+ }
+}
+
+/** Write a single 2-vertex line segment into the position pool at vertex `vi`. */
+function seg(pos: Float32Array, vi: number, ax: number, ay: number, az: number, bx: number, by: number, bz: number): void {
+ const o = vi * 3;
+ pos[o] = ax; pos[o + 1] = ay; pos[o + 2] = az;
+ pos[o + 3] = bx; pos[o + 4] = by; pos[o + 5] = bz;
+}
diff --git a/src/ui/Menus.ts b/src/ui/Menus.ts
index ceef4a9..17b0208 100644
--- a/src/ui/Menus.ts
+++ b/src/ui/Menus.ts
@@ -1,8 +1,15 @@
import type { Settings } from "../types";
+import type {
+ CloudsQuality,
+ FoliageDensity,
+ GraphicsPreset,
+ ShadowQuality,
+ WaterQuality,
+} from "../game/graphics/GraphicsSettings";
import { ScreenManager } from "./ScreenManager";
-function $(id: string): HTMLInputElement | HTMLElement {
- return document.getElementById(id) as HTMLInputElement | HTMLElement;
+function $(id: string): HTMLInputElement | HTMLSelectElement | HTMLElement {
+ return document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLElement;
}
/**
@@ -15,6 +22,8 @@ export class Menus {
onResume?: () => void;
onQuit?: () => void;
onSettingsChange?: (patch: Partial) => void;
+ /** Apply a built-in graphics preset (low/medium/high). */
+ onGraphicsPreset?: (preset: GraphicsPreset) => void;
onRegenerate?: (seed: string) => void;
private current: Settings;
@@ -22,13 +31,13 @@ export class Menus {
constructor(screens: ScreenManager, initial: Settings) {
this.screens = screens;
- this.current = { ...initial };
+ this.current = { ...initial, graphics: { ...initial.graphics } };
this.bind();
this.syncInputs();
}
updateCurrent(settings: Settings): void {
- this.current = { ...settings };
+ this.current = { ...settings, graphics: { ...settings.graphics } };
this.syncInputs();
}
@@ -58,7 +67,7 @@ export class Menus {
el.addEventListener("click", () => this.back());
});
- // --- Settings controls ---
+ // --- General settings ---
const vd = $("set-viewdistance") as HTMLInputElement;
const vdOut = $("out-viewdistance");
vd.addEventListener("input", () => {
@@ -83,9 +92,34 @@ export class Menus {
const fps = $("set-fps") as HTMLInputElement;
fps.addEventListener("change", () => this.emit({ showFps: fps.checked }));
- const clouds = $("set-clouds") as HTMLInputElement;
- clouds.addEventListener("change", () => this.emit({ clouds: clouds.checked }));
+ // --- Graphics presets ---
+ document.querySelectorAll("[data-preset]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const preset = btn.dataset.preset as GraphicsPreset;
+ this.onGraphicsPreset?.(preset);
+ });
+ });
+
+ // --- Graphics individual controls ---
+ const rs = $("set-renderscale") as HTMLSelectElement;
+ rs.addEventListener("change", () => this.patchGraphics({ renderScale: Number(rs.value) }));
+
+ const shadows = $("set-shadows") as HTMLSelectElement;
+ shadows.addEventListener("change", () => this.patchGraphics({ shadows: shadows.value as ShadowQuality }));
+
+ const water = $("set-water") as HTMLSelectElement;
+ water.addEventListener("change", () => this.patchGraphics({ water: water.value as WaterQuality }));
+
+ const foliage = $("set-foliage") as HTMLSelectElement;
+ foliage.addEventListener("change", () => this.patchGraphics({ foliage: foliage.value as FoliageDensity }));
+
+ const clouds = $("set-clouds2") as HTMLSelectElement;
+ clouds.addEventListener("change", () => this.patchGraphics({ clouds: clouds.value as CloudsQuality }));
+ const aa = $("set-aa") as HTMLInputElement;
+ aa.addEventListener("change", () => this.patchGraphics({ antiAliasing: aa.checked }));
+
+ // --- Seed / regenerate ---
const seed = $("set-seed") as HTMLInputElement;
$("btn-regen").addEventListener("click", () => {
const value = seed.value.trim() || "voxl";
@@ -93,8 +127,23 @@ export class Menus {
});
}
+ /**
+ * Apply a single graphics field change. Any individual tweak switches the
+ * preset to "custom" (so the preset buttons no longer highlight a built-in).
+ */
+ private patchGraphics(
+ partial: Partial,
+ ): void {
+ const graphics = { ...this.current.graphics, ...partial, preset: "custom" as const };
+ this.emit({ graphics });
+ }
+
private emit(patch: Partial): void {
- this.current = { ...this.current, ...patch };
+ this.current = {
+ ...this.current,
+ ...patch,
+ graphics: patch.graphics ? { ...patch.graphics } : this.current.graphics,
+ };
this.onSettingsChange?.(patch);
}
@@ -110,7 +159,21 @@ export class Menus {
fov.value = String(s.fov);
($("out-fov")).textContent = String(s.fov);
($("set-fps") as HTMLInputElement).checked = s.showFps;
- ($("set-clouds") as HTMLInputElement).checked = s.clouds;
($("set-seed") as HTMLInputElement).value = s.seed;
+
+ // Graphics
+ const g = s.graphics;
+ ($("set-renderscale") as HTMLSelectElement).value = String(g.renderScale);
+ ($("set-shadows") as HTMLSelectElement).value = g.shadows;
+ ($("set-water") as HTMLSelectElement).value = g.water;
+ ($("set-foliage") as HTMLSelectElement).value = g.foliage;
+ ($("set-clouds2") as HTMLSelectElement).value = g.clouds;
+ ($("set-aa") as HTMLInputElement).checked = g.antiAliasing;
+
+ // Highlight the active preset button (or none for "custom").
+ document.querySelectorAll("[data-preset]").forEach((btn) => {
+ const active = btn.dataset.preset === g.preset;
+ btn.classList.toggle("btn-preset-active", active);
+ });
}
}
diff --git a/src/ui/PerfOverlay.ts b/src/ui/PerfOverlay.ts
new file mode 100644
index 0000000..896e654
--- /dev/null
+++ b/src/ui/PerfOverlay.ts
@@ -0,0 +1,189 @@
+// Developer performance overlay. Renders a compact read-out of frame rate,
+// GPU/draw load, chunk streaming state, and the active graphics configuration.
+// Toggle with F3. Pure read-only diagnostics — never part of gameplay UI.
+
+export interface PerfSnapshot {
+ fps: number;
+ frameMs: number;
+ activeMeshes: number;
+ totalMeshes: number;
+ triangles: number;
+ drawEstimate: number;
+ loadedChunks: number;
+ meshedChunks: number;
+ visibleChunks: number;
+ culledChunks: number;
+ meshQueue: number;
+ lightQueue: number;
+ shadowCasters: number;
+ shadowsEnabled: boolean;
+ waterMeshes: number;
+ preset: string;
+ viewDistance: number;
+ renderScale: number;
+ dpr: number;
+ renderWidth: number;
+ renderHeight: number;
+ heapUsedMB: number | null;
+ gpuRenderer: string | null;
+ timeOfDay: number;
+ // Lighting / fog / water state for diagnosing the High-preset look.
+ fogStart: number;
+ fogEnd: number;
+ ambientIntensity: number;
+ sunIntensity: number;
+ dayFactor: number;
+ waterAlpha: number;
+ waterQuality: string;
+ antiAliasing: boolean;
+}
+
+function $(id: string): HTMLElement {
+ return document.getElementById(id) as HTMLElement;
+}
+
+/**
+ * Builds and owns the `#perf` DOM panel. {@link update} rewrites the text only
+ * when called, so the caller controls the update cadence (throttled in Game's
+ * tick to ~10 Hz to avoid layout thrash while still feeling live).
+ */
+export class PerfOverlay {
+ private readonly root: HTMLElement;
+ private readonly fpsEl: HTMLElement;
+ private readonly frameEl: HTMLElement;
+ private readonly drawsEl: HTMLElement;
+ private readonly trisEl: HTMLElement;
+ private readonly meshesEl: HTMLElement;
+ private readonly chunksEl: HTMLElement;
+ private readonly queuesEl: HTMLElement;
+ private readonly shadowsEl: HTMLElement;
+ private readonly configEl: HTMLElement;
+ private readonly lightEl: HTMLElement;
+ private readonly fogEl: HTMLElement;
+ private readonly waterEl: HTMLElement;
+ private readonly memEl: HTMLElement;
+ private visible = false;
+
+ constructor() {
+ // The container element lives in index.html (``); if
+ // it is missing (older build), create it lazily so the overlay still works.
+ let root = document.getElementById("perf");
+ if (!root) {
+ root = document.createElement("div");
+ root.id = "perf";
+ document.getElementById("app")?.appendChild(root);
+ }
+ root.hidden = true;
+ root.innerHTML = "";
+ const header = el("div", "perf-header");
+ header.textContent = "VOXL · perf";
+ const hint = el("span", "perf-hint");
+ hint.textContent = "F3";
+ header.appendChild(hint);
+ root.appendChild(header);
+
+ const grid = el("div", "perf-grid");
+ this.fpsEl = mkline(grid, "fps");
+ this.frameEl = mkline(grid, "frame");
+ this.drawsEl = mkline(grid, "draws");
+ this.trisEl = mkline(grid, "tris");
+ this.meshesEl = mkline(grid, "meshes");
+ this.chunksEl = mkline(grid, "chunks");
+ this.queuesEl = mkline(grid, "queues");
+ this.shadowsEl = mkline(grid, "shadows");
+ this.configEl = mkline(grid, "config");
+ this.lightEl = mkline(grid, "light");
+ this.fogEl = mkline(grid, "fog");
+ this.waterEl = mkline(grid, "water");
+ this.memEl = mkline(grid, "mem");
+ root.appendChild(grid);
+
+ this.root = root;
+ }
+
+ setVisible(visible: boolean): void {
+ this.visible = visible;
+ this.root.hidden = !visible;
+ }
+
+ toggle(): boolean {
+ this.setVisible(!this.visible);
+ return this.visible;
+ }
+
+ get isOpen(): boolean {
+ return this.visible;
+ }
+
+ update(s: PerfSnapshot): void {
+ if (!this.visible) return;
+ setLine(this.fpsEl, `${Math.round(s.fps)}`, fpsColor(s.fps));
+ setLine(this.frameEl, `${s.frameMs.toFixed(1)} ms`, frameColor(s.frameMs));
+ setLine(this.drawsEl, `~${s.drawEstimate}`);
+ setLine(this.trisEl, `${formatK(s.triangles)} tris`);
+ setLine(this.meshesEl, `${s.activeMeshes}/${s.totalMeshes} active`);
+ setLine(this.chunksEl, `${s.loadedChunks} loaded · ${s.meshedChunks} meshed · ${s.visibleChunks} vis · ${s.culledChunks} culled`);
+ setLine(this.queuesEl, `mesh ${s.meshQueue} · light ${s.lightQueue}`);
+ setLine(
+ this.shadowsEl,
+ s.shadowsEnabled ? `on · ${s.shadowCasters} casters` : "off",
+ s.shadowsEnabled ? "var(--accent)" : "var(--ink-dim)",
+ );
+ setLine(
+ this.configEl,
+ `${s.preset} · dist ${s.viewDistance} · scale ${s.renderScale.toFixed(2)} · dpr ${s.dpr} · ${s.renderWidth}×${s.renderHeight}${s.antiAliasing ? " · fxaa" : ""}`,
+ );
+ setLine(
+ this.lightEl,
+ `amb ${s.ambientIntensity.toFixed(2)} · sun ${s.sunIntensity.toFixed(2)} · day ${s.dayFactor.toFixed(2)}`,
+ );
+ setLine(this.fogEl, `start ${Math.round(s.fogStart)} · end ${Math.round(s.fogEnd)}`);
+ setLine(this.waterEl, `${s.waterQuality} · alpha ${s.waterAlpha.toFixed(2)} · ${s.waterMeshes} water meshes`);
+ const tod = `t ${Math.floor(s.timeOfDay * 24).toString().padStart(2, "0")}:${Math.floor(((s.timeOfDay * 24) % 1) * 60).toString().padStart(2, "0")}`;
+ if (s.heapUsedMB !== null) {
+ setLine(this.memEl, `${s.heapUsedMB.toFixed(0)} MB JS · ${tod}${s.gpuRenderer ? " · " + s.gpuRenderer : ""}`);
+ } else {
+ setLine(this.memEl, `${tod}${s.gpuRenderer ? " · " + s.gpuRenderer : ""}`);
+ }
+ }
+}
+
+function el(tag: string, cls?: string): HTMLElement {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ return e;
+}
+
+function mkline(parent: HTMLElement, label: string): HTMLElement {
+ const row = el("div", "perf-line");
+ const lbl = el("span", "perf-label");
+ lbl.textContent = label;
+ const val = el("span", "perf-value");
+ row.appendChild(lbl);
+ row.appendChild(val);
+ parent.appendChild(row);
+ return val;
+}
+
+function setLine(node: HTMLElement, text: string, color?: string): void {
+ node.textContent = text;
+ node.style.color = color ?? "";
+}
+
+function fpsColor(fps: number): string {
+ if (fps >= 55) return "var(--accent)";
+ if (fps >= 40) return "var(--warm)";
+ return "var(--danger)";
+}
+
+function frameColor(ms: number): string {
+ if (ms <= 18) return "var(--accent)";
+ if (ms <= 28) return "var(--warm)";
+ return "var(--danger)";
+}
+
+function formatK(n: number): string {
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
+ return String(n);
+}
diff --git a/src/ui/ui.css b/src/ui/ui.css
index 234f47d..76a51f6 100644
--- a/src/ui/ui.css
+++ b/src/ui/ui.css
@@ -341,6 +341,56 @@ input[type="checkbox"] {
border-color: var(--accent-2);
}
+/* Graphics settings */
+.setting-divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 18px 0 14px;
+ font-family: var(--mono);
+ font-size: 12px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--accent-2);
+}
+.setting-divider::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--panel-border);
+}
+.preset-row {
+ display: flex;
+ gap: 8px;
+}
+.preset-row .btn {
+ flex: 1;
+}
+.btn-preset-active {
+ background: var(--accent);
+ color: #06140c;
+ border-color: var(--accent);
+}
+.btn-preset-active:hover {
+ background: #2fb658;
+ color: #06140c;
+}
+.select-input {
+ flex: 1;
+ background: var(--bg-1);
+ border: 2px solid var(--panel-border);
+ border-radius: 8px;
+ color: var(--ink);
+ font-family: var(--mono);
+ padding: 8px 10px;
+ font-size: 13px;
+ cursor: pointer;
+}
+.select-input:focus {
+ outline: none;
+ border-color: var(--accent-2);
+}
+
/* ----------------------- HUD ----------------------- */
#hud {
position: absolute;
@@ -765,3 +815,64 @@ input[type="checkbox"] {
.slot { width: 36px; height: 36px; }
.slot .swatch { width: 24px; height: 24px; }
}
+
+/* ----------------------- Performance overlay ----------------------- */
+#perf {
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ z-index: 16;
+ min-width: 230px;
+ padding: 9px 11px;
+ background: rgba(8, 12, 28, 0.72);
+ border: 1px solid var(--panel-border);
+ border-radius: 9px;
+ backdrop-filter: blur(4px);
+ font-family: var(--mono);
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: var(--ink);
+ pointer-events: none;
+ user-select: none;
+}
+#perf[hidden] { display: none; }
+.perf-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid rgba(120, 150, 220, 0.18);
+ color: var(--accent-2);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ font-size: 10.5px;
+}
+.perf-hint {
+ color: var(--ink-dim);
+ font-size: 9.5px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+.perf-grid {
+ display: grid;
+ gap: 2px;
+}
+.perf-line {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ white-space: nowrap;
+}
+.perf-label {
+ flex: 0 0 52px;
+ color: var(--ink-dim);
+ text-transform: lowercase;
+}
+.perf-value {
+ flex: 1 1 auto;
+ text-align: right;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+}