From 9722b483c663a37204b1443c222558d717bdeb5a Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 13 Jun 2026 22:18:38 +0100 Subject: [PATCH 1/4] Add voxel lighting and shadow system (Minetest/Luanti-style) Implements a full voxel-aware lighting model independent of Babylon's real-time lights, plus a directional shadow layer on top. Voxel light engine (src/game/lighting/): - Two per-voxel channels (sun + block light), 0..15, stored per chunk in typed arrays (LightMap), modelled on Luanti's param1 day/night nibbles. - SunLightPropagator: top-down sky exposure + BFS flood fill. Sunlight streams straight down through air/sunlight-passing blocks with no decay and attenuates sideways/up; water and leaves break the column so depth and canopies dim naturally. Cross-chunk boundary inflow keeps chunk seams correct. - BlockLightPropagator: emitter BFS that decays 1/step through light-passing cells (opaque blocks are walls). Glowstone (id 28) emits 15 to demonstrate it in caves. - VoxelLightEngine owns the light maps, implements read-only boundary access, and recomputes a chunk from scratch (with a border-change diff that queues neighbours). Reusable scratch buffers = zero alloc. Block registry: - New BlockLightDefinition (lightPassesThrough, sunlightPassesThrough, lightEmission, lightAbsorption, casts/receivesShadows) with a cached resolveLight() so behaviour is data-driven, never hardcoded. - Leaves/water/jungle-leaves carry light; glowstone emits; new glowstone tile 33 + hotbar slot. Mesh integration: - ChunkMesher takes a BrightnessSampler callback and bakes face-shade * lightToBrightness(combined) into vertex colours per face (light read from the neighbour cell the face looks into). Caves go dark, outdoors stay bright, faces get directional shading. - Sun baked at full day; day/night dimming is global (no per-frame remesh). Day/night + shadows: - DayNightCycle drives the Babylon sun/ambient/hemi intensities and sky/fog colours from a time-of-day value (pause/scrub/snap controls). - ShadowManager wraps a directional ShadowGenerator limited to nearby opaque chunk meshes. Fixed frustum centred on the player with an explicit depth range, frustumEdgeFalloff fade, and a caster margin so the frustum boundary never shows as a hard rectangle. Performance: lighting only runs on change via a budgeted dirty-queue (3 chunks/frame); neighbours re-light only when a border value changes and converge in a few frames. Debug: overlay panel (L), raw sun/block/combined visual modes (K), shadow render-list + frustum diagnostics via __voxl.lighting(), and an H toggle to disable Babylon shadows while keeping voxel light. --- src/engine/Sky.ts | 3 + src/engine/Textures.ts | 13 ++ src/game/Blocks.ts | 109 +++++++++++- src/game/ChunkMesher.ts | 41 +++-- src/game/Game.ts | 103 ++++++++++++ src/game/World.ts | 154 ++++++++++++++++- src/game/lighting/BlockLightPropagator.ts | 135 +++++++++++++++ src/game/lighting/DayNightCycle.ts | 126 ++++++++++++++ src/game/lighting/LightMap.ts | 80 +++++++++ src/game/lighting/LightingConfig.ts | 139 ++++++++++++++++ src/game/lighting/LightingDebugOverlay.ts | 146 ++++++++++++++++ src/game/lighting/LightingSystem.ts | 130 +++++++++++++++ src/game/lighting/ShadowManager.ts | 188 +++++++++++++++++++++ src/game/lighting/SunLightPropagator.ts | 192 ++++++++++++++++++++++ src/game/lighting/VoxelLightEngine.ts | 151 +++++++++++++++++ src/main.ts | 3 + 16 files changed, 1694 insertions(+), 19 deletions(-) create mode 100644 src/game/lighting/BlockLightPropagator.ts create mode 100644 src/game/lighting/DayNightCycle.ts create mode 100644 src/game/lighting/LightMap.ts create mode 100644 src/game/lighting/LightingConfig.ts create mode 100644 src/game/lighting/LightingDebugOverlay.ts create mode 100644 src/game/lighting/LightingSystem.ts create mode 100644 src/game/lighting/ShadowManager.ts create mode 100644 src/game/lighting/SunLightPropagator.ts create mode 100644 src/game/lighting/VoxelLightEngine.ts diff --git a/src/engine/Sky.ts b/src/engine/Sky.ts index b60ac5d..8812345 100644 --- a/src/engine/Sky.ts +++ b/src/engine/Sky.ts @@ -85,6 +85,7 @@ export class Sky { this.dome.material = domeMat; this.dome.infiniteDistance = true; // follow the camera automatically this.dome.applyFog = false; + this.dome.receiveShadows = false; // sky never receives/casts shadows this.dome.parent = this.root; // Skybox-ish: render before everything else, ignore fog. this.dome.renderingGroupId = 0; @@ -121,12 +122,14 @@ export class Sky { this.sunQuad.material = sunMat; this.sunQuad.billboardMode = Mesh.BILLBOARDMODE_ALL; this.sunQuad.applyFog = false; + this.sunQuad.receiveShadows = false; // sun billboard never receives/casts shadows this.sunQuad.alwaysSelectAsActiveMesh = true; this.sunQuad.parent = this.root; // --- Clouds (Minetest/Luanti-style voxel layer) --- this.clouds = new Clouds(seed, scene); this.clouds.mesh.parent = this.root; + this.clouds.mesh.receiveShadows = false; // clouds never receive/casts shadows } setCloudsEnabled(enabled: boolean): void { diff --git a/src/engine/Textures.ts b/src/engine/Textures.ts index 9e36180..76decf0 100644 --- a/src/engine/Textures.ts +++ b/src/engine/Textures.ts @@ -395,6 +395,19 @@ export function createTextureAtlas(scene: Scene): AtlasResult { ctx.fillRect(ox + Math.floor(rand() * (TILE_PX - 2)), oy + Math.floor(rand() * (TILE_PX - 2)), 2, 2); } } + // 33: glowstone (warm, glowing emissive block for testing block light) + { + const [ox, oy] = off(33); + paintSpeckled(ctx, ox, oy, [244, 217, 122], 30, 120, rand); + ctx.fillStyle = "rgb(255,244,190)"; + for (let i = 0; i < 8; i++) { + ctx.fillRect(ox + Math.floor(rand() * TILE_PX), oy + Math.floor(rand() * TILE_PX), 1, 1); + } + ctx.fillStyle = "rgb(180,150,60)"; + for (let i = 0; i < 5; i++) { + ctx.fillRect(ox + Math.floor(rand() * TILE_PX), oy + Math.floor(rand() * TILE_PX), 1, 1); + } + } // Upload the painted canvas to the GPU. texture.update(false); diff --git a/src/game/Blocks.ts b/src/game/Blocks.ts index f3e81e6..c17aeca 100644 --- a/src/game/Blocks.ts +++ b/src/game/Blocks.ts @@ -50,8 +50,45 @@ const T = { JUNGLE_GRASS_SIDE: 30, JUNGLE_LEAVES: 31, MOSSY_STONE: 32, + GLOWSTONE: 33, } as const; +/** + * Lighting behaviour for a block (Minetest/Luanti-style). These fields drive + * the voxel light engine (see src/game/lighting/) — they do NOT affect Babylon + * real-time lights directly. + * + * Light is stored in two channels per voxel: sunlight (sky light) and block + * light (emitted light). Both range 0..MAX_LIGHT (15). + */ +export interface BlockLightDefinition { + /** + * Whether sunlight may pass straight down through this block without being + * broken (Minetest `sunlight_propagates`). Air/glass/water = true; leaves can + * be true so canopies stay bright on top while still attenuating sideways. + * Default: true for air and non-opaque blocks, false for opaque blocks. + */ + sunlightPassesThrough?: boolean; + /** + * Whether ANY light (sun or block) spreads INTO this block (i.e. the block is + * part of the light-conducting space). Opaque blocks are false. Air/water/ + * leaves/plants = true. Default: !opaque. + */ + lightPassesThrough?: boolean; + /** Light this block emits into the block-light channel (0..15). Default 0. */ + lightEmission?: number; + /** + * Extra light attenuation applied when light spreads through this block, on + * top of the default -1/step decay (Minetest has no direct equivalent; this + * is an extension point for "dense" media like deep water). Default 0. + */ + lightAbsorption?: number; + /** Whether this block should be added to the shadow render list. Default true. */ + castsShadows?: boolean; + /** Whether this block's faces receive Babylon shadow mapping. Default true. */ + receivesShadows?: boolean; +} + export interface BlockDef { id: BlockId; name: string; @@ -69,6 +106,8 @@ export interface BlockDef { liquid: boolean; /** Render shape: cube (default) or plantlike (X-cross of two quads). */ shape?: "plantlike"; + /** Voxel lighting behaviour. Omitted fields resolve to documented defaults. */ + light?: BlockLightDefinition; } function uniform(tile: number): readonly [number, number, number, number, number, number] { @@ -147,6 +186,11 @@ export const BLOCKS: readonly BlockDef[] = [ opaque: true, transparent: false, liquid: false, + // Leaves stay opaque for face culling, but let light spread THROUGH them + // (Minetest: light_propagates). Sunlight does NOT pass straight down + // (sunlightPassesThrough defaults to !opaque = false), so a thick canopy + // dims the ground below while still letting scattered light bleed in. + light: { lightPassesThrough: true }, }, { id: 7, @@ -157,6 +201,9 @@ export const BLOCKS: readonly BlockDef[] = [ opaque: false, transparent: true, liquid: true, + // Light spreads through water but sunlight does not pass unattenuated, so + // light decays with depth (deep water is dark). + light: { lightPassesThrough: true, sunlightPassesThrough: false }, }, { id: 8, @@ -351,6 +398,7 @@ export const BLOCKS: readonly BlockDef[] = [ opaque: true, transparent: false, liquid: false, + light: { lightPassesThrough: true }, }, { id: 27, @@ -362,6 +410,19 @@ export const BLOCKS: readonly BlockDef[] = [ transparent: false, liquid: false, }, + { + id: 28, + name: "Glowstone", + tiles: uniform(T.GLOWSTONE), + color: "#f4d97a", + solid: true, + opaque: true, + transparent: false, + liquid: false, + // Debug/test emissive block. Emits maximum block light so the block-light + // propagator can be observed (e.g. a lit radius inside a dark cave). + light: { lightEmission: 15 }, + }, ]; export const AIR_BLOCK = 0; @@ -375,4 +436,50 @@ export function getBlock(id: BlockId): BlockDef { } /** Blocks available in the hotbar (creative palette). */ -export const HOTBAR_BLOCKS: readonly BlockId[] = [1, 2, 3, 4, 5, 6, 7, 9, 19]; +export const HOTBAR_BLOCKS: readonly BlockId[] = [1, 2, 3, 4, 5, 6, 7, 9, 19, 28]; + +// --------------------------------------------------------------------------- +// Lighting accessors (defaults resolved here so the engine never hardcodes +// behaviour per block id). See BlockLightDefinition for the semantics. +// --------------------------------------------------------------------------- + +const MAX_LIGHT = 15; + +/** Resolved (no-undefined) light definition for a block. */ +export interface ResolvedLight { + /** Sunlight may travel straight down through this block unbroken. */ + sunlightPassesThrough: boolean; + /** Any light may spread into/through this block. */ + lightPassesThrough: boolean; + /** Emitted block-light level (0..MAX_LIGHT). */ + lightEmission: number; + /** Extra decay added per spread step through this block. */ + lightAbsorption: number; + castsShadows: boolean; + receivesShadows: boolean; +} + +const cache = new Map(); + +/** Resolve a block's lighting definition with defaults applied. */ +export function resolveLight(def: BlockDef): ResolvedLight { + const hit = cache.get(def.id); + if (hit) return hit; + const l: BlockLightDefinition = def.light ?? {}; + const resolved: ResolvedLight = { + sunlightPassesThrough: l.sunlightPassesThrough ?? !def.opaque, + lightPassesThrough: l.lightPassesThrough ?? !def.opaque, + lightEmission: clampLight(l.lightEmission ?? 0), + lightAbsorption: Math.max(0, l.lightAbsorption ?? 0), + castsShadows: l.castsShadows ?? true, + receivesShadows: l.receivesShadows ?? true, + }; + cache.set(def.id, resolved); + return resolved; +} + +export function clampLight(v: number): number { + return v < 0 ? 0 : v > MAX_LIGHT ? MAX_LIGHT : v; +} + +export { MAX_LIGHT }; diff --git a/src/game/ChunkMesher.ts b/src/game/ChunkMesher.ts index 56a74ee..d5e82bc 100644 --- a/src/game/ChunkMesher.ts +++ b/src/game/ChunkMesher.ts @@ -4,6 +4,16 @@ import type { BlockId, FaceDef } from "../types"; import { getBlock } from "./Blocks"; import { tileUV } from "../engine/Textures"; import type { Chunk } from "./Chunk"; +import { FACE_SHADE, PLANT_SHADE } from "./lighting/LightingConfig"; + +/** + * Samples the final per-vertex brightness (0..1) for the cell a face looks into. + * The world/lighting system builds this callback; the mesher never hardcodes + * light behaviour — it only supplies the directional face shade. This lets the + * lighting system switch between normal rendering and debug overlays (raw sun / + * block light) without the mesher knowing. + */ +export type BrightnessSampler = (wx: number, wy: number, wz: number, shade: number) => number; // The six cube faces. Corner order + UVs are tuned so that triangles // (0,1,2, 2,1,3) produce correctly-wound front faces. Order matches the @@ -103,9 +113,10 @@ const CORNER_UV: readonly (readonly [number, number])[] = [ [1, 1], ]; -// Baked directional brightness per face to fake directional lighting. This is -// robust (no mapping risk) and makes the world read clearly in screenshots. -const FACE_BRIGHTNESS = [0.72, 0.72, 1.0, 0.5, 0.86, 0.86]; +// Baked directional brightness per face index (matches FACE order +// [PX, NX, PY, NY, PZ, NZ]). Re-exported from LightingConfig so all light +// tunables live in one place. +const FACE_BRIGHTNESS = FACE_SHADE; /** Returns true if a face between `self` and `neighbor` should be rendered. */ function shouldRenderFace(self: BlockId, neighbor: BlockId): boolean { @@ -167,13 +178,12 @@ function pushFace( // Two diagonal quads forming an "X" — the classic plantlike cross used for // grass tufts, flowers and mushrooms. Rendered in the cutout pass. -const CROSS_BRIGHTNESS = 0.92; -function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: number): void { +function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: number, brightness: number): void { const uv = tileUV(tile); const du = uv.u1 - uv.u0; const dv = uv.v1 - uv.v0; const base = b.vertexCount; - const br = CROSS_BRIGHTNESS; + const br = brightness; // Quad A: diagonal plane through (0,0,0)-(1,1,1). V is swapped vs. the // positions so that Y=0 (bottom) samples V=v1 (canvas-bottom of the tile // = the stem) and Y=1 (top) samples V=v0 (canvas-top = petals/leaves). @@ -218,11 +228,14 @@ function toVertexData(b: BufferBuilder): VertexData | null { /** * Build opaque + transparent geometry for a chunk. `getBlockWorld` returns the * block id at world coordinates (0 = air for unloaded/out-of-range-above, - * opaque for below the world floor). + * opaque for below the world floor). `sampleBrightness` returns the final + * vertex brightness (0..1) for the cell a face looks into, given the face's + * directional shade — it encodes voxel light + day/night + debug mode. */ export function buildChunkGeometry( chunk: Chunk, getBlockWorld: (x: number, y: number, z: number) => BlockId, + sampleBrightness: BrightnessSampler, ): MeshResult { const opaque = newBuilder(); const cutout = newBuilder(); @@ -240,8 +253,10 @@ export function buildChunkGeometry( const wy = y; const wz = oz + z; // Plantlike decorations render as an X-cross in the cutout pass. + // They read the light of their own cell. if (def.shape === "plantlike") { - pushCross(cutout, wx, wy, wz, def.tiles[2]); + const br = sampleBrightness(wx, wy, wz, PLANT_SHADE); + pushCross(cutout, wx, wy, wz, def.tiles[2], br); continue; } // Only water (liquids) uses the transparent pass/material. Leaves are @@ -249,10 +264,16 @@ export function buildChunkGeometry( const builder = def.liquid ? transparent : opaque; for (let f = 0; f < 6; f++) { const n = FACES[f].neighbor; - const neighborId = getBlockWorld(wx + n[0], wy + n[1], wz + n[2]); + const nwx = wx + n[0]; + const nwy = wy + n[1]; + const nwz = wz + n[2]; + const neighborId = getBlockWorld(nwx, nwy, nwz); if (!shouldRenderFace(id, neighborId)) continue; + // Face brightness comes from the light of the cell the face is + // exposed to (the neighbour air/space), combined with face shade. + const br = sampleBrightness(nwx, nwy, nwz, FACE_BRIGHTNESS[f]); const isWaterTop = def.liquid && n[1] === 1; - pushFace(builder, f, wx, wy, wz, def.tiles[f], FACE_BRIGHTNESS[f], isWaterTop); + pushFace(builder, f, wx, wy, wz, def.tiles[f], br, isWaterTop); } } } diff --git a/src/game/Game.ts b/src/game/Game.ts index 9d504ab..e158c92 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -25,6 +25,7 @@ import { ScreenManager } from "../ui/ScreenManager"; import { HUD } from "../ui/HUD"; import { Menus } from "../ui/Menus"; import { loadSettings, saveSettings } from "../state/Settings"; +import { LightingSystem } from "./lighting/LightingSystem"; const SPAWN_PREGEN_RADIUS = 2; @@ -43,6 +44,7 @@ export class Game { private readonly sky: Sky; private readonly atlas: DynamicTexture; private world: World | null = null; + private lighting: LightingSystem | null = null; private readonly player: Player; private readonly input: Input; private readonly screens: ScreenManager; @@ -130,6 +132,7 @@ export class Game { if (code === "KeyP") void this.takeScreenshot(); if (code === "KeyF") this.selectSlot(this.selectedIndex + 1); if (code === "Escape" && this.state === "playing") this.pause(); + this.handleLightingDebugKey(code); }; } @@ -202,6 +205,10 @@ export class Game { } private createWorld(seed: string): void { + if (this.lighting) { + this.lighting.dispose(); + this.lighting = null; + } if (this.world) { this.world.dispose(); this.world = null; @@ -209,6 +216,14 @@ export class Game { this.world = new World(seed, this.atlas, this.scene); // Re-seed the cloud field so it matches the new world. 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.sun, + this.sky.ambient, + this.sky.hemi, + this.scene, + ); } private setPlaying(): void { @@ -270,6 +285,12 @@ export class Game { this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); } + // Advance day/night + shadow follow even while paused (cheap, and lets you + // inspect frozen time). Skip when there's no world (main menu). + if (this.lighting && this.world) { + this.lighting.update(dt, this.player.position.x, this.player.position.y, this.player.position.z); + } + this.sky.update(dt, this.player.camera.position); this.scene.render(); @@ -301,6 +322,15 @@ export class Game { const p = this.player.position; this.hud.setCoords(p.x, p.y, p.z); this.hud.setMode(this.player.flying ? "Flying" : this.player.inWater ? "Swimming" : "Walking"); + // Lighting debug overlay (throttled). Uses the current block target so + // you can read the exact sun/block light of the block you're aiming at. + if (this.lighting) { + const target = this.player.getTarget(); + const info = this.lighting.buildDebugInfo( + target ? { x: target.x, y: target.y, z: target.z, block: target.block } : null, + ); + this.lighting.overlay.update(info); + } } } @@ -351,6 +381,58 @@ export class Game { this.hud.setSelected(this.selectedIndex); } + // ------------------------------------------------------- lighting --- + + /** + * Debug hotkeys for the lighting system. Active only while a world is loaded. + * L toggle the lighting debug overlay + * K cycle light visual mode (off → sun → block → combined) + * T freeze / unfreeze the day-night clock + * H toggle Babylon real-time shadows (voxel light stays on) + * [ and ] scrub time backward / forward + * ; and ' snap to midnight / midday + */ + private handleLightingDebugKey(code: string): void { + if (!this.lighting) return; + const dn = this.lighting.dayNight; + switch (code) { + case "KeyL": + this.lighting.overlay.toggle(); + break; + case "KeyK": { + const mode = this.lighting.cycleDebugMode(); + this.hud.showToast(`Light view: ${mode}`); + break; + } + case "KeyT": { + dn.setPaused(!dn.paused); + this.hud.showToast(dn.paused ? "Time frozen" : "Time running"); + break; + } + case "KeyH": { + const on = this.lighting.toggleShadows(); + this.hud.showToast(on ? "Shadows: on" : "Shadows: off (voxel light only)"); + break; + } + case "BracketLeft": + dn.setTime(dn.timeOfDay - 0.02); + break; + case "BracketRight": + dn.setTime(dn.timeOfDay + 0.02); + break; + case "Semicolon": + dn.setTimeMidnight(); + this.hud.showToast("Time: midnight"); + break; + case "Quote": + dn.setTimeMidday(); + this.hud.showToast("Time: midday"); + break; + default: + break; + } + } + // --------------------------------------------------------- screens --- private updateFog(): void { @@ -387,6 +469,7 @@ export class Game { this.running = false; window.removeEventListener("resize", this.handleResize); this.input.dispose(); + this.lighting?.dispose(); this.world?.dispose(); this.sky.dispose(); this.atlas.dispose(); @@ -422,6 +505,25 @@ export class Game { return [...chunks.keys()]; } + /** + * Lighting debug surface for the devtools console (`__voxl.lighting()`). + * Prints and returns a snapshot of the lighting system's state plus the block + * currently under the crosshair, and dumps the full shadow render list + + * frustum bounds (every caster mesh: name, position, bounds, visibility). + */ + _lightingDebug(): unknown { + if (!this.lighting) return { error: "no world" }; + const target = this.player.getTarget(); + const info = this.lighting.buildDebugInfo( + target ? { x: target.x, y: target.y, z: target.z, block: target.block } : null, + ); + // eslint-disable-next-line no-console + console.log("[lighting]", info); + // Dump the shadow render list + frustum for diagnosing shadow artifacts. + this.lighting.dumpShadowDiagnostics(); + return info; + } + /** TEMP debug: replace all chunk materials with a flat unlit colour so the * world can be inspected without atlas/texture noise. Toggle via * `__voxl.debugFlat()` from the devtools console. */ @@ -466,5 +568,6 @@ function makeHighlight(scene: Scene): LinesMesh { lines.isPickable = false; lines.alwaysSelectAsActiveMesh = true; lines.applyFog = false; + lines.receiveShadows = false; // selection outline never receives/casts shadows return lines; } diff --git a/src/game/World.ts b/src/game/World.ts index c5e6234..a3ef5fc 100644 --- a/src/game/World.ts +++ b/src/game/World.ts @@ -11,8 +11,16 @@ import { import { CHUNK_SIZE, CHUNK_HEIGHT, MAX_CHUNK_GEN_PER_FRAME, MAX_CHUNK_MESH_PER_FRAME } from "../constants"; import type { BlockId } from "../types"; import { Chunk } from "./Chunk"; -import { buildChunkGeometry } from "./ChunkMesher"; +import { buildChunkGeometry, type BrightnessSampler } from "./ChunkMesher"; import { TerrainGenerator, findGroundY } from "./TerrainGenerator"; +import { VoxelLightEngine, lightKey } from "./lighting/VoxelLightEngine"; +import { + LIGHT_MAX, + MAX_CHUNK_LIGHT_PER_FRAME, + combineLight, + lightToBrightness, + type LightDebugMode, +} from "./lighting/LightingConfig"; function key(cx: number, cz: number): string { return `${cx},${cz}`; @@ -43,6 +51,13 @@ export class World { private readonly meshes = new Map(); private spiralCache = new Map>(); + /** Voxel light field (sun + block light per chunk). Drives mesh brightness. */ + readonly lighting = new VoxelLightEngine((x, y, z) => this.getBlock(x, y, z)); + /** Chunks whose lighting must be recomputed before they (re)mesh. */ + private readonly lightDirty = new Set(); + /** Active light debug overlay (changes the mesh brightness sampler). */ + private lightDebugMode: LightDebugMode = "off"; + constructor(seed: string, atlas: Texture, scene: Scene) { this.scene = scene; this.root = new TransformNode("world-root", scene); @@ -127,13 +142,17 @@ export class World { const lz = wz - cz * CHUNK_SIZE; const changed = chunk.setLocal(lx, wy, lz, id); if (!changed) return false; - // Remesh this chunk, plus neighbors if the edit was on a border (their - // border faces may need to appear/disappear). + // Re-light the edited chunk (and queue neighbours — a changed cell can + // alter light several blocks away). Relighting marks the chunk dirty if any + // light value changed, which triggers a remesh below. + this.relightChunkNow(chunk, true); + // Remesh this chunk, plus neighbours if the edit was on a border (their + // border faces / lighting may need to update). chunk.dirty = true; - if (lx === 0) this.markDirty(cx - 1, cz); - if (lx === CHUNK_SIZE - 1) this.markDirty(cx + 1, cz); - if (lz === 0) this.markDirty(cx, cz - 1); - if (lz === CHUNK_SIZE - 1) this.markDirty(cx, cz + 1); + if (lx === 0) this.queueNeighbourLight(cx - 1, cz); + if (lx === CHUNK_SIZE - 1) this.queueNeighbourLight(cx + 1, cz); + if (lz === 0) this.queueNeighbourLight(cx, cz - 1); + if (lz === CHUNK_SIZE - 1) this.queueNeighbourLight(cx, cz + 1); this.rebuildMesh(chunk); return true; } @@ -143,6 +162,92 @@ export class World { if (chunk && chunk.generated) chunk.dirty = true; } + // --------------------------------------------------------- lighting --- + + /** + * Per-vertex brightness sampler handed to the mesher. Combines the sun + block + * light of the sampled cell with the face's directional shade. In a debug + * overlay mode it returns a raw channel value (grayscale) instead. + * + * Sun light is baked at full day strength (sunFactor = 1); the day/night + * dimming is applied globally via Babylon light intensities so we never have + * to rebuild every mesh as the sun moves. + */ + private readonly sampleBrightness: BrightnessSampler = (wx, wy, wz, shade) => { + const sun = this.lighting.getSun(wx, wy, wz); + const block = this.lighting.getBlockLight(wx, wy, wz); + switch (this.lightDebugMode) { + case "sun": + return sun / LIGHT_MAX; + case "block": + return block / LIGHT_MAX; + case "combined": + return combineLight(sun, block, 1) / LIGHT_MAX; + default: + return shade * lightToBrightness(combineLight(sun, block, 1)); + } + }; + + /** + * Synchronously re-light `chunk` now. When `markNeighboursAlways` is set + * (chunk just generated) the 4 neighbours are queued for re-light so they + * pick up the new boundary light; otherwise neighbours are only queued when a + * border value actually changed. + */ + private relightChunkNow(chunk: Chunk, markNeighboursAlways: boolean): void { + const result = this.lighting.relightChunk(chunk); + if (result.changed) chunk.dirty = true; + if (markNeighboursAlways || result.borderChanged) { + this.queueNeighbourLight(chunk.cx - 1, chunk.cz); + this.queueNeighbourLight(chunk.cx + 1, chunk.cz); + this.queueNeighbourLight(chunk.cx, chunk.cz - 1); + this.queueNeighbourLight(chunk.cx, chunk.cz + 1); + } + } + + /** Queue a chunk (and mark it remesh-dirty) for re-lighting if generated. */ + private queueNeighbourLight(cx: number, cz: number): void { + const chunk = this.chunks.get(key(cx, cz)); + if (chunk && chunk.generated) this.lightDirty.add(key(cx, cz)); + } + + /** + * Drain the light-dirty queue with a per-frame budget. Each re-lit chunk may + * queue its neighbours (only when a border value changed), so light updates + * ripple outward and converge in a few frames rather than in one big stall. + */ + private processLightDirty(budget: number): void { + if (this.lightDirty.size === 0) return; + let remaining = budget; + // Snapshot so we can safely add to the set while iterating. + const batch = [...this.lightDirty]; + this.lightDirty.clear(); + for (const k of batch) { + if (remaining <= 0) { + // Re-queue for next frame (keep closest-first ordering loosely). + this.lightDirty.add(k); + continue; + } + const chunk = this.chunks.get(k); + if (!chunk || !chunk.generated) continue; + this.relightChunkNow(chunk, false); + remaining--; + } + } + + /** Switch the light debug overlay; rebuilds meshes so the change is visible. */ + setLightDebugMode(mode: LightDebugMode): void { + if (this.lightDebugMode === mode) return; + this.lightDebugMode = mode; + for (const chunk of this.chunks.values()) { + if (chunk.generated) chunk.dirty = true; + } + } + + getLightDebugMode(): LightDebugMode { + return this.lightDebugMode; + } + /** 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); @@ -160,6 +265,8 @@ export class World { } if (!chunk.generated) { this.generator.generate(chunk); + // Light the new chunk immediately so spawn-area meshes are correct. + this.relightChunkNow(chunk, true); // Mark already-meshed neighbors dirty so shared borders remesh correctly. this.markDirty(cx - 1, cz); this.markDirty(cx + 1, cz); @@ -191,6 +298,9 @@ export class World { } if (!chunk.generated) { this.generator.generate(chunk); + // Light the freshly generated chunk before it can be meshed, and queue + // neighbours so seams stay correct as chunks stream in. + this.relightChunkNow(chunk, true); this.markDirty(cx - 1, cz); this.markDirty(cx + 1, cz); this.markDirty(cx, cz - 1); @@ -199,6 +309,10 @@ 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); + // Mesh dirty chunks (closest first), respecting the budget. for (const off of order) { if (meshBudget <= 0) break; @@ -220,12 +334,18 @@ export class World { if (ddx * ddx + ddz * ddz > unloadSq) { this.disposeMeshes(k); this.chunks.delete(k); + this.lighting.removeLight(chunk.cx, chunk.cz); + this.lightDirty.delete(k); } } } private rebuildMesh(chunk: Chunk): void { - const result = buildChunkGeometry(chunk, (x, y, z) => this.getBlock(x, y, z)); + const result = buildChunkGeometry( + chunk, + (x, y, z) => this.getBlock(x, y, z), + this.sampleBrightness, + ); const k = key(chunk.cx, chunk.cz); const existing = this.meshes.get(k); @@ -261,11 +381,29 @@ export class World { mesh.material = material; mesh.parent = this.root; mesh.isPickable = false; + // Opaque/cutout terrain receives Babylon shadow mapping; water does not + // (it's alpha-blended + depth-write-disabled, shadows would look wrong). + mesh.receiveShadows = slot !== "transparent"; vd.applyToMesh(mesh, false); entry[slot] = mesh; } } + /** + * Iterate every loaded chunk's opaque mesh + chunk coords. Used by the shadow + * 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) { + 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); + } + } + private disposeMeshes(k: string): void { const entry = this.meshes.get(k); if (!entry) return; diff --git a/src/game/lighting/BlockLightPropagator.ts b/src/game/lighting/BlockLightPropagator.ts new file mode 100644 index 0000000..5cb4d0f --- /dev/null +++ b/src/game/lighting/BlockLightPropagator.ts @@ -0,0 +1,135 @@ +import { CHUNK_SIZE, CHUNK_HEIGHT } from "../../constants"; +import { getBlock, resolveLight } from "../Blocks"; +import type { Chunk } from "../Chunk"; +import type { LightAccess } from "./LightMap"; + +/** + * Block-light (emissive) propagation for a single chunk. Emitters seed their + * own cell at their `lightEmission` level, then light floods outward through + * `lightPassesThrough` cells, decaying by 1 (plus absorption) per step in every + * direction. Opaque blocks are walls (level 0). + * + * Chunk boundaries use the same inflow/outflow scheme as sunlight: border cells + * pull emissive light from neighbour chunks (boundary condition) and the BFS + * pushes light outward within the chunk. A glowstone block placed in a dark + * cave will thus paint a warm, decaying sphere of light across chunk seams. + */ +export class BlockLightPropagator { + private queue: Int32Array; + private qTail = 0; + + constructor() { + this.queue = new Int32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); + } + + propagate(access: LightAccess, chunk: Chunk, block: Uint8Array): void { + block.fill(0); + this.qTail = 0; + + // ---- Seed: emitters ---- + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let z = 0; z < CHUNK_SIZE; z++) { + for (let x = 0; x < CHUNK_SIZE; x++) { + const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; + const id = chunk.blocks[idx]; + if (id === 0) continue; + const emission = resolveLight(getBlock(id)).lightEmission; + if (emission > 0) { + block[idx] = emission; + this.enqueue(idx); + } + } + } + } + + const ox = chunk.originX; + const oz = chunk.originZ; + + // ---- Boundary inflow from neighbour chunks ---- + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let z = 0; z < CHUNK_SIZE; z++) { + this.pullInflow(access, block, chunk, 0, y, z, ox, oz, -1, 0); + this.pullInflow(access, block, chunk, CHUNK_SIZE - 1, y, z, ox, oz, +1, 0); + } + for (let x = 0; x < CHUNK_SIZE; x++) { + this.pullInflow(access, block, chunk, x, y, 0, ox, oz, 0, -1); + this.pullInflow(access, block, chunk, x, y, CHUNK_SIZE - 1, ox, oz, 0, +1); + } + } + + // ---- BFS outward spread (in-chunk only) ---- + let head = 0; + while (head < this.qTail) { + const idx = this.queue[head++]; + const level = block[idx]; + if (level <= 1) continue; + // blockIndex = (y*CHUNK_SIZE + z)*CHUNK_SIZE + x → y-stride = CHUNK_SIZE² + const lx = idx % CHUNK_SIZE; + const lz = (((idx - lx) / CHUNK_SIZE) % CHUNK_SIZE) | 0; + const ly = ((idx - lx - lz * CHUNK_SIZE) / (CHUNK_SIZE * CHUNK_SIZE)) | 0; + if (lx > 0) this.spread(block, chunk, level, -1, 0, 0, lx, ly, lz); + if (lx < CHUNK_SIZE - 1) this.spread(block, chunk, level, +1, 0, 0, lx, ly, lz); + if (lz > 0) this.spread(block, chunk, level, 0, 0, -1, lx, ly, lz); + if (lz < CHUNK_SIZE - 1) this.spread(block, chunk, level, 0, 0, +1, lx, ly, lz); + if (ly > 0) this.spread(block, chunk, level, 0, -1, 0, lx, ly, lz); + if (ly < CHUNK_HEIGHT - 1) this.spread(block, chunk, level, 0, +1, 0, lx, ly, lz); + } + } + + private enqueue(idx: number): void { + if (this.qTail < this.queue.length) this.queue[this.qTail++] = idx; + } + + private spread( + block: Uint8Array, + chunk: Chunk, + level: number, + dx: number, + dy: number, + dz: number, + lx: number, + ly: number, + lz: number, + ): void { + const nx = lx + dx; + const ny = ly + dy; + const nz = lz + dz; + const nbId = chunk.blocks[(ny * CHUNK_SIZE + nz) * CHUNK_SIZE + nx]; + if (nbId !== 0 && !resolveLight(getBlock(nbId)).lightPassesThrough) return; + const absorption = nbId === 0 ? 0 : resolveLight(getBlock(nbId)).lightAbsorption; + const candidate = level - 1 - absorption; + if (candidate <= 0) return; + const nidx = (ny * CHUNK_SIZE + nz) * CHUNK_SIZE + nx; + if (candidate > block[nidx]) { + block[nidx] = candidate; + this.enqueue(nidx); + } + } + + private pullInflow( + access: LightAccess, + block: Uint8Array, + chunk: Chunk, + lx: number, + ly: number, + lz: number, + ox: number, + oz: number, + bx: number, + bz: number, + ): void { + const idx = (ly * CHUNK_SIZE + lz) * CHUNK_SIZE + lx; + const nbId = chunk.blocks[idx]; + if (nbId !== 0 && !resolveLight(getBlock(nbId)).lightPassesThrough) return; + const wx = ox + lx + bx; + const wz = oz + lz + bz; + const nbLevel = access.readBlockLight(wx, ly, wz); + if (nbLevel <= 1) return; + const absorption = nbId === 0 ? 0 : resolveLight(getBlock(nbId)).lightAbsorption; + const candidate = nbLevel - 1 - absorption; + if (candidate > block[idx]) { + block[idx] = candidate; + this.enqueue(idx); + } + } +} diff --git a/src/game/lighting/DayNightCycle.ts b/src/game/lighting/DayNightCycle.ts new file mode 100644 index 0000000..491ac86 --- /dev/null +++ b/src/game/lighting/DayNightCycle.ts @@ -0,0 +1,126 @@ +import { Color3, Color4, DirectionalLight, HemisphericLight, Scene, Vector3 } from "@babylonjs/core"; +import { DAY_LENGTH_SECONDS, TIME_MIDDAY, sunBrightnessAt } from "./LightingConfig"; + +const SKY_DAY = Color3.FromHexString("#bfe3ff"); +const SKY_NIGHT = Color3.FromHexString("#0b1026"); +const SUN_DAY = Color3.FromHexString("#fff4e0"); +const SUN_DUSK = Color3.FromHexString("#ff9a4a"); +const HEMI_DAY = Color3.FromHexString("#bfe3ff"); +const HEMI_GROUND = Color3.FromHexString("#4a6b3a"); + +/** + * Drives the global Babylon lights (sun + ambient + hemisphere) and the sky / + * fog colours from a `timeOfDay` value. This is the *global* day/night layer; + * voxel (cave/overhang) darkness comes from the baked vertex colours and is + * independent of this. + * + * timeOfDay ∈ [0,1): 0.0 midnight · 0.25 sunrise · 0.5 midday · 0.75 sunset + * + * The sun direction is derived from the time so shadows point the right way. + * Time can be paused/frozen for debugging and snapped to day/night. + */ +export class DayNightCycle { + /** Current time of day in [0,1). */ + timeOfDay = TIME_MIDDAY; + /** When true, time does not advance (debugging). */ + paused = false; + /** Multiplier on real-time day length (1 = DAY_LENGTH_SECONDS per full day). */ + timeScale = 1; + + private readonly sun: DirectionalLight; + private readonly ambient: HemisphericLight; + private readonly hemi: HemisphericLight; + private readonly scene: Scene; + /** Cached initial sky/fog colour to restore on dispose. */ + private readonly baseFog: Color3; + + /** Tunable intensities (midday values); night floors are derived from these. + * Kept just above ~1.0 combined so baked vertex colours (the dominant + * brightness control) aren't washed out at midday. */ + sunIntensityDay = 0.5; + ambientIntensityDay = 0.35; + hemiIntensityDay = 0.25; + ambientIntensityNight = 0.1; + + constructor( + sun: DirectionalLight, + ambient: HemisphericLight, + hemi: HemisphericLight, + scene: Scene, + ) { + this.sun = sun; + this.ambient = ambient; + this.hemi = hemi; + this.scene = scene; + this.baseFog = (scene.fogColor ?? SKY_DAY).clone(); + this.apply(); + } + + /** Advance time and refresh light/sky state. */ + update(dt: number): void { + if (!this.paused) { + this.timeOfDay = (this.timeOfDay + (dt * this.timeScale) / DAY_LENGTH_SECONDS) % 1; + if (this.timeOfDay < 0) this.timeOfDay += 1; + } + this.apply(); + } + + /** 0 (full night) .. 1 (full day) brightness factor for the sun channel. */ + get dayFactor(): number { + return sunBrightnessAt(this.timeOfDay); + } + + setTime(t: number): void { + this.timeOfDay = t - Math.floor(t); + this.apply(); + } + + setPaused(paused: boolean): void { + this.paused = paused; + } + + setTimeMidday(): void { + this.setTime(TIME_MIDDAY); + } + + setTimeMidnight(): void { + this.setTime(0); + } + + /** Derived sun direction (pointing from the sun toward the scene). */ + get sunDirection(): Vector3 { + // Midday → steep from above; midnight → low/below horizon. A fixed azimuth + // keeps shadow directions stable and readable. + const a = (this.timeOfDay - TIME_MIDDAY) * Math.PI * 2; + const elev = Math.cos(a); // 1 at midday, -1 at midnight + const dir = new Vector3(-0.5, -Math.max(elev, -0.12), -0.42); + return dir.normalize(); + } + + /** Push the current time into the Babylon lights + sky/fog colours. */ + private apply(): void { + const d = this.dayFactor; // 0..1 + + // Sun: intensity ramps with the day factor; warm/reddish near the horizon. + this.sun.direction = this.sunDirection; + this.sun.intensity = this.sunIntensityDay * d; + const horizonness = 1 - Math.min(1, d * 3); // 1 near horizon, 0 midday + this.sun.diffuse = Color3.Lerp(SUN_DAY, SUN_DUSK, horizonness); + + // Ambient fill: a small night floor so unlit areas aren't pure black. + this.ambient.intensity = this.ambientIntensityNight + (this.ambientIntensityDay - this.ambientIntensityNight) * d; + this.ambient.diffuse = Color3.White(); + this.ambient.groundColor = Color3.White(); + + // Hemisphere sky/ground bounce: fades toward a dim night value. + this.hemi.intensity = 0.04 + this.hemiIntensityDay * d; + this.hemi.diffuse = Color3.Lerp(SKY_NIGHT, HEMI_DAY, d); + this.hemi.groundColor = HEMI_GROUND; + + // Sky clear + fog colour: blue by day, deep navy at night. + const sky = Color3.Lerp(SKY_NIGHT, SKY_DAY, d); + this.scene.clearColor = new Color4(sky.r, sky.g, sky.b, 1); + this.scene.fogColor = sky.clone(); + void this.baseFog; + } +} diff --git a/src/game/lighting/LightMap.ts b/src/game/lighting/LightMap.ts new file mode 100644 index 0000000..b98e2b5 --- /dev/null +++ b/src/game/lighting/LightMap.ts @@ -0,0 +1,80 @@ +import { CHUNK_VOLUME } from "../../constants"; +import { blockIndex } from "../Chunk"; +import { LIGHT_MAX } from "./LightingConfig"; +import type { BlockId } from "../../types"; + +/** + * Read-only view the light propagators use to query neighbour state across + * chunk boundaries. Implemented by {@link VoxelLightEngine}; this indirection + * keeps the propagators decoupled from the world/chunk storage. + */ +export interface LightAccess { + /** Block id at world coordinates (0 = air for unloaded/above-world). */ + getBlockId(wx: number, wy: number, wz: number): BlockId; + /** + * Already-computed SUN light at world coords (boundary condition). + * Above-world → LIGHT_MAX (open sky); below-world → 0; unloaded chunk → + * LIGHT_MAX (safe open-sky assumption, corrected when the neighbour loads). + */ + readSun(wx: number, wy: number, wz: number): number; + /** Already-computed BLOCK light at world coords (0 default). */ + readBlockLight(wx: number, wy: number, wz: number): number; +} + +/** + * Per-chunk voxel light storage. Two channels (sun and block light), each a + * Uint8Array of length CHUNK_VOLUME indexed by `blockIndex(x,y,z)`. This + * mirrors Minetest/Luanti's `param1` (which packs "light with sun" and + * "light without sun" into the upper/lower nibble of one byte) — we keep them + * as separate arrays for simplicity and speed. + * + * `valid` tracks whether propagation has been run for the current block data. + * `dirty` means the light changed since the last mesh bake and the chunk's + * mesh should be rebuilt to pick up new vertex colours. + */ +export class LightMap { + readonly sun: Uint8Array; + readonly block: Uint8Array; + /** Lighting has been computed for the current terrain (not stale). */ + valid = false; + /** Lighting changed since the last mesh rebuild → remesh needed. */ + dirty = false; + + constructor() { + this.sun = new Uint8Array(CHUNK_VOLUME); + this.block = new Uint8Array(CHUNK_VOLUME); + } + + getSun(x: number, y: number, z: number): number { + return this.sun[blockIndex(x, y, z)]; + } + + getBlock(x: number, y: number, z: number): number { + return this.block[blockIndex(x, y, z)]; + } + + setSun(x: number, y: number, z: number, v: number): void { + this.sun[blockIndex(x, y, z)] = v; + } + + setBlock(x: number, y: number, z: number, v: number): void { + this.block[blockIndex(x, y, z)] = v; + } + + /** Reset both channels (used before a fresh relight). */ + clear(): void { + this.sun.fill(0); + this.block.fill(0); + } + + /** Mark lighting freshly computed and the mesh as needing a rebuild. */ + markValid(): void { + this.valid = true; + this.dirty = true; + } +} + +/** Clamp a light level to the engine's [0, LIGHT_MAX] range. */ +export function clampLightLevel(v: number): number { + return v < 0 ? 0 : v > LIGHT_MAX ? LIGHT_MAX : v; +} diff --git a/src/game/lighting/LightingConfig.ts b/src/game/lighting/LightingConfig.ts new file mode 100644 index 0000000..37d4491 --- /dev/null +++ b/src/game/lighting/LightingConfig.ts @@ -0,0 +1,139 @@ +// Central tunables for the voxel lighting system. Keeping every magic number +// here makes day/night, brightness curves and shadow quality adjustable +// without touching propagation logic or the mesher. + +import { MAX_LIGHT } from "../Blocks"; + +/** Maximum light level for both the sun and block-light channels. */ +export const LIGHT_MAX = MAX_LIGHT; + +/** + * Per-face directional shading baked into vertex colours (separate from the + * voxel light value). Top faces are brightest, bottoms darkest, sides in + * between. Matches the Minetest/Minecraft "ambient occlusion by face normal" + * convention and gives cube edges readable definition. + * + * 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; + +/** Brightness multiplier for plantlike (X-cross) decorations. */ +export const PLANT_SHADE = 0.95; + +/** + * Map a raw light level (0..LIGHT_MAX) to a rendered brightness multiplier in + * roughly [MIN_BRIGHTNESS, 1]. Uses a gamma curve so mid values stay legible + * instead of collapsing to near-black (Minetest/Minecraft use the same idea — + * a `light_curve`/gamma so a torch-lit cave isn't pitch dark at level 8). + * + * The floor (MIN_BRIGHTNESS) keeps fully-dark blocks barely visible rather than + * pure black; the Babylon ambient light then lifts them a touch more. + */ +export const MIN_BRIGHTNESS = 0.04; +export const LIGHT_GAMMA = 1.3; + +export function lightToBrightness(level: number): number { + if (level <= 0) return MIN_BRIGHTNESS; + const t = (level > LIGHT_MAX ? LIGHT_MAX : level) / LIGHT_MAX; + return MIN_BRIGHTNESS + (1 - MIN_BRIGHTNESS) * Math.pow(t, LIGHT_GAMMA); +} + +/** + * Combine the sun (day) and block (emissive) channels into a single light + * level for rendering. We take the max: a torch (block light) is as bright as + * its own value regardless of sun, and full sun dominates. The day/night + * factor is applied to the SUN channel only here so torches keep glowing at + * night; the remaining global day/night dimming is applied via Babylon light + * intensities (see DayNightCycle). + */ +export function combineLight( + sun: number, + block: number, + sunFactor = 1, +): number { + const s = sun * sunFactor; + return s >= block ? s : block; +} + +// --- Day / night --- + +/** Length of a full day in real-world seconds (midday→midday). */ +export const DAY_LENGTH_SECONDS = 600; +/** timeOfDay in [0,1): 0 = midnight, 0.25 = sunrise, 0.5 = midday, 0.75 = sunset. */ +export const TIME_MIDDAY = 0.5; +export const TIME_MIDNIGHT = 0; + +/** + * Convert a time-of-day fraction to the brightness multiplier applied to the + * SUN light channel. Roughly a smooth daylight curve: 1 at midday, ~0.04 at + * midnight, with dawn/dusk ramps. Computed from the sun's elevation so it + * matches the visual sun position. + */ +export function sunBrightnessAt(timeOfDay: number): number { + // Sun elevation angle: highest at midday (cos = 1), lowest at midnight (cos = -1). + const angle = (timeOfDay - TIME_MIDDAY) * Math.PI * 2; + const elev = Math.cos(angle); // -1..1 + if (elev <= 0) return 0.04; // below horizon → night floor + // Smooth ramp so twilight isn't a hard cut. + const day = Math.pow(elev, 0.6); + return 0.04 + (1 - 0.04) * day; +} + +// --- Chunk lighting budgets --- + +/** Max chunks relit per frame (lighting propagation pass). */ +export const MAX_CHUNK_LIGHT_PER_FRAME = 3; +/** Max chunks relit by a single block edit before falling back to neighbour recompute. */ +export const EDIT_RELIGHT_RADIUS = 1; + +// --- Debug visualisation --- + +/** + * Light debug overlay modes. When non-"off", chunk meshes are rebuilt with a + * brightness sampler that visualises a raw light channel (grayscale) instead + * of the normal shaded result. Useful for spotting propagation bugs. + */ +export type LightDebugMode = "off" | "sun" | "block" | "combined"; + +// --- Shadows --- + +export interface ShadowConfig { + enabled: boolean; + /** Shadow map size (texels). Higher = crisper, more GPU memory. */ + mapSize: number; + /** + * Half-extent (blocks) of the orthographic shadow frustum centred on the + * player. `shadowFrustumSize` (the Babylon ortho width) = frustum * 2. + * MUST be larger than `casterRadius` so the frustum edge sits in a no-caster + * margin (otherwise caster shadows clip into a hard rectangle). + */ + frustum: number; + /** Only chunk meshes within this radius (blocks) of the player cast shadows. + * Keep < `frustum` − a couple of chunks. */ + casterRadius: number; + /** Use blurred exponential shadow maps (soft) vs hard ESM. */ + blur: boolean; + /** Bias to reduce shadow acne. */ + bias: number; + /** How far shadows fade out before the frustum edge (0 = hard edge = the + * giant-rectangle artifact; ~0.5+ recommended). */ + frustumEdgeFalloff: number; + /** Explicit shadow-camera depth bounds (fixed-frustum path). Tight values + * keep ESM depth precision sane. */ + shadowMinZ: number; + shadowMaxZ: number; +} + +export const DEFAULT_SHADOW_CONFIG: ShadowConfig = { + enabled: true, + mapSize: 2048, + // Frustum half-extent 80 (ortho 160). Caster radius 48 → ~32-block (2-chunk) + // fade margin so caster shadows never touch the frustum edge. + frustum: 80, + casterRadius: 48, + blur: true, + bias: 0.0008, + frustumEdgeFalloff: 0.6, + shadowMinZ: 1, + shadowMaxZ: 300, +}; diff --git a/src/game/lighting/LightingDebugOverlay.ts b/src/game/lighting/LightingDebugOverlay.ts new file mode 100644 index 0000000..6a3a22f --- /dev/null +++ b/src/game/lighting/LightingDebugOverlay.ts @@ -0,0 +1,146 @@ +import { getBlock, resolveLight } from "../Blocks"; + +/** Information the overlay shows each (throttled) update. */ +export interface LightDebugInfo { + enabled: boolean; + timeOfDay: number; + dayFactor: number; + sunIntensity: number; + ambientIntensity: number; + debugMode: string; + shadowsEnabled: boolean; + paused: boolean; + // Targeted block (may be null when nothing is aimed at). + target: { + x: number; + y: number; + z: number; + id: number; + name: string; + sun: number; + block: number; + combined: number; + lightPassesThrough: boolean; + sunlightPassesThrough: boolean; + emission: number; + } | null; + dirtyCount: number; + litCount: number; + loadedCount: number; +} + +/** + * A collapsible DOM panel that surfaces the lighting system's live state for + * debugging: time of day, light intensities, the targeted block's sun/block + * light levels and opacity flags, and how many chunks still need (re)lighting. + * + * Toggled with a hotkey; cheap to update (throttled by the caller). This exists + * because lighting bugs are otherwise invisible — you can't tell a wrong + * skylight value from a texture/colour problem by eye. + */ +export class LightingDebugOverlay { + private readonly root: HTMLElement; + private readonly body: HTMLElement; + visible = false; + + constructor() { + this.root = document.createElement("div"); + this.root.className = "voxl-light-debug"; + this.root.style.cssText = [ + "position:fixed", + "top:8px", + "left:8px", + "z-index:50", + "max-width:320px", + "padding:8px 10px", + "font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace", + "color:#dff", + "background:rgba(8,14,28,0.78)", + "border:1px solid rgba(120,180,255,0.35)", + "border-radius:6px", + "pointer-events:none", + "white-space:pre", + "display:none", + ].join(";"); + + const title = document.createElement("div"); + title.textContent = "LIGHTING (L to toggle)"; + title.style.cssText = "font-weight:700;color:#9cf;margin-bottom:4px;"; + this.root.appendChild(title); + + this.body = document.createElement("div"); + this.root.appendChild(this.body); + + document.body.appendChild(this.root); + } + + setVisible(v: boolean): void { + this.visible = v; + this.root.style.display = v ? "block" : "none"; + } + + toggle(): void { + this.setVisible(!this.visible); + } + + update(info: LightDebugInfo): void { + if (!this.visible) return; + const t = info.target; + const fmt = (n: number) => (n >= 0 ? n.toFixed(2) : "—"); + const lines: string[] = [ + `time: ${formatTime(info.timeOfDay)}${info.paused ? " (frozen)" : ""} day=${fmt(info.dayFactor)}`, + `sun: ${fmt(info.sunIntensity)} ambient: ${fmt(info.ambientIntensity)}`, + `shadows:${info.shadowsEnabled ? " on" : " off"} mode: ${info.debugMode}`, + `chunks: lit=${info.litCount}/${info.loadedCount} relightQueue=${info.dirtyCount}`, + ]; + if (t) { + lines.push( + `target: ${t.name} (${t.x},${t.y},${t.z})`, + ` sun=${t.sun}/15 block=${t.block}/15 combined=${t.combined}/15`, + ` pass=${t.lightPassesThrough} sunPass=${t.sunlightPassesThrough} emit=${t.emission}`, + ); + } else { + lines.push("target: (none)"); + } + this.body.textContent = lines.join("\n"); + } + + dispose(): void { + this.root.remove(); + } +} + +function formatTime(t: number): string { + // Map [0,1) to a 24h HH:MM clock where 0 = midnight, 0.5 = noon. + const totalMin = Math.floor(t * 24 * 60); + const h = Math.floor(totalMin / 60) % 24; + const m = totalMin % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +/** Build debug info for a targeted block (id at given coords). */ +export function buildTargetInfo( + id: number, + x: number, + y: number, + z: number, + sun: number, + block: number, + combined: number, +): LightDebugInfo["target"] { + const def = getBlock(id); + const light = resolveLight(def); + return { + x, + y, + z, + id, + name: def.name, + sun, + block, + combined, + lightPassesThrough: light.lightPassesThrough, + sunlightPassesThrough: light.sunlightPassesThrough, + emission: light.lightEmission, + }; +} diff --git a/src/game/lighting/LightingSystem.ts b/src/game/lighting/LightingSystem.ts new file mode 100644 index 0000000..a6ca64b --- /dev/null +++ b/src/game/lighting/LightingSystem.ts @@ -0,0 +1,130 @@ +import type { DirectionalLight, HemisphericLight, Scene } from "@babylonjs/core"; +import type { World } from "../World"; +import { DayNightCycle } from "./DayNightCycle"; +import { ShadowManager } from "./ShadowManager"; +import { LightingDebugOverlay, buildTargetInfo, type LightDebugInfo } from "./LightingDebugOverlay"; +import { DEFAULT_SHADOW_CONFIG, LIGHT_MAX, type LightDebugMode, type ShadowConfig } from "./LightingConfig"; + +/** + * Top-level facade that wires together every lighting subsystem so the rest of + * the game (Game.ts) only talks to one object. + * + * VoxelLightEngine — per-voxel sun + block light (owned by World) + * DayNightCycle — drives Babylon sun/ambient/hemi + sky/fog colours + * ShadowManager — nearby-chunk directional shadow mapping + * LightingDebugOverlay — live light-value inspection panel + * + * The voxel light field is owned by {@link World} (it needs block access); + * this class holds a typed reference for queries/debug. Lighting never runs in + * the render loop unless something changed — the World queues dirty chunks and + * relights them on a budget. + */ +export class LightingSystem { + readonly dayNight: DayNightCycle; + readonly shadows: ShadowManager; + readonly overlay: LightingDebugOverlay; + readonly config: { shadows: ShadowConfig }; + + private readonly world: World; + + constructor( + world: World, + sun: DirectionalLight, + ambient: HemisphericLight, + hemi: HemisphericLight, + scene: Scene, + ) { + this.world = world; + this.dayNight = new DayNightCycle(sun, ambient, hemi, scene); + this.shadows = new ShadowManager(sun, world, { ...DEFAULT_SHADOW_CONFIG }); + this.overlay = new LightingDebugOverlay(); + this.config = { shadows: this.shadows.config }; + } + + /** Per-frame: advance time, follow player with shadows. */ + update(dt: number, playerX: number, playerY: number, playerZ: number): void { + this.dayNight.update(dt); + this.shadows.update(playerX, playerY, playerZ); + } + + // ---- shadow controls (Babylon real-time shadow; voxel light is independent) ---- + + toggleShadows(): boolean { + return this.shadows.toggle(); + } + + get shadowsEnabled(): boolean { + return this.shadows.enabled; + } + + dumpShadowDiagnostics(): unknown { + return this.shadows.dumpDiagnostics(); + } + + // ---- debug controls ---- + + setDebugMode(mode: LightDebugMode): void { + this.world.setLightDebugMode(mode); + } + + cycleDebugMode(): LightDebugMode { + const order: LightDebugMode[] = ["off", "sun", "block", "combined"]; + const cur = this.world.getLightDebugMode(); + const next = order[(order.indexOf(cur) + 1) % order.length]; + this.setDebugMode(next); + return next; + } + + getDebugMode(): LightDebugMode { + return this.world.getLightDebugMode(); + } + + /** Build the throttled overlay payload for the block the player aims at. */ + buildDebugInfo(target: { x: number; y: number; z: number; block: number } | null): LightDebugInfo { + let t: LightDebugInfo["target"] = null; + if (target) { + const { x, y, z, block } = target; + const sun = this.world.lighting.getSun(x, y, z); + const bl = this.world.lighting.getBlockLight(x, y, z); + const combined = this.world.lighting.getCombined(x, y, z, this.dayNight.dayFactor); + t = buildTargetInfo(block, x, y, z, sun, bl, combined); + } + // Count dirty/light state by scanning loaded chunks via the world. + let loaded = 0; + let lit = 0; + this.world.forEachOpaqueMesh((cx, cz) => { + loaded++; + if (this.world.lighting.hasLight(cx, cz)) lit++; + void cx; + void cz; + }); + // forEachOpaqueMesh only covers meshed chunks; approximate counts are fine. + return { + enabled: this.overlay.visible, + timeOfDay: this.dayNight.timeOfDay, + dayFactor: this.dayNight.dayFactor, + sunIntensity: this.dayNight["sun"].intensity, + ambientIntensity: this.dayNight["ambient"].intensity, + debugMode: this.getDebugMode(), + shadowsEnabled: this.shadows.enabled, + paused: this.dayNight.paused, + target: t, + dirtyCount: this.lightDirtyCount(), + litCount: lit, + loadedCount: loaded, + }; + } + + /** Best-effort access to the World's pending light-update count. */ + private lightDirtyCount(): number { + const w = this.world as unknown as { lightDirty?: { size: number } }; + return w.lightDirty?.size ?? 0; + } + + dispose(): void { + this.shadows.dispose(); + this.overlay.dispose(); + } + + static readonly MAX_LIGHT = LIGHT_MAX; +} diff --git a/src/game/lighting/ShadowManager.ts b/src/game/lighting/ShadowManager.ts new file mode 100644 index 0000000..82c35b8 --- /dev/null +++ b/src/game/lighting/ShadowManager.ts @@ -0,0 +1,188 @@ +import { DirectionalLight, Mesh, ShadowGenerator } from "@babylonjs/core"; +import type { World } from "../World"; +import { type ShadowConfig } from "./LightingConfig"; + +/** + * Manages Babylon directional-light shadow mapping for the voxel terrain. + * + * Voxel sunlight (baked into vertex colours) already produces correct + * cave/overhang darkness and most "shadow" cues. This manager adds *dynamic* + * cast shadows (trees, terrain silhouettes, later the player) on top, limited + * to nearby chunks so it stays affordable. + * + * ┌─ shadow frustum (shadowFrustumSize) ─┐ The frustum is centred on the + * │ │ player and larger than the + * │ ┌─ caster area ─┐ │ caster area, so the hard + * │ │ trees/terrain │ ← fade margin →│ frustum edge never coincides + * │ └────────────────┘ │ with a shadow. Shadows also + * │ │ fade via frustumEdgeFalloff. + * └──────────────────────────────────────┘ (No giant hard rectangle.) + * + * All tunables live in {@link ShadowConfig}. `setEnabled(false)` tears the + * generator down so the world falls back to baked voxel light only — useful to + * confirm whether an artifact is the shadow system or the voxel light. + */ +export class ShadowManager { + readonly config: ShadowConfig; + private readonly sun: DirectionalLight; + private readonly world: World; + private generator: ShadowGenerator | null = null; + /** Current caster list, rebuilt each frame from nearby chunk meshes. */ + private casters: Mesh[] = []; + private playerX = 0; + private playerY = 0; + private playerZ = 0; + + constructor(sun: DirectionalLight, world: World, config: ShadowConfig) { + this.sun = sun; + this.world = world; + this.config = config; + if (config.enabled) this.setup(); + } + + private setup(): void { + const sg = new ShadowGenerator(this.config.mapSize, this.sun); + if (this.config.blur) { + sg.useBlurExponentialShadowMap = true; + sg.blurKernel = 32; + sg.blurScale = 2; + } else { + sg.useExponentialShadowMap = true; + } + sg.bias = this.config.bias; + sg.normalBias = 0.02; // suppress terrain self-shadow (acne) + sg.darkness = 0.5; // shadowed pixels retain 50% light (subtle voxel look) + // Fade shadows out toward the frustum edge so the box boundary is never a + // hard line (Babylon's computeFallOff uses this; default 0 = razor edge, + // which is what produced the giant rectangular shadow artifact). + sg.frustumEdgeFalloff = this.config.frustumEdgeFalloff; + + // Fixed orthographic frustum (stable texel density). The fixed-frustum path + // otherwise borrows the *camera's* minZ/maxZ (0.1/1000) for the shadow + // depth range, which wrecks ESM depth precision — set tight explicit bounds. + this.sun.shadowFrustumSize = this.config.frustum * 2; + this.sun.shadowMinZ = this.config.shadowMinZ; + this.sun.shadowMaxZ = this.config.shadowMaxZ; + + const rt = sg.getShadowMap(); + if (rt) { + rt.renderList = this.casters; + // Only render actual casters (never an empty/whole-scene fallback). + rt.renderParticles = false; + } + this.generator = sg; + } + + /** + * Re-centre the shadow frustum on the player and refresh the nearby-caster + * list. The light is placed up-sun so the frustum box (centred on the light's + * view axis) is centred on the player, not offset from it. + */ + update(playerX: number, playerY: number, playerZ: number): void { + this.playerX = playerX; + this.playerY = playerY; + this.playerZ = playerZ; + if (!this.generator) return; + + const dir = this.sun.direction; + // Centre the depth-symmetric frustum box on the player. The ortho centre in + // world space = light.position + dir * (shadowMaxZ/2), so solving for the + // light position that puts the player at that centre: + const centerDepth = this.config.shadowMaxZ * 0.5; + this.sun.position.x = playerX - dir.x * centerDepth; + this.sun.position.y = playerY - dir.y * centerDepth; + this.sun.position.z = playerZ - dir.z * centerDepth; + + // Casters: nearby opaque chunk meshes only, kept INSIDE the frustum so + // shadow clipping happens in the fade-margin ring (never a hard edge). + const radius = this.config.casterRadius; + const radiusSq = radius * radius; + const next: Mesh[] = []; + this.world.forEachOpaqueMesh((cx, cz, mesh) => { + if (!mesh.isEnabled() || !mesh.isVisible) return; + const ccx = cx * 16 + 8; + const ccz = cz * 16 + 8; + const dx = ccx - playerX; + const dz = ccz - playerZ; + if (dx * dx + dz * dz <= radiusSq) next.push(mesh); + }); + + if (!sameSet(next, this.casters)) { + this.casters = next; + const rt = this.generator.getShadowMap(); + if (rt) rt.renderList = this.casters; + } + } + + setEnabled(enabled: boolean): void { + if (enabled && !this.generator) { + this.config.enabled = true; + this.setup(); + } else if (!enabled && this.generator) { + this.dispose(); + this.config.enabled = false; + } + } + + /** Toggle and return the new enabled state. */ + toggle(): boolean { + this.setEnabled(!this.enabled); + return this.enabled; + } + + get enabled(): boolean { + return this.generator !== null; + } + + /** + * Dump every mesh in the shadow render list (name, position, bounds, + * visibility) plus the light/frustum state. For diagnosing shadow artifacts. + */ + dumpDiagnostics(): unknown { + const dir = this.sun.direction; + const info = { + enabled: this.enabled, + light: { + name: this.sun.name, + direction: { x: dir.x, y: dir.y, z: dir.z }, + position: { x: this.sun.position.x, y: this.sun.position.y, z: this.sun.position.z }, + intensity: this.sun.intensity, + shadowFrustumSize: this.sun.shadowFrustumSize, + shadowMinZ: this.sun.shadowMinZ, + shadowMaxZ: this.sun.shadowMaxZ, + }, + config: { ...this.config }, + player: { x: this.playerX, y: this.playerY, z: this.playerZ }, + casterCount: this.casters.length, + casters: this.casters.map((m) => { + const bi = m.getBoundingInfo(); + const bb = bi ? bi.boundingBox : null; + return { + name: m.name, + isVisible: m.isVisible, + isEnabled: m.isEnabled(), + receiveShadows: m.receiveShadows, + position: { x: m.position.x, y: m.position.y, z: m.position.z }, + boundsMin: bb ? { x: bb.minimumWorld.x, y: bb.minimumWorld.y, z: bb.minimumWorld.z } : null, + boundsMax: bb ? { x: bb.maximumWorld.x, y: bb.maximumWorld.y, z: bb.maximumWorld.z } : null, + }; + }), + }; + // eslint-disable-next-line no-console + console.log("[shadows]", info); + return info; + } + + dispose(): void { + this.generator?.dispose(); + this.generator = null; + this.casters = []; + } +} + +function sameSet(a: Mesh[], b: Mesh[]): boolean { + if (a.length !== b.length) return false; + const bs = new Set(b); + for (const m of a) if (!bs.has(m)) return false; + return true; +} diff --git a/src/game/lighting/SunLightPropagator.ts b/src/game/lighting/SunLightPropagator.ts new file mode 100644 index 0000000..7913581 --- /dev/null +++ b/src/game/lighting/SunLightPropagator.ts @@ -0,0 +1,192 @@ +import { CHUNK_SIZE, CHUNK_HEIGHT } from "../../constants"; +import { getBlock, resolveLight } from "../Blocks"; +import type { Chunk } from "../Chunk"; +import type { LightAccess } from "./LightMap"; +import { LIGHT_MAX } from "./LightingConfig"; + +/** + * Sunlight (sky-light) propagation for a single chunk, modelled on + * Minetest/Luanti and Minecraft. + * + * Three phases: + * + * **A — Vertical sky exposure (chunk-local, neighbour-independent).** + * For each column we walk top→down. While the column is still "open to the + * sky" every AIR or `sunlightPassesThrough` cell is seeded at LIGHT_MAX (15). + * The first cell that does not let sunlight pass straight down (opaque block, + * but also leaves/water, which only allow *spread* light) closes the sky + * column — nothing below it is seeded here; it will be lit by BFS instead. + * + * **A2 — Boundary inflow (horizontal sky bleed between chunks).** + * Every in-chunk border cell pulls light from its out-of-chunk neighbour + * (read-only boundary condition). This is what lets a cave in this chunk be + * lit by an opening in the adjacent chunk, and vice-versa. Corrected/refreshed + * automatically when the neighbour re-lights and marks this chunk dirty. + * + * **B — BFS flood fill (outward spread).** + * Light spreads from brighter to darker in-chunk cells with the classic rules: + * • horizontally / upward: level − 1 (− absorption) + * • straight DOWN through a `sunlightPassesThrough` cell from a level-15 + * source: no decay (sunlight streams down open shafts and through glass) + * • straight DOWN through any other light-passing cell (water/leaves): + * level − 1, so depth and canopy bleed attenuate naturally. + * + * Light never enters a cell whose block does not `lightPassesThrough` + * (stone, dirt, …). Opaque cells stay at 0 and act as walls. + * + * Because a sun value of 15 can only ever arise from an unbroken sky column + * (BFS decay can't reach 15 from below), "level === 15" doubles as the marker + * for the straight-down no-decay rule — no separate flag array is needed. + */ +export class SunLightPropagator { + private queue: Int32Array; + private qTail = 0; + + constructor() { + this.queue = new Int32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); + } + + propagate(access: LightAccess, chunk: Chunk, sun: Uint8Array): void { + sun.fill(0); + this.qTail = 0; + + // ---- Phase A: vertical sky exposure ---- + for (let z = 0; z < CHUNK_SIZE; z++) { + for (let x = 0; x < CHUNK_SIZE; x++) { + let skyExposed = true; // above the chunk is always open sky + for (let y = CHUNK_HEIGHT - 1; y >= 0; y--) { + const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; + const id = chunk.blocks[idx]; + if (id === 0) { + if (skyExposed) { + sun[idx] = LIGHT_MAX; + this.enqueue(idx); + } + continue; + } + const light = resolveLight(getBlock(id)); + if (skyExposed && light.sunlightPassesThrough) { + sun[idx] = LIGHT_MAX; + this.enqueue(idx); + continue; // column stays open (sun passes straight through, e.g. glass) + } + // First cell that breaks straight sunlight (opaque OR + // light-passing-but-not-sun-passing like leaves/water). Its surface + // is NOT seeded: it receives attenuated light from adjacent sky-lit + // air during BFS. + skyExposed = false; + } + } + } + + const ox = chunk.originX; + const oz = chunk.originZ; + + // ---- Phase A2: boundary inflow from neighbour chunks ---- + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let z = 0; z < CHUNK_SIZE; z++) { + this.pullInflow(access, sun, chunk, 0, y, z, ox, oz, -1, 0); + this.pullInflow(access, sun, chunk, CHUNK_SIZE - 1, y, z, ox, oz, +1, 0); + } + for (let x = 0; x < CHUNK_SIZE; x++) { + this.pullInflow(access, sun, chunk, x, y, 0, ox, oz, 0, -1); + this.pullInflow(access, sun, chunk, x, y, CHUNK_SIZE - 1, ox, oz, 0, +1); + } + } + + // ---- Phase B: BFS outward spread ---- + let head = 0; + while (head < this.qTail) { + const idx = this.queue[head++]; + const level = sun[idx]; + if (level <= 1) continue; + // blockIndex = (y*CHUNK_SIZE + z)*CHUNK_SIZE + x → y-stride = CHUNK_SIZE² + const lx = idx % CHUNK_SIZE; + const lz = (((idx - lx) / CHUNK_SIZE) % CHUNK_SIZE) | 0; + const ly = ((idx - lx - lz * CHUNK_SIZE) / (CHUNK_SIZE * CHUNK_SIZE)) | 0; + // 6 in-chunk neighbours only; out-of-chunk handled by A2 inflow. + if (lx > 0) this.spread(sun, chunk, level, lx, ly, lz, -1, 0, 0); + if (lx < CHUNK_SIZE - 1) this.spread(sun, chunk, level, lx, ly, lz, +1, 0, 0); + if (lz > 0) this.spread(sun, chunk, level, lx, ly, lz, 0, 0, -1); + if (lz < CHUNK_SIZE - 1) this.spread(sun, chunk, level, lx, ly, lz, 0, 0, +1); + if (ly > 0) this.spread(sun, chunk, level, lx, ly, lz, 0, -1, 0); + if (ly < CHUNK_HEIGHT - 1) this.spread(sun, chunk, level, lx, ly, lz, 0, +1, 0); + } + } + + private enqueue(idx: number): void { + if (this.qTail < this.queue.length) this.queue[this.qTail++] = idx; + } + + /** Outward spread from `(lx,ly,lz)` into in-chunk neighbour `(lx+dx,…)`. */ + private spread( + sun: Uint8Array, + chunk: Chunk, + level: number, + lx: number, + ly: number, + lz: number, + dx: number, + dy: number, + dz: number, + ): void { + const nx = lx + dx; + const ny = ly + dy; + const nz = lz + dz; + const nbId = chunk.blocks[(ny * CHUNK_SIZE + nz) * CHUNK_SIZE + nx]; + if (nbId !== 0 && !resolveLight(getBlock(nbId)).lightPassesThrough) return; + const absorption = nbId === 0 ? 0 : resolveLight(getBlock(nbId)).lightAbsorption; + + let candidate: number; + if (dy === -1 && level === LIGHT_MAX) { + const sunPass = nbId === 0 ? true : resolveLight(getBlock(nbId)).sunlightPassesThrough; + candidate = sunPass ? level : level - 1 - absorption; + } else { + candidate = level - 1 - absorption; + } + if (candidate <= 0) return; + + const nidx = (ny * CHUNK_SIZE + nz) * CHUNK_SIZE + nx; + if (candidate > sun[nidx]) { + sun[nidx] = candidate; + this.enqueue(nidx); + } + } + + /** + * Pull light INTO the in-chunk border cell `(lx,ly,lz)` from its out-of-chunk + * neighbour at world offset `(bx,bz)`. Uses the straight-down rule when the + * neighbour is directly above the cell (top edge) and is sky-lit. + */ + private pullInflow( + access: LightAccess, + sun: Uint8Array, + chunk: Chunk, + lx: number, + ly: number, + lz: number, + ox: number, + oz: number, + bx: number, + bz: number, + ): void { + const idx = (ly * CHUNK_SIZE + lz) * CHUNK_SIZE + lx; + const nbId = chunk.blocks[idx]; // the receiving cell itself + if (nbId !== 0 && !resolveLight(getBlock(nbId)).lightPassesThrough) return; + + const wx = ox + lx + bx; + const wz = oz + lz + bz; + const nbLevel = access.readSun(wx, ly, wz); + if (nbLevel <= 1) return; + + const absorption = nbId === 0 ? 0 : resolveLight(getBlock(nbId)).lightAbsorption; + const sunPass = nbId === 0 ? true : resolveLight(getBlock(nbId)).sunlightPassesThrough; + // Neighbour is horizontal (bz/bx != 0) → normal decay into our cell. + const candidate = nbLevel - 1 - absorption; + if (candidate > sun[idx]) { + sun[idx] = candidate; + this.enqueue(idx); + } + void sunPass; + } +} diff --git a/src/game/lighting/VoxelLightEngine.ts b/src/game/lighting/VoxelLightEngine.ts new file mode 100644 index 0000000..fb4ddaf --- /dev/null +++ b/src/game/lighting/VoxelLightEngine.ts @@ -0,0 +1,151 @@ +import { CHUNK_SIZE, CHUNK_HEIGHT } from "../../constants"; +import type { BlockId } from "../../types"; +import type { Chunk } from "../Chunk"; +import { LightMap } from "./LightMap"; +import type { LightAccess } from "./LightMap"; +import { SunLightPropagator } from "./SunLightPropagator"; +import { BlockLightPropagator } from "./BlockLightPropagator"; +import { LIGHT_MAX, combineLight } from "./LightingConfig"; + +export function lightKey(cx: number, cz: number): string { + return `${cx},${cz}`; +} + +export interface RelightResult { + /** Any sun/block value in the chunk changed (→ mesh should be rebuilt). */ + changed: boolean; + /** A value on the chunk's border ring changed (→ neighbours must re-light). */ + borderChanged: boolean; +} + +/** + * Owns every chunk's {@link LightMap} and runs voxel light propagation. + * + * The engine is purely computational: it never touches Babylon. It implements + * {@link LightAccess} so the per-channel propagators can read neighbour chunks + * as boundary conditions, and it exposes world-coordinate queries the mesher + * and debug overlay consume. + * + * Relighting is driven externally (by {@link World}) which knows chunk load / + * edit events and feeds the engine chunks via {@link relightChunk}. The engine + * reports whether a relight changed anything (and whether the chunk's *border* + * changed) so the world can mark neighbours and meshes dirty — this is what + * keeps lighting consistent across chunk seams without ever relighting the + * whole world. + */ +export class VoxelLightEngine implements LightAccess { + private readonly maps = new Map(); + private readonly sun = new SunLightPropagator(); + private readonly block = new BlockLightPropagator(); + private readonly getBlockIdAt: (wx: number, wy: number, wz: number) => BlockId; + /** Scratch buffers so relight allocates nothing per call. */ + private readonly scratchSun: Uint8Array; + private readonly scratchBlock: Uint8Array; + + constructor(getBlockIdAt: (wx: number, wy: number, wz: number) => BlockId) { + this.getBlockIdAt = getBlockIdAt; + this.scratchSun = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); + this.scratchBlock = new Uint8Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); + } + + // ---- LightAccess (read-only boundary queries) ---- + + getBlockId(wx: number, wy: number, wz: number): BlockId { + return this.getBlockIdAt(wx, wy, wz); + } + + readSun(wx: number, wy: number, wz: number): number { + if (wy < 0) return 0; + if (wy >= CHUNK_HEIGHT) return LIGHT_MAX; // above world = open sky + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const map = this.maps.get(lightKey(cx, cz)); + // Unloaded/unlit chunk → assume open sky (safe default, corrected on load). + if (!map || !map.valid) return LIGHT_MAX; + return map.getSun(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE); + } + + readBlockLight(wx: number, wy: number, wz: number): number { + if (wy < 0 || wy >= CHUNK_HEIGHT) return 0; + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const map = this.maps.get(lightKey(cx, cz)); + if (!map || !map.valid) return 0; + return map.getBlock(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE); + } + + // ---- world-coordinate queries (mesher / debug) ---- + + getSun(wx: number, wy: number, wz: number): number { + return this.readSun(wx, wy, wz); + } + + getBlockLight(wx: number, wy: number, wz: number): number { + return this.readBlockLight(wx, wy, wz); + } + + /** Combined render light level for a voxel given a sun (day/night) factor. */ + getCombined(wx: number, wy: number, wz: number, sunFactor = 1): number { + return combineLight(this.readSun(wx, wy, wz), this.readBlockLight(wx, wy, wz), sunFactor); + } + + hasLight(cx: number, cz: number): boolean { + const m = this.maps.get(lightKey(cx, cz)); + return !!m && m.valid; + } + + /** Remove light data for an unloading chunk. */ + removeLight(cx: number, cz: number): void { + this.maps.delete(lightKey(cx, cz)); + } + + /** + * Recompute sun + block light for `chunk` from scratch, using neighbour + * chunks (where loaded) as boundary conditions. Returns whether anything + * changed and whether the border ring changed. + */ + relightChunk(chunk: Chunk): RelightResult { + const k = lightKey(chunk.cx, chunk.cz); + let map = this.maps.get(k); + if (!map) { + map = new LightMap(); + this.maps.set(k, map); + } + + this.sun.propagate(this, chunk, this.scratchSun); + this.block.propagate(this, chunk, this.scratchBlock); + + // Diff against the previous values. + let changed = false; + let borderChanged = false; + const oldSun = map.sun; + const oldBlock = map.block; + const newSun = this.scratchSun; + const newBlock = this.scratchBlock; + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let z = 0; z < CHUNK_SIZE; z++) { + const onBorder = z === 0 || z === CHUNK_SIZE - 1; + for (let x = 0; x < CHUNK_SIZE; x++) { + const idx = (y * CHUNK_SIZE + z) * CHUNK_SIZE + x; + const s = newSun[idx]; + const b = newBlock[idx]; + if (s !== oldSun[idx] || b !== oldBlock[idx]) { + changed = true; + if (onBorder || x === 0 || x === CHUNK_SIZE - 1) borderChanged = true; + } + } + } + if (changed && borderChanged) { + // Can't conclude more by scanning further; finish copy below. + } + } + + // Commit scratch → stored map. + oldSun.set(newSun); + oldBlock.set(newBlock); + map.valid = true; + if (changed) map.dirty = true; + + return { changed, borderChanged }; + } +} diff --git a/src/main.ts b/src/main.ts index 12a726f..d19e88a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,8 @@ function boot(): void { // Debug hooks (not part of the gameplay API). debugFlat?: () => void; loadedChunks?: () => unknown; + // Lighting debug surface (see LightingSystem). + lighting?: () => unknown; } (window as unknown as { __voxl?: VoxlAutomation }).__voxl = { beginPlay: () => game.beginPlay(), @@ -30,6 +32,7 @@ function boot(): void { takeScreenshot: () => game.takeScreenshot(), debugFlat: () => game._enableDebugFlat(), loadedChunks: () => game._loadedChunks(), + lighting: () => game._lightingDebug(), }; } From 1d9fdbda6a320c9d24c047ab86f097825e0b7118 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 14 Jun 2026 00:17:18 +0100 Subject: [PATCH 2/4] Add day/night cycle with sun, moon, and two-channel terrain shading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game clock + celestial visuals on top of the voxel light engine: - DayNightCycle: timeOfDay (0=midnight, 0.25=sunrise, 0.5=noon, 0.75= sunset), configurable day length + timeScale, pause/resume, and preset helpers (setSunrise/setNoon/setSunset/setMidnight). Sun orbits east→west with a slight tilt; moon is the exact antipode. dayFactor/moonFactor and a multi-stop sky-colour gradient (night→dusk purple/orange→day blue) are recomputed each frame and pushed to lights + sky/fog. - CelestialSystem: camera-anchored sun disc + additive halo + cratered moon billboards that fade in/out at the horizon, warm at golden hour. Stay in rendering group 0 (with terrain) so blocks occlude them; never cast/receive shadows; ignore fog. - VoxelTerrainMaterial: custom two-channel ShaderMaterial. The mesher bakes sun and block light into vertex-colour .r/.g (raw levels into .b/.a for the debug overlay); the shader combines max(sun*dayFactor, sun*moonFloor, block). Day/night therefore dims outdoor terrain at night WITHOUT touching torch/glowstone (block) light and WITHOUT rebuilding any chunk mesh — only the uDayFactor/uMoonFloor uniforms update each frame. Linear fog is done manually. Fixes the prior shader compile error (uTexture now sampler-only; sources passed inline) and the sun-through-blocks bug (depth group). - Shadows: ShadowManager retained but disabled by default (terrain uses voxel sunlight), permanently retiring the rectangular-frustum artifact. - Debug: L overlay, K cycle sun/block/combined view (now a uniform, no remesh), T pause, H toggle Babylon shadows, [ / ] time presets, O / I speed, ; / ' midnight/noon. Overlay shows time, day/moon factors, sun direction/visibility, intensities. --- src/engine/Sky.ts | 78 ++------ src/game/ChunkMesher.ts | 61 ++++-- src/game/Game.ts | 47 +++-- src/game/World.ts | 102 +++++----- src/game/lighting/CelestialSystem.ts | 206 ++++++++++++++++++++ src/game/lighting/DayNightCycle.ts | 225 +++++++++++++++------- src/game/lighting/LightingConfig.ts | 57 +++++- src/game/lighting/LightingDebugOverlay.ts | 14 +- src/game/lighting/LightingSystem.ts | 131 ++++++++----- src/game/lighting/VoxelTerrainMaterial.ts | 167 ++++++++++++++++ 10 files changed, 814 insertions(+), 274 deletions(-) create mode 100644 src/game/lighting/CelestialSystem.ts create mode 100644 src/game/lighting/VoxelTerrainMaterial.ts diff --git a/src/engine/Sky.ts b/src/engine/Sky.ts index 8812345..dc79037 100644 --- a/src/engine/Sky.ts +++ b/src/engine/Sky.ts @@ -1,14 +1,11 @@ import { Color3, DirectionalLight, - DynamicTexture, HemisphericLight, Mesh, MeshBuilder, Scene, ShaderMaterial, - StandardMaterial, - Texture, TransformNode, Vector3, } from "@babylonjs/core"; @@ -17,7 +14,8 @@ import { Clouds } from "./Clouds"; const ZENITH = Color3.FromHexString("#2f6fdb"); const HORIZON = Color3.FromHexString("#bfe3ff"); -/** Builds the sky: gradient dome, sun light, ambient/hemisphere fill, clouds. */ +/** Builds the sky: gradient dome, sun light, ambient/hemisphere fill, clouds. + * The visual sun/moon discs live in CelestialSystem (lighting/). */ export class Sky { readonly root: TransformNode; readonly sun: DirectionalLight; @@ -26,9 +24,8 @@ export class Sky { private readonly scene: Scene; private readonly dome: Mesh; + private readonly domeMat: ShaderMaterial; private readonly clouds: Clouds; - private readonly sunQuad: Mesh; - private readonly sunOffset = new Vector3(300, 260, 200); constructor(seed = "voxl", scene: Scene) { this.scene = scene; @@ -79,6 +76,7 @@ export class Sky { domeMat.setVector3("bottomColor", new Vector3(HORIZON.r, HORIZON.g, HORIZON.b)); domeMat.setFloat("offset", 33); domeMat.setFloat("exponent", 0.6); + this.domeMat = domeMat; // Inside-out sphere: don't cull back faces, don't write depth. domeMat.backFaceCulling = false; domeMat.disableDepthWrite = true; @@ -107,31 +105,18 @@ export class Sky { this.sun.diffuse = Color3.FromHexString("#fff4e0"); this.sun.intensity = 0.85; - // --- Sun billboard (a camera-facing quad with a radial gradient texture) --- - const sunTex = makeRadialTexture("sun-tex", scene, "#fff6d8", "#ffd27a"); - this.sunQuad = MeshBuilder.CreatePlane("sun", { size: 46 }, scene); - const sunMat = new StandardMaterial("sun-mat", scene); - sunMat.emissiveTexture = sunTex; - sunMat.opacityTexture = sunTex; // use the gradient's luminance as opacity - sunMat.disableLighting = true; - sunMat.backFaceCulling = false; - sunMat.disableDepthWrite = true; - // Always render on top of the sky dome but before world geometry. - sunMat.disableColorWrite = false; - sunMat.alpha = 1; - this.sunQuad.material = sunMat; - this.sunQuad.billboardMode = Mesh.BILLBOARDMODE_ALL; - this.sunQuad.applyFog = false; - this.sunQuad.receiveShadows = false; // sun billboard never receives/casts shadows - this.sunQuad.alwaysSelectAsActiveMesh = true; - this.sunQuad.parent = this.root; - // --- Clouds (Minetest/Luanti-style voxel layer) --- this.clouds = new Clouds(seed, scene); this.clouds.mesh.parent = this.root; this.clouds.mesh.receiveShadows = false; // clouds never receive/casts shadows } + /** Update the gradient dome colours from the day/night cycle. */ + setDomeColours(zenith: Color3, horizon: Color3): void { + this.domeMat.setVector3("topColor", new Vector3(zenith.r, zenith.g, zenith.b)); + this.domeMat.setVector3("bottomColor", new Vector3(horizon.r, horizon.g, horizon.b)); + } + setCloudsEnabled(enabled: boolean): void { this.clouds.setEnabled(enabled); } @@ -145,10 +130,8 @@ export class Sky { this.clouds.step(dt); this.clouds.update(cameraPosition.x, cameraPosition.z); - // The dome uses infiniteDistance (auto-follows camera); but the sun quad - // and clouds still need manual anchoring. - this.sunQuad.setAbsolutePosition(cameraPosition.add(this.sunOffset)); - + // The dome uses infiniteDistance (auto-follows camera); clouds still need + // manual anchoring. The visual sun/moon are anchored by CelestialSystem. // The clouds use a custom ShaderMaterial that doesn't get scene fog for // free, so we bind the current fog state each frame. this.clouds.bindFog( @@ -161,46 +144,11 @@ export class Sky { dispose(): void { this.clouds.dispose(); - const domeMat = this.dome.material; - const sunMat = this.sunQuad.material; - const sunTex = sunMat instanceof StandardMaterial ? sunMat.opacityTexture : null; this.dome.dispose(); - this.sunQuad.dispose(); - domeMat?.dispose(); - sunMat?.dispose(); - if (sunTex) sunTex.dispose(); + this.domeMat.dispose(); this.ambient.dispose(); this.hemi.dispose(); this.sun.dispose(); this.root.dispose(); } } - -/** A soft radial-gradient texture for the sun disc. */ -function makeRadialTexture( - name: string, - scene: Scene, - inner: string, - outer: string, -): DynamicTexture { - const size = 128; - const tex = new DynamicTexture( - name, - { width: size, height: size }, - scene, - false, - Texture.LINEAR_LINEAR, - undefined, - false, - ); - tex.hasAlpha = true; - const ctx = tex.getContext() as CanvasRenderingContext2D; - const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); - g.addColorStop(0, inner); - g.addColorStop(0.5, outer); - g.addColorStop(1, "rgba(255,210,120,0)"); - ctx.fillStyle = g; - ctx.fillRect(0, 0, size, size); - tex.update(false); - return tex; -} diff --git a/src/game/ChunkMesher.ts b/src/game/ChunkMesher.ts index d5e82bc..76fdb80 100644 --- a/src/game/ChunkMesher.ts +++ b/src/game/ChunkMesher.ts @@ -7,13 +7,27 @@ import type { Chunk } from "./Chunk"; import { FACE_SHADE, PLANT_SHADE } from "./lighting/LightingConfig"; /** - * Samples the final per-vertex brightness (0..1) for the cell a face looks into. - * The world/lighting system builds this callback; the mesher never hardcodes - * light behaviour — it only supplies the directional face shade. This lets the - * lighting system switch between normal rendering and debug overlays (raw sun / - * block light) without the mesher knowing. + * Per-vertex light sample for the cell a face looks into. The world/lighting + * system builds this; the mesher never hardcodes light behaviour — it only + * supplies the directional face shade. + * + * Two shaded channels are baked into vertex-colour .r/.g (sun, block) and two + * raw 0..1 levels into .b/.a for the debug overlay. The VoxelTerrainMaterial + * combines them with live day/night uniforms, so torch (block) light survives + * the night while outdoor (sun) light dims. */ -export type BrightnessSampler = (wx: number, wy: number, wz: number, shade: number) => number; +export interface BrightnessSample { + /** face shade × brightness-curve of the sun light level */ + sunBright: number; + /** face shade × brightness-curve of the block (emissive) light level */ + blockBright: number; + /** raw sun level / LIGHT_MAX (0..1) — debug overlay */ + sunLevel: number; + /** raw block level / LIGHT_MAX (0..1) — debug overlay */ + blockLevel: number; +} + +export type BrightnessSampler = (wx: number, wy: number, wz: number, shade: number) => BrightnessSample; // The six cube faces. Corner order + UVs are tuned so that triangles // (0,1,2, 2,1,3) produce correctly-wound front faces. Order matches the @@ -153,7 +167,8 @@ function pushFace( y: number, z: number, tile: number, - brightness: number, + sample: BrightnessSample, + twoChannel: boolean, waterTop: boolean, ): void { const face = FACES[faceIndex]; @@ -161,6 +176,20 @@ 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. + let cr: number, cg: number, cb: number, ca: number; + if (twoChannel) { + cr = sample.sunBright; + cg = sample.blockBright; + cb = sample.sunLevel; + ca = sample.blockLevel; + } else { + const m = sample.sunBright >= sample.blockBright ? sample.sunBright : sample.blockBright; + cr = cg = cb = m; + ca = 1; + } for (let c = 0; c < 4; c++) { const corner = face.corners[c]; // Lower the entire water surface slightly so it reads as a fluid. @@ -169,8 +198,7 @@ function pushFace( b.normals.push(face.normal[0], face.normal[1], face.normal[2]); const cu = CORNER_UV[c]; b.uvs.push(uv.u0 + cu[0] * du, uv.v0 + cu[1] * dv); - // RGBA: Babylon vertex-color path expects 4 components per vertex. - b.colors.push(brightness, brightness, brightness, 1); + b.colors.push(cr, cg, cb, ca); } b.indices.push(base, base + 1, base + 2, base + 2, base + 1, base + 3); b.vertexCount += 4; @@ -178,12 +206,15 @@ function pushFace( // Two diagonal quads forming an "X" — the classic plantlike cross used for // grass tufts, flowers and mushrooms. Rendered in the cutout pass. -function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: number, brightness: number): void { +function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: number, sample: BrightnessSample): void { const uv = tileUV(tile); const du = uv.u1 - uv.u0; const dv = uv.v1 - uv.v0; const base = b.vertexCount; - const br = brightness; + const cr = sample.sunBright; + const cg = sample.blockBright; + const cb = sample.sunLevel; + const ca = sample.blockLevel; // Quad A: diagonal plane through (0,0,0)-(1,1,1). V is swapped vs. the // positions so that Y=0 (bottom) samples V=v1 (canvas-bottom of the tile // = the stem) and Y=1 (top) samples V=v0 (canvas-top = petals/leaves). @@ -206,7 +237,7 @@ function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: numb b.positions.push(x + p[0], y + p[1], z + p[2]); b.normals.push(p[5], 0, p[6]); b.uvs.push(p[3], p[4]); - b.colors.push(br, br, br, 1); + b.colors.push(cr, cg, cb, ca); } b.indices.push(qbase, qbase + 1, qbase + 2, qbase + 2, qbase + 3, qbase); b.vertexCount += 4; @@ -271,9 +302,11 @@ export function buildChunkGeometry( if (!shouldRenderFace(id, neighborId)) continue; // Face brightness comes from the light of the cell the face is // exposed to (the neighbour air/space), combined with face shade. - const br = sampleBrightness(nwx, nwy, nwz, FACE_BRIGHTNESS[f]); + const sample = sampleBrightness(nwx, nwy, nwz, FACE_BRIGHTNESS[f]); const isWaterTop = def.liquid && n[1] === 1; - pushFace(builder, f, wx, wy, wz, def.tiles[f], br, isWaterTop); + // 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); } } } diff --git a/src/game/Game.ts b/src/game/Game.ts index e158c92..a7aeeb8 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -217,13 +217,7 @@ export class Game { // Re-seed the cloud field so it matches the new world. 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.sun, - this.sky.ambient, - this.sky.hemi, - this.scene, - ); + this.lighting = new LightingSystem(this.world, this.sky, this.scene); } private setPlaying(): void { @@ -285,10 +279,16 @@ export class Game { this.world?.update(this.player.position.x, this.player.position.z, this.settings.viewDistance); } - // Advance day/night + shadow follow even while paused (cheap, and lets you - // inspect frozen time). Skip when there's no world (main menu). + // Advance day/night + position sun/moon + push terrain uniforms even while + // paused (cheap; lets you inspect frozen time). Skip when no world. if (this.lighting && this.world) { - this.lighting.update(dt, this.player.position.x, this.player.position.y, this.player.position.z); + this.lighting.update( + dt, + this.player.camera.position, + this.player.position.x, + this.player.position.y, + this.player.position.z, + ); } this.sky.update(dt, this.player.camera.position); @@ -389,7 +389,8 @@ export class Game { * K cycle light visual mode (off → sun → block → combined) * T freeze / unfreeze the day-night clock * H toggle Babylon real-time shadows (voxel light stays on) - * [ and ] scrub time backward / forward + * [ and ] previous / next time preset (sunrise→noon→sunset→midnight) + * O and I speed up / slow down the day-night clock * ; and ' snap to midnight / midday */ private handleLightingDebugKey(code: string): void { @@ -414,18 +415,30 @@ export class Game { this.hud.showToast(on ? "Shadows: on" : "Shadows: off (voxel light only)"); break; } - case "BracketLeft": - dn.setTime(dn.timeOfDay - 0.02); + case "BracketLeft": { + const p = this.lighting.cyclePreset(false); + this.hud.showToast(`Time: ${p}`); + break; + } + case "BracketRight": { + const p = this.lighting.cyclePreset(true); + this.hud.showToast(`Time: ${p}`); + break; + } + case "KeyO": + this.lighting.faster(); + this.hud.showToast(`Time speed ×${dn.timeScale.toFixed(1)}`); break; - case "BracketRight": - dn.setTime(dn.timeOfDay + 0.02); + case "KeyI": + this.lighting.slower(); + this.hud.showToast(`Time speed ×${dn.timeScale.toFixed(1)}`); break; case "Semicolon": - dn.setTimeMidnight(); + dn.setMidnight(); this.hud.showToast("Time: midnight"); break; case "Quote": - dn.setTimeMidday(); + dn.setNoon(); this.hud.showToast("Time: midday"); break; default: diff --git a/src/game/World.ts b/src/game/World.ts index a3ef5fc..da01cb1 100644 --- a/src/game/World.ts +++ b/src/game/World.ts @@ -11,13 +11,13 @@ import { import { CHUNK_SIZE, CHUNK_HEIGHT, MAX_CHUNK_GEN_PER_FRAME, MAX_CHUNK_MESH_PER_FRAME } from "../constants"; import type { BlockId } from "../types"; import { Chunk } from "./Chunk"; -import { buildChunkGeometry, type BrightnessSampler } from "./ChunkMesher"; +import { buildChunkGeometry } from "./ChunkMesher"; import { TerrainGenerator, findGroundY } from "./TerrainGenerator"; import { VoxelLightEngine, lightKey } from "./lighting/VoxelLightEngine"; +import { VoxelTerrainMaterial } from "./lighting/VoxelTerrainMaterial"; import { LIGHT_MAX, MAX_CHUNK_LIGHT_PER_FRAME, - combineLight, lightToBrightness, type LightDebugMode, } from "./lighting/LightingConfig"; @@ -39,8 +39,12 @@ interface ChunkMeshes { */ export class World { readonly root: TransformNode; - readonly opaqueMaterial: StandardMaterial; - readonly cutoutMaterial: StandardMaterial; + /** + * 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. + */ + readonly terrainMaterial: VoxelTerrainMaterial; readonly waterMaterial: StandardMaterial; readonly generator: TerrainGenerator; /** Atlas must have hasAlpha=true for the cutout pass to alpha-test. */ @@ -55,7 +59,7 @@ export class World { readonly lighting = new VoxelLightEngine((x, y, z) => this.getBlock(x, y, z)); /** Chunks whose lighting must be recomputed before they (re)mesh. */ private readonly lightDirty = new Set(); - /** Active light debug overlay (changes the mesh brightness sampler). */ + /** Active light debug overlay (applied as a material uniform — no remesh). */ private lightDebugMode: LightDebugMode = "off"; constructor(seed: string, atlas: Texture, scene: Scene) { @@ -64,35 +68,16 @@ export class World { this.generator = new TerrainGenerator(seed); // The atlas uses clearRect for plantlike tiles; we need alpha for cutout. - // Mark hasAlpha once so the cutout material can alpha-test against it. atlas.hasAlpha = true; this.atlasHasAlpha = true; - // Opaque pass: textured + vertex-coloured, no specular (Lambert-like). - // (Babylon applies vertex colors automatically when the mesh has a color - // vertex buffer, so no useVertexColor flag is needed.) - // Explicit MATERIAL_OPAQUE ensures the texture's alpha channel is ignored - // even though we set hasAlpha=true above (shared atlas). - this.opaqueMaterial = new StandardMaterial("voxel-opaque", scene); - this.opaqueMaterial.diffuseTexture = atlas; - this.opaqueMaterial.specularColor = new Color3(0, 0, 0); - this.opaqueMaterial.useAlphaFromDiffuseTexture = false; - this.opaqueMaterial.backFaceCulling = false; - this.opaqueMaterial.transparencyMode = Material.MATERIAL_OPAQUE; - - // Cutout pass: plantlike decorations (alpha-tested, double-sided). - // MATERIAL_ALPHATEST hard-cuts fragments below alphaCutOff without - // blending — exactly the prior three.js alphaTest behaviour. - this.cutoutMaterial = new StandardMaterial("voxel-cutout", scene); - this.cutoutMaterial.diffuseTexture = atlas; - this.cutoutMaterial.specularColor = new Color3(0, 0, 0); - this.cutoutMaterial.useAlphaFromDiffuseTexture = true; - this.cutoutMaterial.alphaCutOff = 0.5; - this.cutoutMaterial.backFaceCulling = false; - this.cutoutMaterial.transparencyMode = Material.MATERIAL_ALPHATEST; + // Custom terrain shader for opaque + cutout passes. Both share one material + // 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 }); // Transparent pass: water (alpha-blended, no depth write, double-sided). - // MATERIAL_ALPHABLEND uses material.alpha as a uniform opacity. this.waterMaterial = new StandardMaterial("voxel-water", scene); this.waterMaterial.diffuseTexture = atlas; this.waterMaterial.specularColor = new Color3(0, 0, 0); @@ -102,6 +87,10 @@ export class World { this.waterMaterial.transparencyMode = Material.MATERIAL_ALPHABLEND; } + /** The opaque + cutout ShaderMaterial (shared). */ + get opaqueMaterial(): Material { return this.terrainMaterial.material; } + get cutoutMaterial(): Material { return this.terrainMaterial.material; } + private spiral(radius: number): Array<{ dx: number; dz: number; d: number }> { const cached = this.spiralCache.get(radius); if (cached) return cached; @@ -165,27 +154,23 @@ export class World { // --------------------------------------------------------- lighting --- /** - * Per-vertex brightness sampler handed to the mesher. Combines the sun + block - * light of the sampled cell with the face's directional shade. In a debug - * overlay mode it returns a raw channel value (grayscale) instead. + * Per-vertex light sample handed to the mesher. Bakes the sun + block light + * of the sampled cell (times the face shade and brightness curve) into two + * channels, plus the raw 0..1 levels for the debug overlay. * - * Sun light is baked at full day strength (sunFactor = 1); the day/night - * dimming is applied globally via Babylon light intensities so we never have - * to rebuild every mesh as the sun moves. + * Sun light is baked at FULL day strength here; the actual day/night dimming + * is applied later in the VoxelTerrainMaterial shader via the `uDayFactor` + * uniform — so the clock can sweep a whole day without rebuilding any mesh. */ - private readonly sampleBrightness: BrightnessSampler = (wx, wy, wz, shade) => { + private readonly sampleBrightness = (wx: number, wy: number, wz: number, shade: number) => { const sun = this.lighting.getSun(wx, wy, wz); const block = this.lighting.getBlockLight(wx, wy, wz); - switch (this.lightDebugMode) { - case "sun": - return sun / LIGHT_MAX; - case "block": - return block / LIGHT_MAX; - case "combined": - return combineLight(sun, block, 1) / LIGHT_MAX; - default: - return shade * lightToBrightness(combineLight(sun, block, 1)); - } + return { + sunBright: shade * lightToBrightness(sun), + blockBright: shade * lightToBrightness(block), + sunLevel: sun / LIGHT_MAX, + blockLevel: block / LIGHT_MAX, + }; }; /** @@ -235,13 +220,19 @@ export class World { } } - /** Switch the light debug overlay; rebuilds meshes so the change is visible. */ + /** + * Switch the light debug overlay. This is a shader uniform on the terrain + * material (the raw levels are already baked into vertex-colour .ba), so it + * toggles instantly with NO chunk remesh. + */ setLightDebugMode(mode: LightDebugMode): void { - if (this.lightDebugMode === mode) return; this.lightDebugMode = mode; - for (const chunk of this.chunks.values()) { - if (chunk.generated) chunk.dirty = true; - } + const code = mode === "sun" ? 1 : mode === "block" ? 2 : mode === "combined" ? 3 : 0; + const tint = + 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); } getLightDebugMode(): LightDebugMode { @@ -381,9 +372,9 @@ export class World { mesh.material = material; mesh.parent = this.root; mesh.isPickable = false; - // Opaque/cutout terrain receives Babylon shadow mapping; water does not - // (it's alpha-blended + depth-write-disabled, shadows would look wrong). - mesh.receiveShadows = slot !== "transparent"; + // Terrain uses a custom shader (no Babylon shadow receiving); water is + // alpha-blended. Either way, nothing here receives Babylon shadow maps. + mesh.receiveShadows = false; vd.applyToMesh(mesh, false); entry[slot] = mesh; } @@ -422,8 +413,7 @@ export class World { dispose(): void { for (const k of [...this.meshes.keys()]) this.disposeMeshes(k); this.chunks.clear(); - this.opaqueMaterial.dispose(); - this.cutoutMaterial.dispose(); + this.terrainMaterial.dispose(); this.waterMaterial.dispose(); this.root.dispose(); } diff --git a/src/game/lighting/CelestialSystem.ts b/src/game/lighting/CelestialSystem.ts new file mode 100644 index 0000000..12f9c8f --- /dev/null +++ b/src/game/lighting/CelestialSystem.ts @@ -0,0 +1,206 @@ +import { + Color3, + DynamicTexture, + Engine, + Material, + Mesh, + MeshBuilder, + Scene, + StandardMaterial, + Texture, + TransformNode, + Vector3, +} from "@babylonjs/core"; +import type { DayNightCycle } from "./DayNightCycle"; + +const SKY_DIST = 480; // how far the discs sit from the camera (inside the dome) +const SUN_SIZE = 42; +const HALO_SIZE = 96; +const MOON_SIZE = 30; + +/** + * The visual sun and moon — camera-facing discs anchored at sky distance, plus + * a soft additive halo around the sun. Positions, colours and opacity are all + * derived from {@link DayNightCycle} each frame, so the sun rises in the east, + * arcs overhead, sets in the west, and the moon mirrors it. + * + * These are pure visuals: they never cast or receive shadows, never enter the + * shadow render list, and ignore fog (so the sun stays a clean disc rather than + * a murky square at distance). + */ +export class CelestialSystem { + private readonly scene: Scene; + private readonly root: TransformNode; + private readonly sunDisc: Mesh; + private readonly sunHalo: Mesh; + private readonly moonDisc: Mesh; + private readonly sunMat: StandardMaterial; + private readonly haloMat: StandardMaterial; + private readonly moonMat: StandardMaterial; + private readonly sunTex: DynamicTexture; + private readonly haloTex: DynamicTexture; + private readonly moonTex: DynamicTexture; + + constructor(scene: Scene, parent: TransformNode) { + this.scene = scene; + this.root = new TransformNode("celestial-root", scene); + this.root.parent = parent; + + this.sunTex = makeRadialTexture("celestial-sun-tex", this.scene, ["#fff8e6", "#ffd98a", "rgba(255,200,120,0)"]); + this.haloTex = makeRadialTexture("celestial-halo-tex", this.scene, ["rgba(255,220,150,0.55)", "rgba(255,180,90,0.18)", "rgba(255,160,80,0)"]); + this.moonTex = makeMoonTexture("celestial-moon-tex", this.scene); + + this.sunMat = this.discMaterial("celestial-sun-mat", this.sunTex, Color3.White(), false); + this.haloMat = this.discMaterial("celestial-halo-mat", this.haloTex, Color3.White(), true); + this.moonMat = this.discMaterial("celestial-moon-mat", this.moonTex, Color3.FromHexString("#dce8ff"), false); + + this.sunDisc = this.makeDisc("sun", SUN_SIZE, this.sunMat); + this.sunHalo = this.makeDisc("sun-halo", HALO_SIZE, this.haloMat); + this.moonDisc = this.makeDisc("moon", MOON_SIZE, this.moonMat); + } + + private makeDisc(name: string, size: number, material: Material): Mesh { + const m = MeshBuilder.CreatePlane(`celestial-${name}`, { size }, this.scene); + m.material = material; + m.parent = this.root; + // Always face the camera (disc) but stay positioned out at sky distance. + m.billboardMode = Mesh.BILLBOARDMODE_ALL; + m.isPickable = false; + m.applyFog = false; // sun/moon ignore fog so they stay crisp discs + m.receiveShadows = false; // never receive or cast shadows + m.alwaysSelectAsActiveMesh = true; // visible even though "far" away + return m; + } + + private discMaterial(name: string, tex: DynamicTexture, tint: Color3, additive: boolean): StandardMaterial { + const mat = new StandardMaterial(name, this.scene); + mat.emissiveTexture = tex; + mat.opacityTexture = tex; // radial alpha shapes the disc + mat.emissiveColor = tint; + mat.disableLighting = true; + mat.backFaceCulling = false; + mat.disableDepthWrite = true; // don't occlude each other / sky + if (additive) { + mat.transparencyMode = Material.MATERIAL_ALPHABLEND; + mat.alphaMode = Engine.ALPHA_ADD; // glow adds light + } else { + mat.transparencyMode = Material.MATERIAL_ALPHABLEND; + } + return mat; + } + + /** Reposition the sun/moon and update their colour/opacity for this frame. */ + update(cameraPosition: Vector3, dn: DayNightCycle): void { + const cam = cameraPosition; + // Sun (disc + halo) sits where the sun actually is in the sky. + const sunPos = cam.add(dn.sunSkyDirection.scale(SKY_DIST)); + this.sunDisc.setAbsolutePosition(sunPos); + this.sunHalo.setAbsolutePosition(sunPos); + // Moon sits opposite the sun. + this.moonDisc.setAbsolutePosition(cam.add(dn.moonSkyDirection.scale(SKY_DIST))); + + const sunVis = dn.sunVisibility; + const moonVis = dn.moonVisibility; + this.sunDisc.visibility = sunVis; + this.sunHalo.visibility = sunVis; + this.moonDisc.visibility = moonVis; + + // Warm up the sun disc near the horizon; keep the halo matched to it. + const warm = Color3.Lerp(Color3.White(), dn.sunColor, 0.7); + this.sunMat.emissiveColor = warm; + this.haloMat.emissiveColor = Color3.Lerp(Color3.White(), dn.sunColor, 0.5); + this.moonMat.emissiveColor = dn.moonColor; + + // Keep sun/moon/halo in the SAME rendering group as the terrain (0). Babylon + // clears depth between rendering groups, so a higher group would lose the + // terrain depth buffer and the sun would show through blocks. Here the discs + // render in the transparent pass after opaque terrain (depth-write off, + // depth-test on) → terrain in front occludes them, and they still draw over + // the depth-write-disabled sky dome. The halo must paint before the disc, so + // force it to render earlier via its render order (additive, no depth write). + this.sunHalo.renderingGroupId = 0; + this.sunDisc.renderingGroupId = 0; + this.moonDisc.renderingGroupId = 0; + } + + dispose(): void { + this.sunDisc.dispose(); + this.sunHalo.dispose(); + this.moonDisc.dispose(); + this.sunMat.dispose(); + this.haloMat.dispose(); + this.moonMat.dispose(); + this.sunTex.dispose(); + this.haloTex.dispose(); + this.moonTex.dispose(); + this.root.dispose(); + } +} + +/** A soft radial-gradient texture (inner → outer → transparent edge). */ +function makeRadialTexture( + name: string, + scene: Scene, + stops: [string, string, string], +): DynamicTexture { + const size = 128; + const tex = new DynamicTexture(name, { width: size, height: size }, scene, false, Texture.LINEAR_LINEAR, undefined, false); + tex.hasAlpha = true; + const ctx = tex.getContext() as CanvasRenderingContext2D; + const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + g.addColorStop(0, stops[0]); + g.addColorStop(0.5, stops[1]); + g.addColorStop(1, stops[2]); + ctx.fillStyle = g; + ctx.fillRect(0, 0, size, size); + tex.update(false); + return tex; +} + +/** A pale moon disc with a few darker crater speckles and a soft limb. */ +function makeMoonTexture(name: string, scene: Scene): DynamicTexture { + const size = 128; + const tex = new DynamicTexture(name, { width: size, height: size }, scene, false, Texture.LINEAR_LINEAR, undefined, false); + tex.hasAlpha = true; + const ctx = tex.getContext() as CanvasRenderingContext2D; + const c = size / 2; + const r = size / 2 - 2; + // Soft circular alpha mask (slightly fuzzy limb). + const alpha = ctx.createRadialGradient(c, c, r * 0.7, c, c, r); + alpha.addColorStop(0, "rgba(0,0,0,1)"); + alpha.addColorStop(1, "rgba(0,0,0,0)"); + ctx.clearRect(0, 0, size, size); + // Disc body (pale blue-white), shaded toward the limb for a round look. + const body = ctx.createRadialGradient(c - r * 0.25, c - r * 0.25, r * 0.2, c, c, r); + body.addColorStop(0, "#f4f8ff"); + body.addColorStop(0.7, "#d4e0f4"); + body.addColorStop(1, "#9fb4d8"); + ctx.fillStyle = body; + ctx.beginPath(); + ctx.arc(c, c, r, 0, Math.PI * 2); + ctx.fill(); + // Crater speckles. + let seed = 9173; + const rng = () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + }; + for (let i = 0; i < 9; i++) { + const a = rng() * Math.PI * 2; + const rr = rng() * r * 0.7; + const x = c + Math.cos(a) * rr; + const y = c + Math.sin(a) * rr; + const rad = 2 + rng() * 5; + ctx.fillStyle = `rgba(150,165,195,${0.25 + rng() * 0.3})`; + ctx.beginPath(); + ctx.arc(x, y, rad, 0, Math.PI * 2); + ctx.fill(); + } + // Apply the soft limb mask to the alpha channel. + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = alpha; + ctx.fillRect(0, 0, size, size); + ctx.globalCompositeOperation = "source-over"; + tex.update(false); + return tex; +} diff --git a/src/game/lighting/DayNightCycle.ts b/src/game/lighting/DayNightCycle.ts index 491ac86..152f77b 100644 --- a/src/game/lighting/DayNightCycle.ts +++ b/src/game/lighting/DayNightCycle.ts @@ -1,46 +1,93 @@ import { Color3, Color4, DirectionalLight, HemisphericLight, Scene, Vector3 } from "@babylonjs/core"; -import { DAY_LENGTH_SECONDS, TIME_MIDDAY, sunBrightnessAt } from "./LightingConfig"; - -const SKY_DAY = Color3.FromHexString("#bfe3ff"); -const SKY_NIGHT = Color3.FromHexString("#0b1026"); -const SUN_DAY = Color3.FromHexString("#fff4e0"); -const SUN_DUSK = Color3.FromHexString("#ff9a4a"); -const HEMI_DAY = Color3.FromHexString("#bfe3ff"); -const HEMI_GROUND = Color3.FromHexString("#4a6b3a"); +import { + DAY_LENGTH_SECONDS, + MOON_FLOOR, + TIME_MIDDAY, + TIME_MIDNIGHT, + TIME_SUNRISE, + TIME_SUNSET, + dayFactorAt, + moonFactorAt, + smoothstep, + sunElevationAt, +} from "./LightingConfig"; + +// Sky colour stops (zenith / horizon). Tweaked for a warm golden hour and a +// deep, slightly-purple night — modelled on Luanti/Minecraft day/night palettes. +const SKY = { + nightZenith: Color3.FromHexString("#070b1e"), + nightHorizon: Color3.FromHexString("#121a3a"), + dayZenith: Color3.FromHexString("#2f6fdb"), + dayHorizon: Color3.FromHexString("#bfe3ff"), + duskZenith: Color3.FromHexString("#3a2a5a"), + duskHorizon: Color3.FromHexString("#ff8a4a"), +}; +const SUN_COLOR_HIGH = Color3.FromHexString("#fff4e0"); // midday warm-white +const SUN_COLOR_LOW = Color3.FromHexString("#ff7a33"); // sunrise/sunset orange +const MOON_COLOR = Color3.FromHexString("#cfe0ff"); // pale cool moonlight /** - * Drives the global Babylon lights (sun + ambient + hemisphere) and the sky / - * fog colours from a `timeOfDay` value. This is the *global* day/night layer; - * voxel (cave/overhang) darkness comes from the baked vertex colours and is - * independent of this. + * The game's clock and the single source of truth for everything time-derived. + * + * timeOfDay ∈ [0,1): 0.00 midnight · 0.25 sunrise · 0.50 noon · 0.75 sunset * - * timeOfDay ∈ [0,1): 0.0 midnight · 0.25 sunrise · 0.5 midday · 0.75 sunset + * The sun orbits the world in the X–Y plane (rises in +X/east, sets in −X/west) + * with a slight −Z tilt so shadows/visuals aren't axis-aligned. The moon is the + * sun's exact antipode — it rises at sunset and rides opposite the sun. * - * The sun direction is derived from the time so shadows point the right way. - * Time can be paused/frozen for debugging and snapped to day/night. + * Each {@link update} advances the clock and recomputes (and publishes) the + * derived state: sun/moon directions, day/moon factors, multi-stop sky colours + * and the Babylon light intensities (used by the water pass + entities). The + * voxel terrain reads `dayFactor`/`moonFactor` as shader uniforms, so the clock + * never forces a chunk remesh. */ export class DayNightCycle { /** Current time of day in [0,1). */ timeOfDay = TIME_MIDDAY; + /** Real-time seconds for one full day (midday→midday). */ + dayLengthSeconds = DAY_LENGTH_SECONDS; + /** Multiplier on the clock speed (1 = real-time day length, 10 = fast debug). */ + timeScale = 1; /** When true, time does not advance (debugging). */ paused = false; - /** Multiplier on real-time day length (1 = DAY_LENGTH_SECONDS per full day). */ - timeScale = 1; + + // ---- Derived state (recomputed every update; read by consumers) ---- + /** Sun elevation, -1 (nadir) .. 1 (zenith). */ + sunElevation = 1; + /** Direction light travels from the sun toward the scene (unit). */ + sunDirection = new Vector3(0, -1, 0); + /** Direction light travels from the moon toward the scene (unit). */ + moonDirection = new Vector3(0, 1, 0); + /** Unit direction from the camera toward the sun disc (for placement). */ + sunSkyDirection = new Vector3(0, 1, 0); + /** Unit direction from the camera toward the moon disc (for placement). */ + moonSkyDirection = new Vector3(0, -1, 0); + /** 0 (night) .. 1 (full day) — multiplies the terrain SUN channel. */ + dayFactor = 1; + /** Moonlight contribution to the terrain sun channel at night. */ + moonFactor = 0; + /** 0 (sun well below horizon) .. 1 (sun at horizon) — golden-hour warmth. */ + goldenHour = 0; + /** Current sun colour (warm at horizon, white at noon). */ + sunColor = SUN_COLOR_HIGH.clone(); + /** Current moon colour. */ + moonColor = MOON_COLOR.clone(); + /** Current sky zenith colour. */ + skyZenith = SKY.dayZenith.clone(); + /** Current sky horizon colour. */ + skyHorizon = SKY.dayHorizon.clone(); + + // Tunable Babylon-light intensities (drive the non-terrain StandardMaterial + // passes, e.g. water; terrain uses the custom shader instead). + sunIntensityDay = 0.55; + ambientIntensityDay = 0.35; + hemiIntensityDay = 0.25; + ambientIntensityNight = 0.1; private readonly sun: DirectionalLight; private readonly ambient: HemisphericLight; private readonly hemi: HemisphericLight; private readonly scene: Scene; - /** Cached initial sky/fog colour to restore on dispose. */ - private readonly baseFog: Color3; - - /** Tunable intensities (midday values); night floors are derived from these. - * Kept just above ~1.0 combined so baked vertex colours (the dominant - * brightness control) aren't washed out at midday. */ - sunIntensityDay = 0.5; - ambientIntensityDay = 0.35; - hemiIntensityDay = 0.25; - ambientIntensityNight = 0.1; constructor( sun: DirectionalLight, @@ -52,75 +99,115 @@ export class DayNightCycle { this.ambient = ambient; this.hemi = hemi; this.scene = scene; - this.baseFog = (scene.fogColor ?? SKY_DAY).clone(); this.apply(); } - /** Advance time and refresh light/sky state. */ + // ---- clock ---- + + /** Advance the clock and refresh all derived state. */ update(dt: number): void { - if (!this.paused) { - this.timeOfDay = (this.timeOfDay + (dt * this.timeScale) / DAY_LENGTH_SECONDS) % 1; + if (!this.paused && this.timeScale !== 0) { + this.timeOfDay = + (this.timeOfDay + (dt * this.timeScale) / this.dayLengthSeconds) % 1; if (this.timeOfDay < 0) this.timeOfDay += 1; } this.apply(); } - /** 0 (full night) .. 1 (full day) brightness factor for the sun channel. */ - get dayFactor(): number { - return sunBrightnessAt(this.timeOfDay); - } - - setTime(t: number): void { + setTimeOfDay(t: number): void { this.timeOfDay = t - Math.floor(t); this.apply(); } - setPaused(paused: boolean): void { - this.paused = paused; + setSunrise(): void { this.setTimeOfDay(TIME_SUNRISE); } + setNoon(): void { this.setTimeOfDay(TIME_MIDDAY); } + setSunset(): void { this.setTimeOfDay(TIME_SUNSET); } + setMidnight(): void { this.setTimeOfDay(TIME_MIDNIGHT); } + /** Alias: "day" = bright noon. */ + setDay(): void { this.setNoon(); } + + pauseTime(): void { this.paused = true; } + resumeTime(): void { this.paused = false; } + setPaused(p: boolean): void { this.paused = p; } + togglePaused(): boolean { this.paused = !this.paused; return this.paused; } + + /** Multiply the clock speed (clamped to keep it sane). */ + scaleTime(factor: number): void { + this.timeScale = Math.max(0, Math.min(64, this.timeScale * factor)); } - setTimeMidday(): void { - this.setTime(TIME_MIDDAY); + /** Advance/rewind by a number of preset steps (used by [ / ] debug keys). */ + stepTime(steps: number): void { + this.setTimeOfDay(this.timeOfDay + steps * 0.02); } - setTimeMidnight(): void { - this.setTime(0); - } - - /** Derived sun direction (pointing from the sun toward the scene). */ - get sunDirection(): Vector3 { - // Midday → steep from above; midnight → low/below horizon. A fixed azimuth - // keeps shadow directions stable and readable. - const a = (this.timeOfDay - TIME_MIDDAY) * Math.PI * 2; - const elev = Math.cos(a); // 1 at midday, -1 at midnight - const dir = new Vector3(-0.5, -Math.max(elev, -0.12), -0.42); - return dir.normalize(); - } + // ---- derived state ---- - /** Push the current time into the Babylon lights + sky/fog colours. */ private apply(): void { - const d = this.dayFactor; // 0..1 - - // Sun: intensity ramps with the day factor; warm/reddish near the horizon. + const t = this.timeOfDay; + this.sunElevation = sunElevationAt(t); + this.dayFactor = dayFactorAt(t); + this.moonFactor = moonFactorAt(t) * MOON_FLOOR; + + // --- Sun & moon orbital directions --- + // sunAngle: 0 at sunrise, π/2 noon, π sunset, 3π/2 midnight. + const sunAngle = (t - TIME_SUNRISE) * Math.PI * 2; + const cosA = Math.cos(sunAngle); + const sinA = Math.sin(sunAngle); + // Light travels from sun toward scene. Sun sits in +X at sunrise. + const dir = new Vector3(-cosA, -sinA, -0.35); + dir.normalize(); + this.sunDirection = dir; + this.moonDirection = dir.scale(-1); // moon is the antipode + this.sunSkyDirection = dir.scale(-1); // where the disc appears in the sky + this.moonSkyDirection = dir; // moon disc opposite the sun disc + + // --- Golden-hour warmth: peaks when the sun is near the horizon. --- + this.goldenHour = 1 - Math.min(1, Math.abs(this.sunElevation) / 0.3); + + // --- Sky colours: day↔night base, then bleed dusk warmth at the horizon. --- + const d = this.dayFactor; + const baseZen = Color3.Lerp(SKY.nightZenith, SKY.dayZenith, d); + const baseHor = Color3.Lerp(SKY.nightHorizon, SKY.dayHorizon, d); + const g = this.goldenHour; + this.skyZenith = Color3.Lerp(baseZen, SKY.duskZenith, g * 0.4); + this.skyHorizon = Color3.Lerp(baseHor, SKY.duskHorizon, g * 0.75); + + // --- Sun/moon colours --- + this.sunColor = Color3.Lerp(SUN_COLOR_HIGH, SUN_COLOR_LOW, g); + this.moonColor = MOON_COLOR.clone(); + + // --- Push into Babylon lights (used by the water pass + entities) --- this.sun.direction = this.sunDirection; this.sun.intensity = this.sunIntensityDay * d; - const horizonness = 1 - Math.min(1, d * 3); // 1 near horizon, 0 midday - this.sun.diffuse = Color3.Lerp(SUN_DAY, SUN_DUSK, horizonness); - - // Ambient fill: a small night floor so unlit areas aren't pure black. - this.ambient.intensity = this.ambientIntensityNight + (this.ambientIntensityDay - this.ambientIntensityNight) * d; + this.sun.diffuse = this.sunColor; + + // Ambient: small night floor + a touch of moonlight; never bright enough to + // light caves (caves have no sun channel and the custom shader ignores this). + this.ambient.intensity = + this.ambientIntensityNight + + (this.ambientIntensityDay - this.ambientIntensityNight) * d + + this.moonFactor * 0.4; this.ambient.diffuse = Color3.White(); this.ambient.groundColor = Color3.White(); - // Hemisphere sky/ground bounce: fades toward a dim night value. this.hemi.intensity = 0.04 + this.hemiIntensityDay * d; - this.hemi.diffuse = Color3.Lerp(SKY_NIGHT, HEMI_DAY, d); - this.hemi.groundColor = HEMI_GROUND; + this.hemi.diffuse = Color3.Lerp(SKY.nightHorizon, SKY.dayHorizon, d); + this.hemi.groundColor = Color3.FromHexString("#4a6b3a"); - // Sky clear + fog colour: blue by day, deep navy at night. - const sky = Color3.Lerp(SKY_NIGHT, SKY_DAY, d); + // --- Sky clear + fog colour track the horizon --- + const sky = this.skyHorizon; this.scene.clearColor = new Color4(sky.r, sky.g, sky.b, 1); this.scene.fogColor = sky.clone(); - void this.baseFog; + } + + /** Sun disc opacity (0 when below the horizon, fades in as it rises). */ + get sunVisibility(): number { + return smoothstep(-0.06, 0.06, this.sunElevation); + } + + /** Moon disc opacity (0 when below the horizon). */ + get moonVisibility(): number { + return smoothstep(-0.06, 0.06, -this.sunElevation); } } diff --git a/src/game/lighting/LightingConfig.ts b/src/game/lighting/LightingConfig.ts index 37d4491..659c41f 100644 --- a/src/game/lighting/LightingConfig.ts +++ b/src/game/lighting/LightingConfig.ts @@ -57,11 +57,53 @@ export function combineLight( // --- Day / night --- -/** Length of a full day in real-world seconds (midday→midday). */ +/** Length of a full day in real-world seconds (midday→midday). ~10 min. */ export const DAY_LENGTH_SECONDS = 600; /** timeOfDay in [0,1): 0 = midnight, 0.25 = sunrise, 0.5 = midday, 0.75 = sunset. */ -export const TIME_MIDDAY = 0.5; export const TIME_MIDNIGHT = 0; +export const TIME_SUNRISE = 0.25; +export const TIME_MIDDAY = 0.5; +export const TIME_SUNSET = 0.75; + +/** + * Maximum moonlight contribution to the SUN channel at night (as a fraction of + * full sun brightness). Outdoor areas get at least `sun·moonFloor` so they are + * never pitch black; caves stay dark because their sun channel is ~0. + */ +export const MOON_FLOOR = 0.16; + +/** GLSL-style smoothstep clamped to [0,1]. */ +export function smoothstep(edge0: number, edge1: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +/** + * Sun elevation (-1..1) for a time-of-day, where 1 = noon (overhead), + * 0 = sunrise/sunset (horizon), -1 = midnight (nadir). Derived from the sun's + * orbital angle so it matches the visual sun/moon positions exactly. + */ +export function sunElevationAt(timeOfDay: number): number { + const angle = (timeOfDay - TIME_SUNRISE) * Math.PI * 2; + return Math.sin(angle); +} + +/** + * Daylight factor for the SUN channel (0 at night → 1 midday) with a smooth + * twilight band around sunrise/sunset. Used as a shader uniform so day/night + * can dim outdoor terrain WITHOUT rebuilding any chunk meshes. + */ +export function dayFactorAt(timeOfDay: number): number { + return smoothstep(-0.12, 0.18, sunElevationAt(timeOfDay)); +} + +/** + * Moonlight factor for the SUN channel (peaks at midnight, 0 by day). Provides + * a subtle outdoor floor at night. + */ +export function moonFactorAt(timeOfDay: number): number { + return smoothstep(-0.12, 0.18, -sunElevationAt(timeOfDay)); +} /** * Convert a time-of-day fraction to the brightness multiplier applied to the @@ -70,9 +112,7 @@ export const TIME_MIDNIGHT = 0; * matches the visual sun position. */ export function sunBrightnessAt(timeOfDay: number): number { - // Sun elevation angle: highest at midday (cos = 1), lowest at midnight (cos = -1). - const angle = (timeOfDay - TIME_MIDDAY) * Math.PI * 2; - const elev = Math.cos(angle); // -1..1 + const elev = sunElevationAt(timeOfDay); if (elev <= 0) return 0.04; // below horizon → night floor // Smooth ramp so twilight isn't a hard cut. const day = Math.pow(elev, 0.6); @@ -125,7 +165,12 @@ export interface ShadowConfig { } export const DEFAULT_SHADOW_CONFIG: ShadowConfig = { - enabled: true, + // Disabled by default: the terrain renders through the custom two-channel + // VoxelTerrainMaterial which does its own (voxel-sunlight) shadowing. The + // ShadowManager is retained for opt-in Babylon shadow maps (e.g. a future + // player mesh) but is off to keep day/night simple and free of the prior + // shadow-frustum rectangle artifact. + enabled: false, mapSize: 2048, // Frustum half-extent 80 (ortho 160). Caster radius 48 → ~32-block (2-chunk) // fade margin so caster shadows never touch the frustum edge. diff --git a/src/game/lighting/LightingDebugOverlay.ts b/src/game/lighting/LightingDebugOverlay.ts index 6a3a22f..430c6bb 100644 --- a/src/game/lighting/LightingDebugOverlay.ts +++ b/src/game/lighting/LightingDebugOverlay.ts @@ -4,9 +4,14 @@ import { getBlock, resolveLight } from "../Blocks"; export interface LightDebugInfo { enabled: boolean; timeOfDay: number; + timeScale: number; dayFactor: number; + moonFactor: number; sunIntensity: number; ambientIntensity: number; + sunDirection: { x: number; y: number; z: number }; + sunVisible: number; + moonVisible: number; debugMode: string; shadowsEnabled: boolean; paused: boolean; @@ -87,10 +92,13 @@ export class LightingDebugOverlay { if (!this.visible) return; const t = info.target; const fmt = (n: number) => (n >= 0 ? n.toFixed(2) : "—"); + const sd = info.sunDirection; const lines: string[] = [ - `time: ${formatTime(info.timeOfDay)}${info.paused ? " (frozen)" : ""} day=${fmt(info.dayFactor)}`, - `sun: ${fmt(info.sunIntensity)} ambient: ${fmt(info.ambientIntensity)}`, - `shadows:${info.shadowsEnabled ? " on" : " off"} mode: ${info.debugMode}`, + `time: ${formatTime(info.timeOfDay)}${info.paused ? " (frozen)" : ""} ×${fmt(info.timeScale)}`, + `day=${fmt(info.dayFactor)} moon=${fmt(info.moonFactor)}`, + `sunDir: (${sd.x.toFixed(2)},${sd.y.toFixed(2)},${sd.z.toFixed(2)}) vis s/m ${fmt(info.sunVisible)}/${fmt(info.moonVisible)}`, + `sun I=${fmt(info.sunIntensity)} ambient=${fmt(info.ambientIntensity)}`, + `shadows:${info.shadowsEnabled ? " on" : " off"} lightView: ${info.debugMode}`, `chunks: lit=${info.litCount}/${info.loadedCount} relightQueue=${info.dirtyCount}`, ]; if (t) { diff --git a/src/game/lighting/LightingSystem.ts b/src/game/lighting/LightingSystem.ts index a6ca64b..d45544a 100644 --- a/src/game/lighting/LightingSystem.ts +++ b/src/game/lighting/LightingSystem.ts @@ -1,6 +1,8 @@ -import type { DirectionalLight, HemisphericLight, Scene } from "@babylonjs/core"; +import type { Color3, Scene, Vector3 } from "@babylonjs/core"; import type { World } from "../World"; +import type { Sky } from "../../engine/Sky"; import { DayNightCycle } from "./DayNightCycle"; +import { CelestialSystem } from "./CelestialSystem"; import { ShadowManager } from "./ShadowManager"; import { LightingDebugOverlay, buildTargetInfo, type LightDebugInfo } from "./LightingDebugOverlay"; import { DEFAULT_SHADOW_CONFIG, LIGHT_MAX, type LightDebugMode, type ShadowConfig } from "./LightingConfig"; @@ -9,64 +11,103 @@ import { DEFAULT_SHADOW_CONFIG, LIGHT_MAX, type LightDebugMode, type ShadowConfi * Top-level facade that wires together every lighting subsystem so the rest of * the game (Game.ts) only talks to one object. * - * VoxelLightEngine — per-voxel sun + block light (owned by World) - * DayNightCycle — drives Babylon sun/ambient/hemi + sky/fog colours - * ShadowManager — nearby-chunk directional shadow mapping + * DayNightCycle — the clock; source of truth for all time-derived state + * CelestialSystem — visual sun disc + halo + moon (camera-anchored) + * VoxelTerrainMaterial — two-channel terrain shader (owned by World) + * ShadowManager — opt-in Babylon shadow maps (disabled by default) * LightingDebugOverlay — live light-value inspection panel * - * The voxel light field is owned by {@link World} (it needs block access); - * this class holds a typed reference for queries/debug. Lighting never runs in - * the render loop unless something changed — the World queues dirty chunks and - * relights them on a budget. + * Each frame this advances the clock and pushes a handful of uniforms + * (dayFactor, moonFloor, fog, dome colours, sun/moon positions). No chunk is + * ever remeshed because of the time of day. */ export class LightingSystem { readonly dayNight: DayNightCycle; + readonly celestial: CelestialSystem; readonly shadows: ShadowManager; readonly overlay: LightingDebugOverlay; readonly config: { shadows: ShadowConfig }; private readonly world: World; + private readonly sky: Sky; + private readonly scene: Scene; - constructor( - world: World, - sun: DirectionalLight, - ambient: HemisphericLight, - hemi: HemisphericLight, - scene: Scene, - ) { + constructor(world: World, sky: Sky, scene: Scene) { this.world = world; - this.dayNight = new DayNightCycle(sun, ambient, hemi, scene); - this.shadows = new ShadowManager(sun, world, { ...DEFAULT_SHADOW_CONFIG }); + this.sky = sky; + this.scene = scene; + this.dayNight = new DayNightCycle(sky.sun, sky.ambient, sky.hemi, scene); + this.celestial = new CelestialSystem(scene, sky.root); + this.shadows = new ShadowManager(sky.sun, world, { ...DEFAULT_SHADOW_CONFIG }); this.overlay = new LightingDebugOverlay(); this.config = { shadows: this.shadows.config }; } - /** Per-frame: advance time, follow player with shadows. */ - update(dt: number, playerX: number, playerY: number, playerZ: number): void { - this.dayNight.update(dt); + /** + * Per-frame: advance the clock, position the sun/moon, and push the live + * day/night uniforms into the terrain shader + sky dome + fog. No remeshing. + */ + update(dt: number, cameraPosition: Vector3, playerX: number, playerY: number, playerZ: number): void { + const dn = this.dayNight; + dn.update(dt); + + // Visuals: sun/moon discs + sky dome gradient. + this.celestial.update(cameraPosition, dn); + this.sky.setDomeColours(dn.skyZenith, dn.skyHorizon); + + // Terrain shader uniforms: sun channel × dayFactor (+ moonlight floor), + // block channel untouched. Fog tracks the horizon colour. + const fogColor: Color3 = dn.skyHorizon; + this.world.terrainMaterial.setDayNight(dn.dayFactor, dn.moonFactor); + this.world.terrainMaterial.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); } - // ---- shadow controls (Babylon real-time shadow; voxel light is independent) ---- - - toggleShadows(): boolean { - return this.shadows.toggle(); - } - - get shadowsEnabled(): boolean { - return this.shadows.enabled; + // ---- clock / time controls ---- + + setTimeOfDay(t: number): void { this.dayNight.setTimeOfDay(t); } + setSunrise(): void { this.dayNight.setSunrise(); } + setNoon(): void { this.dayNight.setNoon(); } + setSunset(): void { this.dayNight.setSunset(); } + setMidnight(): void { this.dayNight.setMidnight(); } + /** Cycle the time presets: sunrise → noon → sunset → midnight → sunrise. */ + cyclePreset(forward: boolean): string { + const presets = ["sunrise", "noon", "sunset", "midnight"] as const; + const times = [0.25, 0.5, 0.75, 0.0]; + const cur = this.dayNight.timeOfDay; + // nearest preset index + let idx = 0; + let best = Infinity; + for (let i = 0; i < times.length; i++) { + const d = Math.min(Math.abs(cur - times[i]), 1 - Math.abs(cur - times[i])); + if (d < best) { best = d; idx = i; } + } + idx = (idx + (forward ? 1 : presets.length - 1)) % presets.length; + this.setTimeOfDay(times[idx]); + return presets[idx]; } + pauseTime(): void { this.dayNight.pauseTime(); } + resumeTime(): void { this.dayNight.resumeTime(); } + togglePause(): boolean { return this.dayNight.togglePaused(); } + faster(): void { this.dayNight.scaleTime(1.5); } + slower(): void { this.dayNight.scaleTime(1 / 1.5); } - dumpShadowDiagnostics(): unknown { - return this.shadows.dumpDiagnostics(); - } + // ---- shadow controls (Babylon real-time shadow; voxel light is independent) ---- - // ---- debug controls ---- + toggleShadows(): boolean { return this.shadows.toggle(); } + get shadowsEnabled(): boolean { return this.shadows.enabled; } + dumpShadowDiagnostics(): unknown { return this.shadows.dumpDiagnostics(); } - setDebugMode(mode: LightDebugMode): void { - this.world.setLightDebugMode(mode); - } + // ---- debug overlay ---- + setDebugMode(mode: LightDebugMode): void { this.world.setLightDebugMode(mode); } cycleDebugMode(): LightDebugMode { const order: LightDebugMode[] = ["off", "sun", "block", "combined"]; const cur = this.world.getLightDebugMode(); @@ -74,10 +115,7 @@ export class LightingSystem { this.setDebugMode(next); return next; } - - getDebugMode(): LightDebugMode { - return this.world.getLightDebugMode(); - } + getDebugMode(): LightDebugMode { return this.world.getLightDebugMode(); } /** Build the throttled overlay payload for the block the player aims at. */ buildDebugInfo(target: { x: number; y: number; z: number; block: number } | null): LightDebugInfo { @@ -89,22 +127,27 @@ export class LightingSystem { const combined = this.world.lighting.getCombined(x, y, z, this.dayNight.dayFactor); t = buildTargetInfo(block, x, y, z, sun, bl, combined); } - // Count dirty/light state by scanning loaded chunks via the world. let loaded = 0; let lit = 0; this.world.forEachOpaqueMesh((cx, cz) => { loaded++; if (this.world.lighting.hasLight(cx, cz)) lit++; - void cx; - void cz; }); - // forEachOpaqueMesh only covers meshed chunks; approximate counts are fine. return { enabled: this.overlay.visible, timeOfDay: this.dayNight.timeOfDay, + timeScale: this.dayNight.timeScale, dayFactor: this.dayNight.dayFactor, + moonFactor: this.dayNight.moonFactor, sunIntensity: this.dayNight["sun"].intensity, ambientIntensity: this.dayNight["ambient"].intensity, + sunDirection: { + x: this.dayNight.sunDirection.x, + y: this.dayNight.sunDirection.y, + z: this.dayNight.sunDirection.z, + }, + sunVisible: this.dayNight.sunVisibility, + moonVisible: this.dayNight.moonVisibility, debugMode: this.getDebugMode(), shadowsEnabled: this.shadows.enabled, paused: this.dayNight.paused, @@ -115,13 +158,13 @@ export class LightingSystem { }; } - /** Best-effort access to the World's pending light-update count. */ private lightDirtyCount(): number { const w = this.world as unknown as { lightDirty?: { size: number } }; return w.lightDirty?.size ?? 0; } dispose(): void { + this.celestial.dispose(); this.shadows.dispose(); this.overlay.dispose(); } diff --git a/src/game/lighting/VoxelTerrainMaterial.ts b/src/game/lighting/VoxelTerrainMaterial.ts new file mode 100644 index 0000000..46e9936 --- /dev/null +++ b/src/game/lighting/VoxelTerrainMaterial.ts @@ -0,0 +1,167 @@ +import { Color3, Scene, ShaderMaterial, Texture, Vector3 } from "@babylonjs/core"; + +// Inline GLSL ES 1.00 sources (Babylon migrates them to GLSL3 for WebGL2). Kept +// minimal: sample the atlas, combine the two baked light channels with the live +// day/night uniforms, and apply linear fog. No lighting/shadow/fog includes → +// simple and artifact-free. Sources are passed inline (vertexSource/fragmentSource, +// same pattern as the working sky-dome material) so there is no ShaderStore +// lookup dependency. +const vertexSource = /* glsl */ ` +precision highp float; +attribute vec3 position; +attribute vec2 uv; +attribute vec4 color; +uniform mat4 world; +uniform mat4 worldViewProjection; +uniform vec3 uCameraPos; +varying vec2 vUV; +varying vec4 vColor; +varying float vFogDist; +void main() { + vec4 wp = world * vec4(position, 1.0); + vFogDist = length(uCameraPos - wp.xyz); + vUV = uv; + vColor = color; + gl_Position = worldViewProjection * vec4(position, 1.0); +} +`; + +const fragmentSource = /* glsl */ ` +precision highp float; +varying vec2 vUV; +varying vec4 vColor; +varying float vFogDist; +uniform sampler2D uTexture; +uniform float uDayFactor; +uniform float uMoonFloor; +uniform float uAlphaCutOff; +uniform vec3 uFogColor; +uniform float uFogStart; +uniform float uFogEnd; +uniform float uDebugMode; +uniform vec3 uDebugTint; +void main() { + vec4 tex = texture2D(uTexture, vUV); + if (tex.a < uAlphaCutOff) discard; + float brightness; + if (uDebugMode > 0.5) { + float s = vColor.b; + float b = vColor.a; + if (uDebugMode > 2.5) { + brightness = max(s, b); + } else if (uDebugMode > 1.5) { + brightness = b; + } else { + brightness = s; + } + gl_FragColor = vec4(uDebugTint * brightness, 1.0); + return; + } + float sun = vColor.r; + float block = vColor.g; + brightness = max(max(sun * uDayFactor, sun * uMoonFloor), block); + vec3 color = tex.rgb * brightness; + float fog = clamp((uFogEnd - vFogDist) / (uFogEnd - uFogStart), 0.0, 1.0); + color = mix(uFogColor, color, fog); + gl_FragColor = vec4(color, 1.0); +} +`; + +export interface VoxelTerrainMaterialOptions { + /** Atlas texture to sample. */ + texture: Texture; + /** Alpha-test threshold. Use ~0.5 for cutout pass, 0.0 (disabled) for opaque. */ + alphaCutOff?: number; +} + +/** + * Custom terrain material that bakes TWO light channels into the vertex colour + * and combines them with live day/night uniforms: + * + * vertex.r = shaded sun-channel brightness (face shade × light curve of sun) + * vertex.g = shaded block-channel brightness (face shade × light curve of block) + * vertex.b = raw sun level 0..1 (for the debug overlay) + * vertex.a = raw block level 0..1 (for the debug overlay) + * + * final = texture × max( max(r·dayFactor, r·moonFloor), g ) + * + * Because `dayFactor` and `moonFloor` are uniforms, the whole world dims at + * night and relights at dawn WITHOUT rebuilding a single chunk mesh, and torch + * / glowstone light (the `g` channel) is unaffected by the time of day. Fog is + * replicated manually (linear) so no fragile fog/shadow GLSL includes are wired + * in. The debug overlay toggles via a uniform too (no remesh). + */ +export class VoxelTerrainMaterial { + readonly material: ShaderMaterial; + private readonly scene: Scene; + + constructor(scene: Scene, options: VoxelTerrainMaterialOptions) { + this.scene = scene; + const mat = new ShaderMaterial( + "voxel-terrain", + scene, + { vertexSource, fragmentSource }, + { + attributes: ["position", "uv", "color"], + uniforms: [ + "world", + "worldViewProjection", + "uCameraPos", + "uDayFactor", + "uMoonFloor", + "uAlphaCutOff", + "uFogColor", + "uFogStart", + "uFogEnd", + "uDebugMode", + "uDebugTint", + ], + samplers: ["uTexture"], + }, + ); + mat.setTexture("uTexture", options.texture); + mat.setFloat("uDayFactor", 1); + mat.setFloat("uMoonFloor", 0.05); + mat.setFloat("uAlphaCutOff", options.alphaCutOff ?? 0.5); + mat.setFloat("uDebugMode", 0); + mat.setColor3("uDebugTint", new Color3(1, 1, 1)); + mat.setColor3("uFogColor", new Color3(0.8, 0.9, 1)); + 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; + 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 + // includes/uniforms into the raw ShaderMaterial and the unexpanded + // `#include` leaves a literal `<` (shader compile error). + mat.fogEnabled = false; + this.material = mat; + } + + /** Live day/night state (call every frame). */ + setDayNight(dayFactor: number, moonFloor: number): void { + this.material.setFloat("uDayFactor", dayFactor); + this.material.setFloat("uMoonFloor", moonFloor); + } + + /** Fog + camera (call every frame; scene uses linear fog). */ + setFog(cameraPosition: Vector3, color: Color3, start: number, end: number): void { + this.material.setVector3("uCameraPos", cameraPosition); + this.material.setColor3("uFogColor", color); + this.material.setFloat("uFogStart", start); + this.material.setFloat("uFogEnd", end); + } + + /** Debug overlay: 0 normal, 1 sun, 2 block, 3 combined. No remesh required. */ + setDebugMode(mode: number, tint: Color3): void { + this.material.setFloat("uDebugMode", mode); + this.material.setColor3("uDebugTint", tint); + } + + dispose(): void { + this.material.dispose(); + } +} From 85c2ff36dc493cc44316a453ade6ff5d35344f27 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 14 Jun 2026 00:48:14 +0100 Subject: [PATCH 3/4] Address PR review: world-top skylight fix, cleanup, light tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [HIGH] SunLightPropagator Phase A: seed the topmost light-passing-but-not- sun-passing cell (water/leaves at the world ceiling) with one step of decay before closing the sky column, so it is no longer pitch-black and skylight bleeds into it. Also drop the misleading top-edge inflow comment + dead sunPass variable in pullInflow. - [MEDIUM] LightingSystem no longer peeks World.lightDirty via an as-unknown cast; World exposes a public lightDirtyCount getter. - [MEDIUM] Commit the propagation tests as scripts/lighttest.ts (+ bun run test:light) so the verification is reproducible. Adds a world-top water boundary case alongside open shaft / enclosed pocket / glowstone decay / cross-chunk bleed / canopy / water depth. - [MEDIUM] VoxelLightEngine.relightChunk now breaks the diff scan once both changed+borderChanged are true instead of scanning the rest of the chunk. - [MEDIUM] CelestialSystem sets renderingGroupId once in makeDisc (was set every frame in update) — the value is constant. - [LOW] Remove unused LightMap.markValid()/dirty; add VoxelLightEngine.dispose and call it from World.dispose. - [LOW] DayNightCycle.apply reuses scratch Color3/Color4/Vector3 instead of allocating per frame. Pre-existing website screenshot.ts/playwright typecheck left untouched (out of scope; playwright is an optional dep per AGENTS.md). --- package.json | 1 + scripts/lighttest.ts | 195 ++++++++++++++++++++++++ src/game/World.ts | 7 + src/game/lighting/CelestialSystem.ts | 20 ++- src/game/lighting/DayNightCycle.ts | 52 ++++--- src/game/lighting/LightMap.ts | 8 - src/game/lighting/LightingSystem.ts | 7 +- src/game/lighting/SunLightPropagator.ts | 26 ++-- src/game/lighting/VoxelLightEngine.ts | 11 +- 9 files changed, 265 insertions(+), 62 deletions(-) create mode 100644 scripts/lighttest.ts diff --git a/package.json b/package.json index 3601fa4..af9d298 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "site:preview": "cd website && bun run preview", "typecheck": "tsc --noEmit", "check": "bun run typecheck", + "test:light": "bun scripts/lighttest.ts", "screenshot": "bun scripts/screenshot.ts" }, "dependencies": { diff --git a/scripts/lighttest.ts b/scripts/lighttest.ts new file mode 100644 index 0000000..fa95eb8 --- /dev/null +++ b/scripts/lighttest.ts @@ -0,0 +1,195 @@ +// Standalone tests for the voxel light engine (sun + block propagation). +// Run with: bun run test:light +// +// These exercise the engine directly (no Babylon/DOM) so they run under bun in +// milliseconds. They cover the scenarios the lighting PR depends on: +// - open-air skylight, open vertical shaft (no decay straight down) +// - enclosed pocket stays dark +// - glowstone block-light decay +// - water depth attenuation +// - canopy attenuation under leaves +// - cross-chunk boundary bleed (cave lit from a neighbour's opening) +// - world-top water/leaves boundary (the cell that breaks the sky column) +// +// Failures print and exit non-zero so this works in CI. + +import { CHUNK_SIZE, CHUNK_HEIGHT } from "../src/constants"; +import { Chunk, blockIndex } from "../src/game/Chunk"; +import { VoxelLightEngine, lightKey } from "../src/game/lighting/VoxelLightEngine"; +import { LIGHT_MAX } from "../src/game/lighting/LightingConfig"; + +// A tiny in-memory world of (2*radius+1)² chunks for cross-chunk tests. +class MiniWorld { + readonly chunks = new Map(); + constructor(public radius: number) { + for (let cx = -radius; cx <= radius; cx++) + for (let cz = -radius; cz <= radius; cz++) + this.chunks.set(lightKey(cx, cz), new Chunk(cx, cz)); + } + get(cx: number, cz: number): Chunk { + return this.chunks.get(lightKey(cx, cz))!; + } + getBlock(wx: number, wy: number, wz: number): number { + if (wy < 0) return 3; + if (wy >= CHUNK_HEIGHT) return 0; + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const c = this.chunks.get(lightKey(cx, cz)); + if (!c) return 0; + return c.getLocal(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE); + } + setBlock(wx: number, wy: number, wz: number, id: number): void { + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const c = this.chunks.get(lightKey(cx, cz)); + if (!c) return; + c.blocks[blockIndex(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE)] = id; + c.generated = true; + } +} + +function engineFor(w: MiniWorld): VoxelLightEngine { + return new VoxelLightEngine((x, y, z) => w.getBlock(x, y, z)); +} + +let failures = 0; +function check(name: string, cond: boolean, extra = ""): void { + if (!cond) { + failures++; + console.error(` FAIL: ${name} ${extra}`); + } else { + console.log(` ok: ${name} ${extra}`); + } +} + +// 1. Open air is fully sky-lit and an open shaft does not decay downward. +{ + const w = new MiniWorld(0); + const c = w.get(0, 0); + c.generated = true; + const eng = engineFor(w); + eng.relightChunk(c); + check("open air top sun=15", eng.getSun(0, CHUNK_HEIGHT - 1, 0) === LIGHT_MAX); + check("open air bottom sun=15", eng.getSun(0, 0, 0) === LIGHT_MAX, `(got ${eng.getSun(0, 0, 0)})`); + check("deep open shaft sun=15", eng.getSun(5, 5, 5) === LIGHT_MAX); +} + +// 2. A fully-enclosed air pocket deep in stone is dark. +{ + const w = new MiniWorld(1); + for (const c of w.chunks.values()) { + c.blocks.fill(3); + c.generated = true; + } + const c = w.get(0, 0); + for (let y = 40; y <= 42; y++) + for (let x = 7; x <= 9; x++) + for (let z = 7; z <= 9; z++) c.blocks[blockIndex(x, y, z)] = 0; + const eng = engineFor(w); + eng.relightChunk(w.get(0, 0)); + eng.relightChunk(w.get(-1, 0)); + eng.relightChunk(w.get(1, 0)); + eng.relightChunk(w.get(0, -1)); + eng.relightChunk(w.get(0, 1)); + eng.relightChunk(w.get(0, 0)); + const pocket = eng.getSun(8, 41, 8); + check("enclosed pocket is dark (sun<3)", pocket < 3, `(got sun=${pocket})`); +} + +// 3. Glowstone emits block light that decays by 1 per block. +{ + const w = new MiniWorld(1); + for (const c of w.chunks.values()) { + c.blocks.fill(3); + c.generated = true; + } + // Carve an air pocket around the emitter so block light can spread. + for (let y = 38; y <= 42; y++) + for (let x = 6; x <= 14; x++) + for (let z = 6; z <= 10; z++) w.setBlock(x, y, z, 0); + w.setBlock(8, 40, 8, 28); // glowstone + const eng = engineFor(w); + eng.relightChunk(w.get(0, 0)); + check("glowstone cell block=15", eng.getBlockLight(8, 40, 8) === LIGHT_MAX); + check("1 block away block=14", eng.getBlockLight(9, 40, 8) === 14, `(got ${eng.getBlockLight(9, 40, 8)})`); + check("16 blocks away block<=0", eng.getBlockLight(8 + 16, 40, 8) <= 0, `(got ${eng.getBlockLight(8 + 16, 40, 8)})`); +} + +// 4. Water attenuates with depth. +{ + const w = new MiniWorld(0); + const c = w.get(0, 0); + c.blocks.fill(0); + for (let y = 60; y <= 75; y++) + for (let x = 0; x < CHUNK_SIZE; x++) + for (let z = 0; z < CHUNK_SIZE; z++) c.blocks[blockIndex(x, y, z)] = 7; + for (let x = 0; x < CHUNK_SIZE; x++) + for (let z = 0; z < CHUNK_SIZE; z++) c.blocks[blockIndex(x, 60, z)] = 3; + c.generated = true; + const eng = engineFor(w); + eng.relightChunk(c); + const surface = eng.getSun(8, 75, 8); + const deep = eng.getSun(8, 62, 8); + check("water surface sun high", surface >= 13, `(got ${surface})`); + check("deep water sun < surface", deep < surface, `(surface=${surface}, deep=${deep})`); +} + +// 5. Leaves attenuate light under a canopy. +{ + const w = new MiniWorld(0); + const c = w.get(0, 0); + c.blocks.fill(0); + c.generated = true; + for (let y = 70; y <= 80; y++) + for (let x = 0; x < CHUNK_SIZE; x++) + for (let z = 0; z < CHUNK_SIZE; z++) c.blocks[blockIndex(x, y, z)] = 6; + for (let x = 0; x < CHUNK_SIZE; x++) + for (let z = 0; z < CHUNK_SIZE; z++) c.blocks[blockIndex(x, 60, z)] = 3; + const eng = engineFor(w); + eng.relightChunk(c); + const top = eng.getSun(8, 80, 8); + const ground = eng.getSun(8, 61, 8); + check("canopy top is bright", top >= 5, `(got ${top})`); + check("ground under canopy dimmer than top", ground < top, `(top=${top}, ground=${ground})`); +} + +// 6. Cross-chunk: light bleeds across a boundary through a tunnel. +{ + const w = new MiniWorld(1); + for (const c of w.chunks.values()) { + c.blocks.fill(3); + c.generated = true; + } + for (let x = -8; x <= 24; x++) w.setBlock(x, 40, 8, 0); // horizontal tunnel + for (let y = 40; y < CHUNK_HEIGHT; y++) w.setBlock(20, y, 8, 0); // skylight in chunk (1,0) + const eng = engineFor(w); + eng.relightChunk(w.get(0, 0)); + eng.relightChunk(w.get(1, 0)); + eng.relightChunk(w.get(0, 0)); + const near = eng.getSun(20, 40, 8); + const edge = eng.getSun(16, 40, 8); // chunk boundary + const inside = eng.getSun(2, 40, 8); // deep in chunk (0,0) tunnel + check("shaft cell is sky-lit", near === LIGHT_MAX, `(got ${near})`); + check("light crosses boundary", edge > 0, `(got sun=${edge})`); + check("light attenuates along tunnel", inside < edge, `(edge=${edge}, inside=${inside})`); +} + +// 7. World-top water boundary: a water cell at the top of the world is lit on +// its surface (not pitch-black), since it breaks but still conducts light. +{ + const w = new MiniWorld(0); + const c = w.get(0, 0); + c.blocks.fill(0); + c.generated = true; + // Water slab at the very top of the world, air below. + for (let y = CHUNK_HEIGHT - 4; y < CHUNK_HEIGHT; y++) + for (let x = 0; x < CHUNK_SIZE; x++) + for (let z = 0; z < CHUNK_SIZE; z++) c.blocks[blockIndex(x, y, z)] = 7; + const eng = engineFor(w); + eng.relightChunk(c); + const topWater = eng.getSun(8, CHUNK_HEIGHT - 1, 8); // topmost cell, breaks column + check("world-top water surface is lit (sun>=12)", topWater >= 12, `(got sun=${topWater})`); +} + +console.log(failures === 0 ? "\nALL LIGHT TESTS PASSED" : `\n${failures} TEST(S) FAILED`); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/game/World.ts b/src/game/World.ts index da01cb1..28c8447 100644 --- a/src/game/World.ts +++ b/src/game/World.ts @@ -239,6 +239,11 @@ export class World { return this.lightDebugMode; } + /** Number of chunks queued for re-lighting (debug overlay). */ + get lightDirtyCount(): number { + return this.lightDirty.size; + } + /** 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); @@ -413,6 +418,8 @@ export class World { 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.root.dispose(); diff --git a/src/game/lighting/CelestialSystem.ts b/src/game/lighting/CelestialSystem.ts index 12f9c8f..5c89915 100644 --- a/src/game/lighting/CelestialSystem.ts +++ b/src/game/lighting/CelestialSystem.ts @@ -57,6 +57,10 @@ export class CelestialSystem { this.sunDisc = this.makeDisc("sun", SUN_SIZE, this.sunMat); this.sunHalo = this.makeDisc("sun-halo", HALO_SIZE, this.haloMat); this.moonDisc = this.makeDisc("moon", MOON_SIZE, this.moonMat); + // Paint order within the transparent pass doesn't need forcing: the halo is + // additive (blending commutes) and all discs have depth-write off (no + // z-fighting). renderingGroupId is set once in makeDisc (group 0, same as + // terrain, so blocks occlude the discs). } private makeDisc(name: string, size: number, material: Material): Mesh { @@ -69,6 +73,11 @@ export class CelestialSystem { m.applyFog = false; // sun/moon ignore fog so they stay crisp discs m.receiveShadows = false; // never receive or cast shadows m.alwaysSelectAsActiveMesh = true; // visible even though "far" away + // Same group as terrain (0): Babylon clears depth between groups, so a + // higher group would lose the terrain depth buffer and let the discs show + // through blocks. Within group 0 they render in the transparent pass after + // opaque terrain (depth-write off, depth-test on) → blocks occlude them. + m.renderingGroupId = 0; return m; } @@ -110,17 +119,6 @@ export class CelestialSystem { this.sunMat.emissiveColor = warm; this.haloMat.emissiveColor = Color3.Lerp(Color3.White(), dn.sunColor, 0.5); this.moonMat.emissiveColor = dn.moonColor; - - // Keep sun/moon/halo in the SAME rendering group as the terrain (0). Babylon - // clears depth between rendering groups, so a higher group would lose the - // terrain depth buffer and the sun would show through blocks. Here the discs - // render in the transparent pass after opaque terrain (depth-write off, - // depth-test on) → terrain in front occludes them, and they still draw over - // the depth-write-disabled sky dome. The halo must paint before the disc, so - // force it to render earlier via its render order (additive, no depth write). - this.sunHalo.renderingGroupId = 0; - this.sunDisc.renderingGroupId = 0; - this.moonDisc.renderingGroupId = 0; } dispose(): void { diff --git a/src/game/lighting/DayNightCycle.ts b/src/game/lighting/DayNightCycle.ts index 152f77b..bb91ff2 100644 --- a/src/game/lighting/DayNightCycle.ts +++ b/src/game/lighting/DayNightCycle.ts @@ -88,6 +88,12 @@ export class DayNightCycle { private readonly ambient: HemisphericLight; private readonly hemi: HemisphericLight; private readonly scene: Scene; + // Scratch instances mutated every frame (avoids per-frame Color3/Color4 + // allocations in the hot path). + private readonly _c1 = new Color3(); + private readonly _c2 = new Color3(); + private readonly _cFog = new Color3(); + private readonly _clearColor = new Color4(0, 0, 0, 1); constructor( sun: DirectionalLight, @@ -154,33 +160,32 @@ export class DayNightCycle { const sunAngle = (t - TIME_SUNRISE) * Math.PI * 2; const cosA = Math.cos(sunAngle); const sinA = Math.sin(sunAngle); - // Light travels from sun toward scene. Sun sits in +X at sunrise. - const dir = new Vector3(-cosA, -sinA, -0.35); + // Light travels from sun toward scene. Sun sits in +X at sunrise. Mutate the + // stable direction vectors in place (no per-frame allocation). + const dir = this.sunDirection.set(-cosA, -sinA, -0.35); dir.normalize(); - this.sunDirection = dir; - this.moonDirection = dir.scale(-1); // moon is the antipode - this.sunSkyDirection = dir.scale(-1); // where the disc appears in the sky - this.moonSkyDirection = dir; // moon disc opposite the sun disc + dir.scaleToRef(-1, this.moonDirection); // moon is the antipode + dir.scaleToRef(-1, this.sunSkyDirection); // where the disc appears in the sky + this.moonSkyDirection.copyFrom(dir); // moon disc opposite the sun disc // --- Golden-hour warmth: peaks when the sun is near the horizon. --- this.goldenHour = 1 - Math.min(1, Math.abs(this.sunElevation) / 0.3); // --- Sky colours: day↔night base, then bleed dusk warmth at the horizon. --- const d = this.dayFactor; - const baseZen = Color3.Lerp(SKY.nightZenith, SKY.dayZenith, d); - const baseHor = Color3.Lerp(SKY.nightHorizon, SKY.dayHorizon, d); const g = this.goldenHour; - this.skyZenith = Color3.Lerp(baseZen, SKY.duskZenith, g * 0.4); - this.skyHorizon = Color3.Lerp(baseHor, SKY.duskHorizon, g * 0.75); + Color3.LerpToRef(SKY.nightZenith, SKY.dayZenith, d, this._c1); + Color3.LerpToRef(SKY.nightHorizon, SKY.dayHorizon, d, this._c2); + Color3.LerpToRef(this._c1, SKY.duskZenith, g * 0.4, this.skyZenith); + Color3.LerpToRef(this._c2, SKY.duskHorizon, g * 0.75, this.skyHorizon); // --- Sun/moon colours --- - this.sunColor = Color3.Lerp(SUN_COLOR_HIGH, SUN_COLOR_LOW, g); - this.moonColor = MOON_COLOR.clone(); + Color3.LerpToRef(SUN_COLOR_HIGH, SUN_COLOR_LOW, g, this.sunColor); // --- Push into Babylon lights (used by the water pass + entities) --- - this.sun.direction = this.sunDirection; + this.sun.direction.copyFrom(this.sunDirection); this.sun.intensity = this.sunIntensityDay * d; - this.sun.diffuse = this.sunColor; + this.sun.diffuse.copyFrom(this.sunColor); // Ambient: small night floor + a touch of moonlight; never bright enough to // light caves (caves have no sun channel and the custom shader ignores this). @@ -188,17 +193,18 @@ export class DayNightCycle { this.ambientIntensityNight + (this.ambientIntensityDay - this.ambientIntensityNight) * d + this.moonFactor * 0.4; - this.ambient.diffuse = Color3.White(); - this.ambient.groundColor = Color3.White(); this.hemi.intensity = 0.04 + this.hemiIntensityDay * d; - this.hemi.diffuse = Color3.Lerp(SKY.nightHorizon, SKY.dayHorizon, d); - this.hemi.groundColor = Color3.FromHexString("#4a6b3a"); - - // --- Sky clear + fog colour track the horizon --- - const sky = this.skyHorizon; - this.scene.clearColor = new Color4(sky.r, sky.g, sky.b, 1); - this.scene.fogColor = sky.clone(); + Color3.LerpToRef(SKY.nightHorizon, SKY.dayHorizon, d, this._c1); + this.hemi.diffuse.copyFrom(this._c1); + + // --- Sky clear + fog colour track the horizon (mutate stable instances) --- + this._clearColor.r = this.skyHorizon.r; + this._clearColor.g = this.skyHorizon.g; + this._clearColor.b = this.skyHorizon.b; + this._clearColor.a = 1; + this.scene.clearColor = this._clearColor; + this.scene.fogColor = this._cFog.copyFrom(this.skyHorizon); } /** Sun disc opacity (0 when below the horizon, fades in as it rises). */ diff --git a/src/game/lighting/LightMap.ts b/src/game/lighting/LightMap.ts index b98e2b5..fb88c68 100644 --- a/src/game/lighting/LightMap.ts +++ b/src/game/lighting/LightMap.ts @@ -37,8 +37,6 @@ export class LightMap { readonly block: Uint8Array; /** Lighting has been computed for the current terrain (not stale). */ valid = false; - /** Lighting changed since the last mesh rebuild → remesh needed. */ - dirty = false; constructor() { this.sun = new Uint8Array(CHUNK_VOLUME); @@ -66,12 +64,6 @@ export class LightMap { this.sun.fill(0); this.block.fill(0); } - - /** Mark lighting freshly computed and the mesh as needing a rebuild. */ - markValid(): void { - this.valid = true; - this.dirty = true; - } } /** Clamp a light level to the engine's [0, LIGHT_MAX] range. */ diff --git a/src/game/lighting/LightingSystem.ts b/src/game/lighting/LightingSystem.ts index d45544a..7b1f87f 100644 --- a/src/game/lighting/LightingSystem.ts +++ b/src/game/lighting/LightingSystem.ts @@ -152,17 +152,12 @@ export class LightingSystem { shadowsEnabled: this.shadows.enabled, paused: this.dayNight.paused, target: t, - dirtyCount: this.lightDirtyCount(), + dirtyCount: this.world.lightDirtyCount, litCount: lit, loadedCount: loaded, }; } - private lightDirtyCount(): number { - const w = this.world as unknown as { lightDirty?: { size: number } }; - return w.lightDirty?.size ?? 0; - } - dispose(): void { this.celestial.dispose(); this.shadows.dispose(); diff --git a/src/game/lighting/SunLightPropagator.ts b/src/game/lighting/SunLightPropagator.ts index 7913581..4a243c6 100644 --- a/src/game/lighting/SunLightPropagator.ts +++ b/src/game/lighting/SunLightPropagator.ts @@ -70,10 +70,17 @@ export class SunLightPropagator { this.enqueue(idx); continue; // column stays open (sun passes straight through, e.g. glass) } - // First cell that breaks straight sunlight (opaque OR - // light-passing-but-not-sun-passing like leaves/water). Its surface - // is NOT seeded: it receives attenuated light from adjacent sky-lit - // air during BFS. + // First cell that breaks straight sunlight. If it still conducts light + // (water, leaves), seed its surface with one step of decay so a + // water/leaf column at the world top isn't pitch-black and so sky-lit + // colour bleeds into it; then close the column. Opaque blocks stay dark. + if (skyExposed && light.lightPassesThrough) { + const seed = LIGHT_MAX - 1 - light.lightAbsorption; + if (seed > 0 && seed > sun[idx]) { + sun[idx] = seed; + this.enqueue(idx); + } + } skyExposed = false; } } @@ -154,9 +161,11 @@ export class SunLightPropagator { } /** - * Pull light INTO the in-chunk border cell `(lx,ly,lz)` from its out-of-chunk - * neighbour at world offset `(bx,bz)`. Uses the straight-down rule when the - * neighbour is directly above the cell (top edge) and is sky-lit. + * Pull light INTO the in-chunk border cell `(lx,ly,lz)` from its horizontal + * out-of-chunk neighbour at world offset `(bx,bz)`. This is what lets a cave + * in this chunk be lit by an opening in the adjacent chunk. Normal -1 decay. + * (Vertical/top-edge inflow is unnecessary: Phase A already seeds every + * sky-exposed column from the open world top.) */ private pullInflow( access: LightAccess, @@ -180,13 +189,10 @@ export class SunLightPropagator { if (nbLevel <= 1) return; const absorption = nbId === 0 ? 0 : resolveLight(getBlock(nbId)).lightAbsorption; - const sunPass = nbId === 0 ? true : resolveLight(getBlock(nbId)).sunlightPassesThrough; - // Neighbour is horizontal (bz/bx != 0) → normal decay into our cell. const candidate = nbLevel - 1 - absorption; if (candidate > sun[idx]) { sun[idx] = candidate; this.enqueue(idx); } - void sunPass; } } diff --git a/src/game/lighting/VoxelLightEngine.ts b/src/game/lighting/VoxelLightEngine.ts index fb4ddaf..ec50faa 100644 --- a/src/game/lighting/VoxelLightEngine.ts +++ b/src/game/lighting/VoxelLightEngine.ts @@ -99,6 +99,11 @@ export class VoxelLightEngine implements LightAccess { this.maps.delete(lightKey(cx, cz)); } + /** Drop all light maps (call when the world is disposed). */ + dispose(): void { + this.maps.clear(); + } + /** * Recompute sun + block light for `chunk` from scratch, using neighbour * chunks (where loaded) as boundary conditions. Returns whether anything @@ -135,16 +140,14 @@ export class VoxelLightEngine implements LightAccess { } } } - if (changed && borderChanged) { - // Can't conclude more by scanning further; finish copy below. - } + // Both flags already set: no more information to gather — stop the scan. + if (changed && borderChanged) break; } // Commit scratch → stored map. oldSun.set(newSun); oldBlock.set(newBlock); map.valid = true; - if (changed) map.dirty = true; return { changed, borderChanged }; } From 8efb89540f7f2e3309c8624fee9a73e56c9f66f3 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 14 Jun 2026 00:59:16 +0100 Subject: [PATCH 4/4] Address 2nd PR review: getters, allocations, website typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [MEDIUM] DayNightCycle exposes public sunIntensity/ambientIntensity getters; LightingSystem.buildDebugInfo uses them instead of bracket-accessing the private light fields (same brittle-coupling pattern as the prior lightDirty fix). - [MEDIUM] Exclude website/scripts from astro check — screenshot.ts is optional tooling whose dynamic import("playwright") breaks typecheck when the optional dep is absent. Restores `bun run typecheck` (astro check) in website/. - [LOW] Sky.setDomeColours reuses two scratch Vector3 instead of allocating per frame. - [LOW] ShadowManager.sameSet compares with an allocation-free O(n²) membership check instead of building a Set every frame. --- src/engine/Sky.ts | 9 +++++++-- src/game/lighting/DayNightCycle.ts | 5 +++++ src/game/lighting/LightingSystem.ts | 4 ++-- src/game/lighting/ShadowManager.ts | 18 +++++++++++++++--- website/tsconfig.json | 2 +- 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/engine/Sky.ts b/src/engine/Sky.ts index dc79037..e742db8 100644 --- a/src/engine/Sky.ts +++ b/src/engine/Sky.ts @@ -26,6 +26,9 @@ export class Sky { private readonly dome: Mesh; private readonly domeMat: ShaderMaterial; private readonly clouds: Clouds; + /** Scratch uniform vectors for the dome shader (avoid per-frame allocation). */ + private readonly _zenVec = new Vector3(); + private readonly _horVec = new Vector3(); constructor(seed = "voxl", scene: Scene) { this.scene = scene; @@ -113,8 +116,10 @@ export class Sky { /** Update the gradient dome colours from the day/night cycle. */ setDomeColours(zenith: Color3, horizon: Color3): void { - this.domeMat.setVector3("topColor", new Vector3(zenith.r, zenith.g, zenith.b)); - this.domeMat.setVector3("bottomColor", new Vector3(horizon.r, horizon.g, horizon.b)); + this._zenVec.set(zenith.r, zenith.g, zenith.b); + this._horVec.set(horizon.r, horizon.g, horizon.b); + this.domeMat.setVector3("topColor", this._zenVec); + this.domeMat.setVector3("bottomColor", this._horVec); } setCloudsEnabled(enabled: boolean): void { diff --git a/src/game/lighting/DayNightCycle.ts b/src/game/lighting/DayNightCycle.ts index bb91ff2..0124968 100644 --- a/src/game/lighting/DayNightCycle.ts +++ b/src/game/lighting/DayNightCycle.ts @@ -137,6 +137,11 @@ export class DayNightCycle { setPaused(p: boolean): void { this.paused = p; } togglePaused(): boolean { this.paused = !this.paused; return this.paused; } + /** Current Babylon sun-light intensity (debug overlay). */ + get sunIntensity(): number { return this.sun.intensity; } + /** Current Babylon ambient-light intensity (debug overlay). */ + get ambientIntensity(): number { return this.ambient.intensity; } + /** Multiply the clock speed (clamped to keep it sane). */ scaleTime(factor: number): void { this.timeScale = Math.max(0, Math.min(64, this.timeScale * factor)); diff --git a/src/game/lighting/LightingSystem.ts b/src/game/lighting/LightingSystem.ts index 7b1f87f..edab9a5 100644 --- a/src/game/lighting/LightingSystem.ts +++ b/src/game/lighting/LightingSystem.ts @@ -139,8 +139,8 @@ export class LightingSystem { timeScale: this.dayNight.timeScale, dayFactor: this.dayNight.dayFactor, moonFactor: this.dayNight.moonFactor, - sunIntensity: this.dayNight["sun"].intensity, - ambientIntensity: this.dayNight["ambient"].intensity, + sunIntensity: this.dayNight.sunIntensity, + ambientIntensity: this.dayNight.ambientIntensity, sunDirection: { x: this.dayNight.sunDirection.x, y: this.dayNight.sunDirection.y, diff --git a/src/game/lighting/ShadowManager.ts b/src/game/lighting/ShadowManager.ts index 82c35b8..91a697f 100644 --- a/src/game/lighting/ShadowManager.ts +++ b/src/game/lighting/ShadowManager.ts @@ -180,9 +180,21 @@ export class ShadowManager { } } +/** + * 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 — + * fine for the ~tens of nearby casters). Avoids a per-frame Set allocation. + */ function sameSet(a: Mesh[], b: Mesh[]): boolean { - if (a.length !== b.length) return false; - const bs = new Set(b); - for (const m of a) if (!bs.has(m)) return false; + const n = a.length; + if (n !== b.length) return false; + for (let i = 0; i < n; i++) { + const m = a[i]; + let found = false; + for (let j = 0; j < n; j++) { + if (b[j] === m) { found = true; break; } + } + if (!found) return false; + } return true; } diff --git a/website/tsconfig.json b/website/tsconfig.json index 184ab0f..7677eb3 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist", "public"], + "exclude": ["dist", "public", "scripts"], "compilerOptions": { // Allow importing the data module etc. with project types. "verbatimModuleSyntax": false,