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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ <h2>Settings</h2>
<div class="setting">
<label for="set-viewdistance">View distance</label>
<div class="setting-row">
<input type="range" id="set-viewdistance" min="2" max="12" step="1" />
<input type="range" id="set-viewdistance" min="2" max="20" step="1" />
<output id="out-viewdistance">—</output>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/engine/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
7 changes: 4 additions & 3 deletions src/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Vector3,
} from "@babylonjs/core";
import {
CHUNK_SIZE,
PLAYER_HALF_WIDTH,
PLAYER_HEIGHT,
SEA_LEVEL,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/game/World.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/game/graphics/GraphicsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}

Expand Down
14 changes: 9 additions & 5 deletions src/game/graphics/GraphicsSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const GRAPHICS_PRESETS: Record<Exclude<GraphicsPreset, "custom">, Graphic
renderScale: 1.0,
dprCap: 2,
antiAliasing: false,
shadows: "low",
shadows: "off",
water: "medium",
foliage: "medium",
clouds: "fancy",
Expand All @@ -71,7 +71,7 @@ export const GRAPHICS_PRESETS: Record<Exclude<GraphicsPreset, "custom">, Graphic
renderScale: 1.0,
dprCap: 2,
antiAliasing: true,
shadows: "medium",
shadows: "off",
water: "high",
foliage: "high",
clouds: "fancy",
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -129,12 +129,16 @@ export function migrateGraphics(parsed: Partial<GraphicsSettings> | 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,
Expand Down
6 changes: 2 additions & 4 deletions src/game/lighting/WaterMaterial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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). */
Expand Down
7 changes: 3 additions & 4 deletions src/game/liquid/LiquidSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 40 additions & 10 deletions src/game/liquid/LiquidUpdateQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ export class LiquidUpdateQueue {
/** Normal lane (flow propagation + seeding). */
private readonly pending = new Set<string>();
private fifo: QueuedCell[] = [];
private fifoHead = 0;
/** Priority lane (player edits) — drained before the normal lane. */
private readonly priorityPending = new Set<string>();
private priorityFifo: QueuedCell[] = [];
private priorityFifoHead = 0;
/** High-water mark (both lanes) since creation, for the debug overlay. */
peakSize = 0;

Expand Down Expand Up @@ -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;
}

Expand All @@ -104,24 +114,44 @@ 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;
}

/** Empty both lanes (on world unload / sim reset). */
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;
}
}
10 changes: 9 additions & 1 deletion src/state/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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 };
14 changes: 14 additions & 0 deletions tests/liquid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading