Skip to content
DasPeTe Tutorials

Smooth Voxel Terrain in Babylon.js · Part 11 of 12 in the series

Part 11: Cave Light

Let the light of the sky reach only as far into a cave as it can travel through the air: a grid of light nodes, filled breadth-first from the open sky, read at every vertex, the same in neighboring chunks, and a lamp for the dark.

Quest objectives

  • Measure how far each point is from the open sky, through the air
  • Keep light from leaking through thin roofs
  • Work out the light so that neighboring chunks agree at their borders
  • Relight the chunks around and below an edit
  • Dim sun and sky in caves, and add a lamp at the camera

Walk into a cave in part 10 and it is as bright as the field outside: the shader lights every surface by the sun and the sky, whether it can see them or not. Real caves get dark a few steps in. The light comes in through the opening and fades along the way.

Inside a cave in part 10: the walls are lit as if they stood in the open.

Steps to the sky

Proper light would follow rays as they bounce around the cave, far too slow for a world that changes as you dig. A good stand-in is a single question for every point: how far would you have to go through the air to see the open sky? Under the open sky that's nothing, and the light is full. In a cave entrance it's a few meters, and deep inside it's too far, and dark.

The answer comes from a grid of light nodes, 2 m apart:

  1. A node is open when there is nothing solid above it, all the way up. Open nodes are 0 steps from the sky.

  2. From the open nodes, the light spreads into the air, one node per step, in the six directions: a breadth-first search. Each node learns the fewest steps it takes to reach it. Solid nodes stop the light.

  3. After 8 steps, 16 m, it is dark. A node's light is 1 minus its steps over 8.

Light that only goes around corners in steps along the axes isn't physics, but it looks right: bright at the mouth, fading inwards, dark where a tunnel turns away.

No light through the roof

Nodes 2 m apart have a problem: a roof of rock a meter thick can lie between two of them, with air above and air below, and the light would step right through it. So a node only counts as air when it is at least a meter from the ground, a density below -1. Any rock between two nodes 2 m apart is then within a meter of one of them, which makes that one solid: the step is blocked. Narrow gaps block the light too, but a crack that narrow would be dark anyway.

The same light on both sides

Every chunk works out its light on its own, in its worker. For the light to match at a chunk's border, both chunks must get the same steps for the nodes they share. Two things make sure of that:

  • The nodes lie on one grid for the whole world, every 2 m from the origin, so the chunks share the same nodes.

  • Each chunk works out the nodes of its own space and 20 m around it. Light that reaches a node in the chunk at all came at most 16 m, so its whole way lies within that space. Both chunks see the whole way and count the same steps.

Whether a node is open needs the column above it, all the way up. The search reads each column from above the highest ground, from part 7 and the edits, down to the first solid node.

From nodes to vertices

A vertex lies on the surface, and the nodes around it are partly in the rock. Only air nodes are blended, with the trilinear weights as before. But a vertex right below a node layer, with the nodes above it less than a meter away, can have no air node around it at all, and would go black on open ground. So each vertex reads the light 1.5 m out along its normal, a little way into the air, where there are air nodes. Create src/skyLight.ts:

src/skyLight.ts
import type { EditStore } from './edits.ts';
import { CHUNK_SIZE } from './grid.ts';
import { highestGround } from './terrain.ts';

/** Meters between the nodes of the light grid. */
const SPACING = 2;
/** How far the sky's light reaches into caves, through the air: further in it is dark. */
const REACH = 16;
const STEPS = REACH / SPACING;
/** How far around a chunk the light can come from, in meters. */
export const LIGHT_REACH = REACH + 2 * SPACING;
/**
 * A node counts as air only at least this far from the ground. Two nodes are 2 m apart,
 * so any rock between them keeps one of them within 1 m: light can't pass through.
 */
const CLEARANCE = 1;
/** Vertices read the light this far out along their normal, among the air nodes there. */
const LOOK_OUT = 1.5;
const UNREACHED = 255;

/**
 * How much of the sky's light reaches each vertex of a chunk, from 0 to 1: 1 under the
 * open sky, less the further it is through the air to it. `positions` and `normals` are
 * the chunk's mesh, positions from `origin`.
 */
export function skyLight(
  origin: { x: number; y: number; z: number },
  positions: Float32Array,
  normals: Float32Array,
  edits: EditStore,
): Float32Array {
  // The nodes from LIGHT_REACH below the chunk's low corner to as far past its high one,
  // numbered on a grid the whole world shares, so neighboring chunks agree.
  const low = [origin.x, origin.y, origin.z].map((o) => Math.floor((o - LIGHT_REACH) / SPACING));
  const high = [origin.x, origin.y, origin.z].map((o) => Math.ceil((o + CHUNK_SIZE + LIGHT_REACH) / SPACING));
  const [nx, ny, nz] = [0, 1, 2].map((axis) => high[axis] - low[axis] + 1);
  const node = (i: number, j: number, k: number) => i + nx * (j + ny * k);
  const air = new Uint8Array(nx * ny * nz);
  const steps = new Uint8Array(nx * ny * nz).fill(UNREACHED);
  const queue = new Int32Array(nx * ny * nz);
  let queued = 0;

  // Above this height no ground rises over the grid: everything up there sees the sky.
  const size = (nx - 1) * SPACING;
  let top = highestGround(low[0] * SPACING, low[2] * SPACING, size);
  for (let cz = Math.floor((low[2] * SPACING) / CHUNK_SIZE); cz <= Math.floor((high[2] * SPACING) / CHUNK_SIZE); cz += 1) {
    for (let cx = Math.floor((low[0] * SPACING) / CHUNK_SIZE); cx <= Math.floor((high[0] * SPACING) / CHUNK_SIZE); cx += 1) {
      top = Math.max(top, (edits.highestLayer(cx, cz) + 1) * CHUNK_SIZE);
    }
  }

  for (let k = 0; k < nz; k += 1) {
    for (let i = 0; i < nx; i += 1) {
      const x = (low[0] + i) * SPACING;
      const z = (low[2] + k) * SPACING;
      // Down the column from the top: the sky reaches every node above the first
      // solid one, also those above the grid.
      let open = true;
      for (let y = Math.ceil(top / SPACING) * SPACING; y > high[1] * SPACING && open; y -= SPACING) {
        if (edits.sample(x, y, z) >= -CLEARANCE) open = false;
      }
      for (let j = ny - 1; j >= 0; j -= 1) {
        const index = node(i, j, k);
        air[index] = edits.sample(x, (low[1] + j) * SPACING, z) < -CLEARANCE ? 1 : 0;
        if (!air[index]) open = false;
        if (open) {
          steps[index] = 0;
          queue[queued++] = index;
        }
      }
    }
  }

  // From the open nodes into the caves, one step at a time: each node learns how many
  // steps through the air it is from the sky.
  const neighbors = [1, -1, nx, -nx, nx * ny, -nx * ny];
  for (let head = 0; head < queued; head += 1) {
    const index = queue[head];
    const next = steps[index] + 1;
    if (next > STEPS) continue;
    const i = index % nx;
    const j = Math.floor(index / nx) % ny;
    const k = Math.floor(index / (nx * ny));
    for (let n = 0; n < 6; n += 1) {
      // Not past the edge of the grid.
      if ((n === 0 && i === nx - 1) || (n === 1 && i === 0)) continue;
      if ((n === 2 && j === ny - 1) || (n === 3 && j === 0)) continue;
      if ((n === 4 && k === nz - 1) || (n === 5 && k === 0)) continue;
      const other = index + neighbors[n];
      if (!air[other] || steps[other] !== UNREACHED) continue;
      steps[other] = next;
      queue[queued++] = other;
    }
  }

  // Each vertex blends the light of the air nodes around a point a little way out.
  const light = new Float32Array(positions.length / 3);
  for (let v = 0; v < light.length; v += 1) {
    const p = [0, 1, 2].map((axis) => {
      const position = [origin.x, origin.y, origin.z][axis] + positions[v * 3 + axis];
      return (position + normals[v * 3 + axis] * LOOK_OUT) / SPACING - low[axis];
    });
    const [i0, j0, k0] = p.map(Math.floor);
    let sum = 0;
    let weights = 0;
    for (let c = 0; c < 8; c += 1) {
      const [i, j, k] = [i0 + (c & 1), j0 + ((c >> 1) & 1), k0 + (c >> 2)];
      if (i < 0 || j < 0 || k < 0 || i >= nx || j >= ny || k >= nz) continue;
      const index = node(i, j, k);
      if (!air[index]) continue;
      const weight =
        (c & 1 ? p[0] - i0 : 1 - (p[0] - i0)) *
        ((c >> 1) & 1 ? p[1] - j0 : 1 - (p[1] - j0)) *
        (c >> 2 ? p[2] - k0 : 1 - (p[2] - k0));
      sum += weight * (steps[index] === UNREACHED ? 0 : 1 - steps[index] / STEPS);
      weights += weight;
    }
    light[v] = weights > 0 ? sum / weights : 0;
  }
  return light;
}
  • low and high are the first and last node on each axis, in node numbers, from 20 m before the chunk's corner to 20 m past its far side.

  • The first loop reads each column from the top down. open stays true until the first solid node, and every node until then goes into the queue with 0 steps.

  • The second loop is the search: it takes nodes from the front of the queue and adds their neighbors that are air and not reached yet, with one step more. A plain array and a growing index are a queue that never needs to shift.

  • The last loop blends the light for each vertex, from the air nodes around its point 1.5 m out.

The worker works out the light after the mesh, with the edits, and sends it along. In src/chunks.ts, import skyLight, add the light to ChunkMesh, and end buildChunk() with it:

src/chunks.ts
/** A chunk's mesh, with how far below the ground each vertex lies and how much sky it sees. */
export interface ChunkMesh extends MeshData {
  depths: Float32Array;
  light: Float32Array;
}

const light = skyLight(origin, mesh.positions, mesh.normals, edits);
return { ...mesh, depths, light };

In src/chunkWorker.ts, transfer it with the other arrays:

src/chunkWorker.ts
const transfer = mesh
  ? [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer, mesh.depths.buffer, mesh.light.buffer]
  : [];

The light takes about as long as the rest of the chunk, so a chunk now takes about twice as long to build. Two chunks next to each other give their shared vertices the same light, to the last digit, with either mesher.

Relighting after an edit

Dig a hole into a cave's roof, and the light changes: around the hole, and below it, where the sky now reaches down. The chunks there have to be built again, though their shape didn't change. In src/world.ts, import LIGHT_REACH, and at the end of edit(), count up the chunks within the light's reach of the changed samples, from the lowest layer up:

src/world.ts
// The light changes around the edit, and below it, where the sky may reach now: the
// chunks there are built again too, but not held back for the batch.
const lightFirst = (axis: number) => Math.ceil((box.low[axis] - CHUNK_SIZE - LIGHT_REACH) / CHUNK_SIZE);
const lightLast = (axis: number) => Math.floor((box.high[axis] + LIGHT_REACH) / CHUNK_SIZE);
for (let z = lightFirst(2); z <= lightLast(2); z += 1) {
  for (let x = lightFirst(0); x <= lightLast(0); x += 1) {
    for (let y = LOWEST_LAYER; y <= lightLast(1); y += 1) {
      const chunk = this.chunks.get(chunkKey({ x, y, z }));
      if (chunk && chunk.batch !== batch) chunk.version += 1;
    }
  }
}

They are not part of the batch: their shape is the same as before, so showing them a frame later leaves no crack, only light that catches up. In createChunkMesh(), add the light as a second attribute, under the depth:

src/world.ts
mesh.setVerticesData('light', data.light, false, 1);

The shaders

The vertex shader passes the light on. Replace src/terrain.vertex.glsl:

src/terrain.vertex.glsl
precision highp float;

attribute vec3 position;
attribute vec3 normal;
attribute float depth;
attribute float light;

uniform mat4 world;
uniform mat4 viewProjection;

varying vec3 vPosition;
varying vec3 vNormal;
varying float vDepth;
varying float vLight;

void main() {
  vec4 worldPosition = world * vec4(position, 1.0);
  vPosition = worldPosition.xyz;
  // Chunks are only moved, never turned or scaled, so the normal stays as it is.
  vNormal = normal;
  vDepth = depth;
  vLight = light;
  gl_Position = viewProjection * worldPosition;
}

The fragment shader uses it on both kinds of light. The sun needs a clear view of the sky, so it fades out between 1 and 0.6: in a cave entrance, the walls are already in shade. The sky's light reaches further, and fades with the light raised to the power of 1.5. A faint violet glow keeps the darkest corners from being black, and a warm lamp at the camera lights the walls near you, 24 m at most, and only where it is dark: outside, it would be a bright circle around you. Replace src/terrain.fragment.glsl:

src/terrain.fragment.glsl
precision highp float;
precision highp sampler2DArray;

varying vec3 vPosition;
varying vec3 vNormal;
varying float vDepth;
varying float vLight;

uniform sampler2DArray layers;
uniform vec3 cameraPosition;
uniform vec3 sunDirection;
uniform vec3 sunColor;
uniform vec3 skyColor;
uniform vec3 groundColor;
uniform vec3 caveColor;
uniform vec3 lampColor;
uniform vec3 fogColor;
uniform vec2 fogRange;

// The layers of the texture array.
const float GRASS = 0.0;
const float SOIL = 1.0;
const float ROCK = 2.0;
const float SNOW = 3.0;
// Meters the lamp at the camera reaches.
const float LAMP_RANGE = 24.0;
// Meters one texture covers, and a second, much larger size against visible repeats.
const float TILE = 4.0;
const float LARGE_TILE = 27.0;

// A texture at both sizes, half and half.
vec3 sampleLayer(vec2 p, float layer) {
  return (texture(layers, vec3(p / TILE, layer)).rgb + texture(layers, vec3(p / LARGE_TILE, layer)).rgb) * 0.5;
}

// A layer seen from three sides: the texture projected along x, y and z, blended by how
// much the surface faces each way.
vec3 triplanar(float layer, vec3 blend) {
  return sampleLayer(vPosition.zy, layer) * blend.x
    + sampleLayer(vPosition.xz, layer) * blend.y
    + sampleLayer(vPosition.xy, layer) * blend.z;
}

void main() {
  vec3 normal = normalize(vNormal);
  // Raised to the 4th power, so that the blend between the sides stays narrow.
  vec3 blend = pow(abs(normal), vec3(4.0));
  blend /= blend.x + blend.y + blend.z;

  // Grass on gentle slopes at the surface, rock on steep ones and deeper down, soil
  // between them, and snow high up.
  float flat_ = normal.y;
  float grass = smoothstep(0.7, 0.85, flat_) * (1.0 - smoothstep(0.5, 2.0, vDepth));
  float rock = max(1.0 - smoothstep(0.45, 0.7, flat_), smoothstep(3.0, 6.0, vDepth));
  float snow = smoothstep(105.0, 120.0, vPosition.y) * smoothstep(0.55, 0.75, flat_);

  vec3 color = triplanar(SOIL, blend);
  if (grass > 0.0) color = mix(color, triplanar(GRASS, blend), grass);
  if (rock > 0.0) color = mix(color, triplanar(ROCK, blend), rock);
  if (snow > 0.0) color = mix(color, triplanar(SNOW, blend), snow);

  // Light from the sun, which needs a clear view of the sky, and from the sky, which
  // reaches further in. More from above than from below, and a faint glow in caves.
  float sky = vLight;
  float sun = max(dot(normal, -sunDirection), 0.0) * smoothstep(0.6, 1.0, sky);
  vec3 ambient = mix(groundColor, skyColor, normal.y * 0.5 + 0.5) * pow(sky, 1.5) + caveColor;

  // A lamp at the camera for the dark: brightest close by, gone at LAMP_RANGE.
  vec3 toCamera = cameraPosition - vPosition;
  float distanceToCamera = length(toCamera);
  float lamp = max(dot(normal, toCamera / distanceToCamera), 0.0)
    * pow(1.0 - min(distanceToCamera / LAMP_RANGE, 1.0), 2.0) * (1.0 - sky);

  vec3 lit = color * (ambient + sunColor * sun + lampColor * lamp);

  float fog = smoothstep(fogRange.x, fogRange.y, distance(vPosition, cameraPosition));
  gl_FragColor = vec4(mix(lit, fogColor, fog), 1.0);
}

The material gets the new attribute and the two colors. Replace src/material.ts:

src/material.ts
import {
  Color3,
  Constants,
  RawTexture2DArray,
  ShaderMaterial,
  Texture,
  Vector2,
  Vector3,
  type Scene,
} from '@babylonjs/core/pure';
import fragmentSource from './terrain.fragment.glsl?raw';
import vertexSource from './terrain.vertex.glsl?raw';
import { LAYERS, paintLayers, TEXTURE_SIZE } from './textures.ts';

/** The sky's color, a hazy lilac, which the fog fades to. */
export const SKY = Color3.FromHexString('#b8a4e0');

/** The terrain's material: painted textures, projected from three sides, and lit. */
export function createTerrainMaterial(scene: Scene, fogStart: number, fogEnd: number): ShaderMaterial {
  const material = new ShaderMaterial(
    'terrain',
    scene,
    { vertexSource, fragmentSource },
    {
      attributes: ['position', 'normal', 'depth', 'light'],
      uniforms: [
        'world', 'viewProjection', 'cameraPosition', 'sunDirection',
        'sunColor', 'skyColor', 'groundColor', 'caveColor', 'lampColor', 'fogColor', 'fogRange',
      ],
      samplers: ['layers'],
    },
  );

  const textures = new RawTexture2DArray(
    paintLayers(),
    TEXTURE_SIZE,
    TEXTURE_SIZE,
    LAYERS.length,
    Constants.TEXTUREFORMAT_RGBA,
    scene,
    true,
    false,
    Texture.TRILINEAR_SAMPLINGMODE,
  );
  textures.wrapU = Texture.WRAP_ADDRESSMODE;
  textures.wrapV = Texture.WRAP_ADDRESSMODE;
  // Sharper textures on ground seen at a flat angle.
  textures.anisotropicFilteringLevel = 8;
  material.setTexture('layers', textures);

  // A warm, low sun, a cool sky and a dark violet below.
  material.setVector3('sunDirection', new Vector3(0.5, -0.65, -0.55).normalize());
  material.setColor3('sunColor', Color3.FromHexString('#ffd8a8').scale(1.1));
  material.setColor3('skyColor', Color3.FromHexString('#c4b5fd').scale(0.6));
  material.setColor3('groundColor', Color3.FromHexString('#3b1676').scale(0.6));
  // In caves: a faint violet glow, and a warm lamp.
  material.setColor3('caveColor', Color3.FromHexString('#5b21b6').scale(0.3));
  material.setColor3('lampColor', Color3.FromHexString('#fdba74').scale(1.2));
  material.setColor3('fogColor', SKY);
  material.setVector2('fogRange', new Vector2(fogStart, fogEnd));
  return material;
}

Try it

Walk to one of the cave entrances on the slope in front of you and go in. A few steps in, the walls lose the sun; further in, the sky's light fades, and the lamp takes over, lighting the walls around you while the tunnel ahead stays dark. Look back: the entrance glows. Now dig a hole up through the roof, and the light pours in around it a moment later.

The project at the end of part 11: caves get dark inside, and a lamp lights the walls near you. Click, WASD walks, F flies, the mouse buttons dig and build.
Open in a new tab

The world still ends in fog 250 m away. In the last part it reaches to the horizon.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.