Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
195 changes: 195 additions & 0 deletions scripts/lighttest.ts
Original file line number Diff line number Diff line change
@@ -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<string, Chunk>();
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);
84 changes: 20 additions & 64 deletions src/engine/Sky.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import {
Color3,
DirectionalLight,
DynamicTexture,
HemisphericLight,
Mesh,
MeshBuilder,
Scene,
ShaderMaterial,
StandardMaterial,
Texture,
TransformNode,
Vector3,
} from "@babylonjs/core";
Expand All @@ -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;
Expand All @@ -26,9 +24,11 @@ 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);
/** 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;
Expand Down Expand Up @@ -79,12 +79,14 @@ 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;
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;
Expand All @@ -106,27 +108,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.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._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 {
Expand All @@ -142,10 +135,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(
Expand All @@ -158,46 +149,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;
}
13 changes: 13 additions & 0 deletions src/engine/Textures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading