diff --git a/index.html b/index.html
index 02f6812..bd3d8f6 100644
--- a/index.html
+++ b/index.html
@@ -67,7 +67,7 @@
Settings
diff --git a/src/engine/Renderer.ts b/src/engine/Renderer.ts
index 339ee60..de2bc97 100644
--- a/src/engine/Renderer.ts
+++ b/src/engine/Renderer.ts
@@ -16,11 +16,11 @@ export class Renderer {
this.engine = new Engine(
this.canvas,
- true, // antialias
+ false, // antialias; FXAA is controlled by graphics settings when requested
{
preserveDrawingBuffer: true, // needed for canvas.toDataURL screenshots
powerPreference: "high-performance",
- stencil: true,
+ stencil: false,
},
false, // adaptToDeviceRatio — we cap manually below
);
diff --git a/src/game/Game.ts b/src/game/Game.ts
index 107dac3..0db131a 100644
--- a/src/game/Game.ts
+++ b/src/game/Game.ts
@@ -11,6 +11,7 @@ import {
Vector3,
} from "@babylonjs/core";
import {
+ CHUNK_SIZE,
PLAYER_HALF_WIDTH,
PLAYER_HEIGHT,
SEA_LEVEL,
@@ -925,13 +926,13 @@ export class Game {
this.scene.fogEnd = 1e6;
return;
}
- const far = this.settings.viewDistance * 16 * 2.0;
+ const far = (this.settings.viewDistance + 1.25) * CHUNK_SIZE;
// 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.settings.graphics.preset === "high" ? 0.78 :
+ this.settings.graphics.preset === "low" ? 0.58 : 0.7;
this.scene.fogEnd = far;
this.scene.fogStart = far * startFrac;
// Tell the underwater renderer the new above-water baseline so its blend
diff --git a/src/game/World.ts b/src/game/World.ts
index 8a630df..0a8ba3c 100644
--- a/src/game/World.ts
+++ b/src/game/World.ts
@@ -194,9 +194,11 @@ export class World {
const cached = this.spiralCache.get(radius);
if (cached) return cached;
const list: Array<{ dx: number; dz: number; d: number }> = [];
+ const radiusSq = radius * radius;
for (let dx = -radius; dx <= radius; dx++) {
for (let dz = -radius; dz <= radius; dz++) {
- list.push({ dx, dz, d: dx * dx + dz * dz });
+ const d = dx * dx + dz * dz;
+ if (d <= radiusSq) list.push({ dx, dz, d });
}
}
list.sort((a, b) => a.d - b.d);
@@ -514,7 +516,7 @@ export class World {
/** Set the foliage (cutout) render-distance tier. Draw-time only. */
setFoliageDensity(density: FoliageDensity): void {
- this.foliageCutoutDistance = density === "low" ? 48 : density === "medium" ? 96 : Infinity;
+ this.foliageCutoutDistance = density === "low" ? 48 : density === "medium" ? 96 : 160;
}
/**
diff --git a/src/game/graphics/GraphicsController.ts b/src/game/graphics/GraphicsController.ts
index bfda87e..aa93fea 100644
--- a/src/game/graphics/GraphicsController.ts
+++ b/src/game/graphics/GraphicsController.ts
@@ -41,7 +41,7 @@ export function shadowConfigForQuality(quality: ShadowQuality): {
}
/** Maximum supported render distance (chunk radius), capped for browser safety. */
-export const MAX_RENDER_DISTANCE = 12;
+export const MAX_RENDER_DISTANCE = 20;
/** Minimum render distance — below this the world feels empty. */
export const MIN_RENDER_DISTANCE = 2;
@@ -133,9 +133,9 @@ export class GraphicsController {
}
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.
+ // AA off: tear down the pipeline so the scene renders straight to the
+ // default framebuffer. Engine-level antialiasing is also disabled at
+ // context creation, so low/custom presets render with no AA.
this.pipeline.dispose();
this.pipeline = null;
}
@@ -179,9 +179,9 @@ export class GraphicsController {
export function presetRenderDistance(preset: GraphicsSettings["preset"]): number {
switch (preset) {
case "low": return 4;
- case "high": return 8;
+ case "high": return 16;
case "medium":
- default: return 6;
+ default: return 10;
}
}
diff --git a/src/game/graphics/GraphicsSettings.ts b/src/game/graphics/GraphicsSettings.ts
index 2625d5a..c52fadf 100644
--- a/src/game/graphics/GraphicsSettings.ts
+++ b/src/game/graphics/GraphicsSettings.ts
@@ -60,7 +60,7 @@ export const GRAPHICS_PRESETS: Record, Graphic
renderScale: 1.0,
dprCap: 2,
antiAliasing: false,
- shadows: "low",
+ shadows: "off",
water: "medium",
foliage: "medium",
clouds: "fancy",
@@ -71,7 +71,7 @@ export const GRAPHICS_PRESETS: Record, Graphic
renderScale: 1.0,
dprCap: 2,
antiAliasing: true,
- shadows: "medium",
+ shadows: "off",
water: "high",
foliage: "high",
clouds: "fancy",
@@ -88,7 +88,7 @@ export function defaultGraphicsSettings(): GraphicsSettings {
/** A sensible default render distance (chunks) matching the device class. */
export function defaultRenderDistance(): number {
- return detectLowEndDevice() ? 4 : 6;
+ return detectLowEndDevice() ? 4 : 10;
}
/**
@@ -129,12 +129,16 @@ export function migrateGraphics(parsed: Partial | undefined):
if (clouds === undefined && typeof legacy.clouds === "boolean") {
clouds = legacy.clouds ? "fancy" : "off";
}
+ const preset = parsed.preset ?? base.preset;
+ const oldPresetShadow =
+ (preset === "medium" && parsed.shadows === "low") ||
+ (preset === "high" && parsed.shadows === "medium");
return {
- preset: parsed.preset ?? base.preset,
+ 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,
+ shadows: oldPresetShadow ? "off" : parsed.shadows ?? base.shadows,
water: parsed.water ?? base.water,
foliage: parsed.foliage ?? base.foliage,
clouds: clouds ?? base.clouds,
diff --git a/src/game/lighting/WaterMaterial.ts b/src/game/lighting/WaterMaterial.ts
index b616931..8e86eee 100644
--- a/src/game/lighting/WaterMaterial.ts
+++ b/src/game/lighting/WaterMaterial.ts
@@ -43,7 +43,7 @@ export class WaterMaterial {
private alpha = 0.82;
/** Base diffuse/emissive colours (the subtle animation oscillates around these). */
private readonly baseDiffuse = Color3.FromHexString("#1f86d8");
- private readonly baseEmissive = Color3.FromHexString("#0b3a6b");
+ private readonly baseEmissive = Color3.Black();
private readonly shimmerColor = Color3.FromHexString("#2aa0e8");
private animTime = 0;
private animationEnabled = true;
@@ -160,9 +160,7 @@ export class WaterMaterial {
m.diffuseColor.r = this.baseDiffuse.r + (this.shimmerColor.r - this.baseDiffuse.r) * amp * w;
m.diffuseColor.g = this.baseDiffuse.g + (this.shimmerColor.g - this.baseDiffuse.g) * amp * w;
m.diffuseColor.b = this.baseDiffuse.b + (this.shimmerColor.b - this.baseDiffuse.b) * amp * w;
- m.emissiveColor.r = this.baseEmissive.r * (1 + amp * 0.5 * w);
- m.emissiveColor.g = this.baseEmissive.g * (1 + amp * 0.5 * w);
- m.emissiveColor.b = this.baseEmissive.b * (1 + amp * 0.5 * w);
+ m.emissiveColor.copyFrom(this.baseEmissive);
}
/** Debug: enable/disable the surface scroll + shimmer (texture stays put). */
diff --git a/src/game/liquid/LiquidSimulator.ts b/src/game/liquid/LiquidSimulator.ts
index b9a5a9e..bc5bd34 100644
--- a/src/game/liquid/LiquidSimulator.ts
+++ b/src/game/liquid/LiquidSimulator.ts
@@ -452,10 +452,9 @@ export class LiquidSimulator {
/**
* True if the liquid cell at (x,y,z) may spread SIDEWARDS — i.e. it is a
- * source, or a flowing cell resting on SOLID terrain. A flowing cell with
- * water/air below is part of a suspended/falling column and must NOT spread
- * sideways (otherwise a waterfall grows into a diverging 3D plume). Only the
- * cell that actually lands on the floor spreads, which keeps flow bounded.
+ * source, or a flowing cell resting on SOLID terrain. This mirrors Luanti's
+ * `LIQUID_FLOW_DOWN_MASK` behavior: source nodes still feed same-level
+ * neighbours, but same-level falling flowing nodes cannot feed sideways.
*/
private isSupported(access: LiquidAccess, x: number, y: number, z: number): boolean {
const id = access.getBlock(x, y, z);
diff --git a/src/game/liquid/LiquidUpdateQueue.ts b/src/game/liquid/LiquidUpdateQueue.ts
index d651ce9..4505cbd 100644
--- a/src/game/liquid/LiquidUpdateQueue.ts
+++ b/src/game/liquid/LiquidUpdateQueue.ts
@@ -25,9 +25,11 @@ export class LiquidUpdateQueue {
/** Normal lane (flow propagation + seeding). */
private readonly pending = new Set();
private fifo: QueuedCell[] = [];
+ private fifoHead = 0;
/** Priority lane (player edits) — drained before the normal lane. */
private readonly priorityPending = new Set();
private priorityFifo: QueuedCell[] = [];
+ private priorityFifoHead = 0;
/** High-water mark (both lanes) since creation, for the debug overlay. */
peakSize = 0;
@@ -84,17 +86,25 @@ export class LiquidUpdateQueue {
*/
dequeue(): QueuedCell | null {
// Priority first.
- while (this.priorityFifo.length > 0) {
- const cell = this.priorityFifo.shift()!;
+ while (this.priorityFifoHead < this.priorityFifo.length) {
+ const cell = this.priorityFifo[this.priorityFifoHead++]!;
const k = this.key(cell.x, cell.y, cell.z);
- if (this.priorityPending.delete(k)) return cell;
+ if (this.priorityPending.delete(k)) {
+ this.compactPriority();
+ return cell;
+ }
}
+ this.compactPriority(true);
// Then the normal backlog.
- while (this.fifo.length > 0) {
- const cell = this.fifo.shift()!;
+ while (this.fifoHead < this.fifo.length) {
+ const cell = this.fifo[this.fifoHead++]!;
const k = this.key(cell.x, cell.y, cell.z);
- if (this.pending.delete(k)) return cell;
+ if (this.pending.delete(k)) {
+ this.compactNormal();
+ return cell;
+ }
}
+ this.compactNormal(true);
return null;
}
@@ -104,11 +114,15 @@ export class LiquidUpdateQueue {
* scheduled tick). Returns null when the priority lane is empty.
*/
pullPriority(): QueuedCell | null {
- while (this.priorityFifo.length > 0) {
- const cell = this.priorityFifo.shift()!;
+ while (this.priorityFifoHead < this.priorityFifo.length) {
+ const cell = this.priorityFifo[this.priorityFifoHead++]!;
const k = this.key(cell.x, cell.y, cell.z);
- if (this.priorityPending.delete(k)) return cell;
+ if (this.priorityPending.delete(k)) {
+ this.compactPriority();
+ return cell;
+ }
}
+ this.compactPriority(true);
return null;
}
@@ -116,12 +130,28 @@ export class LiquidUpdateQueue {
clear(): void {
this.pending.clear();
this.fifo.length = 0;
+ this.fifoHead = 0;
this.priorityPending.clear();
this.priorityFifo.length = 0;
+ this.priorityFifoHead = 0;
}
/** Debug snapshot of queued positions (read-only; does not mutate). */
snapshot(): readonly QueuedCell[] {
- return this.fifo;
+ return this.fifo.slice(this.fifoHead);
+ }
+
+ private compactNormal(force = false): void {
+ if (this.fifoHead === 0) return;
+ if (!force && this.fifoHead < 1024 && this.fifoHead * 2 < this.fifo.length) return;
+ this.fifo = this.fifo.slice(this.fifoHead);
+ this.fifoHead = 0;
+ }
+
+ private compactPriority(force = false): void {
+ if (this.priorityFifoHead === 0) return;
+ if (!force && this.priorityFifoHead < 1024 && this.priorityFifoHead * 2 < this.priorityFifo.length) return;
+ this.priorityFifo = this.priorityFifo.slice(this.priorityFifoHead);
+ this.priorityFifoHead = 0;
}
}
diff --git a/src/state/Settings.ts b/src/state/Settings.ts
index e1c763b..000127b 100644
--- a/src/state/Settings.ts
+++ b/src/state/Settings.ts
@@ -36,7 +36,8 @@ export function loadSettings(): Settings {
if (legacyClouds !== undefined && savedGraphics?.clouds === undefined) {
graphics.clouds = legacyClouds ? "fancy" : "off";
}
- return { ...DEFAULT_SETTINGS, ...rest, mode, graphics };
+ const viewDistance = migratePresetViewDistance(rest.viewDistance, graphics.preset);
+ return { ...DEFAULT_SETTINGS, ...rest, viewDistance, mode, graphics };
} catch {
return { ...DEFAULT_SETTINGS };
}
@@ -50,4 +51,11 @@ export function saveSettings(settings: Settings): void {
}
}
+function migratePresetViewDistance(viewDistance: number | undefined, preset: GraphicsSettings["preset"]): number {
+ if (viewDistance === undefined) return DEFAULT_SETTINGS.viewDistance;
+ if (preset === "medium" && (viewDistance === 6 || viewDistance === 8)) return 10;
+ if (preset === "high" && (viewDistance === 8 || viewDistance === 12)) return 16;
+ return viewDistance;
+}
+
export type { GraphicsSettings };
diff --git a/tests/liquid.test.ts b/tests/liquid.test.ts
index 778f730..be6c96a 100644
--- a/tests/liquid.test.ts
+++ b/tests/liquid.test.ts
@@ -66,10 +66,24 @@ console.log("\n[Test 1] Source above air pours straight down (waterfall), stops
assert(w.getBlock(0, 4, 0) === WATER_FLOWING_BLOCK, "flowing at y=4");
assert(w.getBlock(0, 3, 0) === WATER_FLOWING_BLOCK, "flowing at y=3");
assert(w.getLevel(0, 4, 0) === 7 && w.getLevel(0, 1, 0) === 7, "falling column is full level (7)");
+ assert(w.getBlock(1, 5, 0) === WATER_FLOWING_BLOCK && w.getBlock(-1, 5, 0) === WATER_FLOWING_BLOCK, "source feeds same-level neighbours like Luanti");
assert(w.getBlock(0, 0, 0) === 3, "floor intact");
assert(sim.queueSize === 0, "queue drained (no endless loop)");
}
+console.log("\n[Test 1b] Falling flowing water does not feed sideways until it lands.");
+{
+ const w = new FakeWorld();
+ w.floor(-14, 14, -14, 14, 0);
+ w.set(0, 5, 0, WATER_FLOWING_BLOCK, 7);
+ w.set(0, 4, 0, WATER_FLOWING_BLOCK, 7);
+ const sim = new LiquidSimulator();
+ sim.enqueue(1, 4, 0);
+ settle(sim, w);
+ assert(w.getBlock(1, 4, 0) === AIR_BLOCK, "same-level falling flowing node does not feed sideways");
+ assert(sim.queueSize === 0, "queue drained");
+}
+
console.log("\n[Test 2] Source on a solid floor spreads horizontally and stops at range.");
{
const w = new FakeWorld();