Added coastal water wavelets with a Voronoi normal map

This commit is contained in:
2026-09-19 22:03:58 +02:00
parent 2c022d0cbb
commit 187c7a7ed8
6 changed files with 394 additions and 28 deletions
+54
View File
@@ -0,0 +1,54 @@
// Distance from each sea tile to the nearest land, for the coastal wavelets.
//
// The ocean is drawn as a single flat quad, so the water shader cannot see the
// coastline directly. This walks a multi-source breadth-first search out of
// every land tile across the sea and stores, per tile, how many tiles away the
// shore is. The shader samples it to know where the wavelets should run. One
// byte per tile, normalised so 0 is the coast and 255 is `range` tiles or more
// offshore.
import { key } from "../../../shared/hex.js";
import { isWaterTile } from "./textures.js";
export function coastDistanceField(view, range = 8) {
const topo = view && view.topology;
const tiles = (view && view.tiles) || {};
if (!topo || range <= 0) return null;
const width = topo.width;
const height = topo.height;
const originX = topo.originX;
const originY = topo.originY;
const index = (x, y) => (y - originY) * width + (x - originX);
const dist = new Int32Array(width * height).fill(-1);
const queue = [];
for (let y = originY; y < originY + height; y++) {
for (let x = originX; x < originX + width; x++) {
const tile = tiles[key(x, y)];
if (!tile || isWaterTile(tile)) continue;
dist[index(x, y)] = 0;
queue.push({ x, y });
}
}
let head = 0;
while (head < queue.length) {
const { x, y } = queue[head++];
const distance = dist[index(x, y)];
if (distance >= range) continue;
for (const raw of topo.neighbours(x, y)) {
const nx = topo.wrapX(raw.x);
const ny = raw.y;
if (ny < originY || ny >= originY + height) continue;
const i = index(nx, ny);
if (dist[i] !== -1) continue;
if (!tiles[key(nx, ny)]) continue;
dist[i] = distance + 1;
queue.push({ x: nx, y: ny });
}
}
const data = new Uint8Array(width * height);
for (let i = 0; i < dist.length; i++) {
const distance = dist[i] < 0 ? range : Math.min(dist[i], range);
data[i] = Math.round((distance / range) * 255);
}
return { width, height, data };
}
+15
View File
@@ -43,6 +43,21 @@ export const GRASS_DRY_HIGH = 0.45;
export const GRASS_DRY_STRENGTH = 0.8;
export const GRASS_DRY_TINT = [1.2, 1.0, 0.45];
// Coastal wavelets: the baked coast field spans COAST_RANGE tiles and sets how
// strongly the ripples run near land, while `pulse` is how fast each wavelet
// comes and goes (cycles/s).
export const COAST_RANGE = 6;
export const COAST_WAVE_PULSE = 0.35;
export const COAST_WAVE_STRENGTH = 0.8;
// The baked Voronoi map repeats every WATER_REPEAT world pixels and holds
// `WATER_BUMP_CELLS` cells across it, so a cell is about
// WATER_REPEAT / WATER_BUMP_CELLS world pixels — a few map pixels. Those are
// the glinting wavelets; WATER_BUMP is how far they tilt the surface.
export const WATER_REPEAT = 32;
export const WATER_BUMP_CELLS = 8;
export const WATER_BUMP = 0.5;
// Levels of detail for the terrain mesh, from the closest zoom to the furthest.
// `segments` is the wavy-edge subdivision (1 draws straight hexes), `relief`
// scales the normal maps' surface detail so distant terrain does not shimmer
+82
View File
@@ -0,0 +1,82 @@
// Tileable Voronoi bump and normal map for the ocean surface.
//
// The sea is drawn as one flat quad, so all of its detail has to come from the
// shader. This bakes a seamless Voronoi field once: the ridge between the two
// nearest cells (F2 - F1) reads as a wave crest, and its gradient becomes a
// tangent-space normal. The result is a single RGBA byte texture, normal in RGB
// and height in A, ready to upload with REPEAT so it tiles across the world.
// A tiny seeded PRNG, so every client bakes the same water.
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function generateWaterBump(size = 256, cells = 6, seed = 1) {
const random = mulberry32(seed);
// One feature point per cell, as a fraction of the cell, wrapped below so the
// field is periodic.
const fx = new Float32Array(cells * cells);
const fy = new Float32Array(cells * cells);
for (let i = 0; i < fx.length; i++) {
fx[i] = random();
fy[i] = random();
}
const cellIndex = (cx, cy) => {
const x = ((cx % cells) + cells) % cells;
const y = ((cy % cells) + cells) % cells;
return y * cells + x;
};
const height = new Float32Array(size * size);
for (let y = 0; y < size; y++) {
const v = (y + 0.5) / size;
const cy = Math.floor(v * cells);
for (let x = 0; x < size; x++) {
const u = (x + 0.5) / size;
const cx = Math.floor(u * cells);
let first = Infinity;
let second = Infinity;
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const i = cellIndex(cx + ox, cy + oy);
// Keep the unwrapped cell position, so the seam measures correctly.
const dx = (cx + ox + fx[i]) / cells - u;
const dy = (cy + oy + fy[i]) / cells - v;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < first) {
second = first;
first = distance;
} else if (distance < second) {
second = distance;
}
}
}
height[y * size + x] = Math.min(1, (second - first) * cells * 0.8);
}
}
// Normal from the height gradient, central differences with wrap so the map
// tiles without a visible seam.
const data = new Uint8Array(size * size * 4);
const at = (x, y) => height[(((y % size) + size) % size) * size + (((x % size) + size) % size)];
const strength = size * 0.035;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let nx = -(at(x + 1, y) - at(x - 1, y)) * strength;
let ny = -(at(x, y + 1) - at(x, y - 1)) * strength;
const length = Math.sqrt(nx * nx + ny * ny + 1);
nx /= length;
ny /= length;
const i = (y * size + x) * 4;
data[i] = Math.round((nx * 0.5 + 0.5) * 255);
data[i + 1] = Math.round((ny * 0.5 + 0.5) * 255);
data[i + 2] = Math.round((1 / length * 0.5 + 0.5) * 255);
data[i + 3] = Math.round(height[y * size + x] * 255);
}
}
return { size, data };
}
+126 -28
View File
@@ -14,7 +14,7 @@
// shift so the copies wrap around the camera. Per-frame work is just uniforms
// and a handful of draw calls, so panning does not touch the DOM at all.
import { HEX_W, HEX_H, mapToLocal, parseKey, key } from "../../../shared/hex.js";
import { HEX_W, HEX_H, COL_STEP, mapToLocal, parseKey, key } from "../../../shared/hex.js";
import { nearestCorners, parseColour } from "./utils.js";
import {
CORNERS,
@@ -48,10 +48,18 @@ import {
GRASS_DRY_HIGH,
GRASS_DRY_STRENGTH,
GRASS_DRY_TINT,
COAST_WAVE_PULSE,
COAST_WAVE_STRENGTH,
COAST_RANGE,
WATER_REPEAT,
WATER_BUMP_CELLS,
WATER_BUMP,
TERRAIN_LODS,
terrainLod,
} from "./constants.js";
import { createHeightField } from "./heightmap.js";
import { coastDistanceField } from "./coast.js";
import { generateWaterBump } from "./water.js";
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, height, the base
// colour (grass, sand, atlas), one weight per overlay, then the two slope
@@ -87,6 +95,8 @@ const UNIT_SAND = 2;
const UNIT_FOREST = 3;
const UNIT_MOUNTAIN = 4;
const UNIT_MOUNTAIN_NORMAL = 5;
const UNIT_COAST = 6;
const UNIT_WATER = 7;
// National border stroke, and the region border that is half as thick and
// drawn as dotted dashes instead of a continuous line.
const NATIONAL_BORDER = 3.5;
@@ -158,6 +168,15 @@ uniform float uGrassDryLow;
uniform float uGrassDryHigh;
uniform float uGrassDryStrength;
uniform vec3 uGrassDryTint;
uniform sampler2D uCoast;
uniform vec2 uCoastOrigin;
uniform vec2 uCoastSize;
uniform vec2 uHexStep;
uniform float uWavePulse;
uniform float uWaveStrength;
uniform sampler2D uWaterNormal;
uniform float uWaterRepeat;
uniform float uWaterBump;
varying vec2 vUV;
varying vec4 vColour;
varying vec2 vWorld;
@@ -171,27 +190,6 @@ vec3 reliefNormal(vec4 texel) {
vec2 slope = texel.xy * 2.0 - 1.0;
return normalize(vec3(slope, 1.0));
}
// Procedural sea: a few crossing swells drive the deep/shallow gradient and a
// soft specular sparkle. It is driven by the same period-snapped texture
// coordinate as the land, whose whole cycles repeat with the world, so the waves
// stay seamless across both tile edges and the cylindrical wrap.
float waterWave(vec2 uv, vec2 freq, float speed, float t) {
return sin(6.2831853 * dot(freq, uv) + t * speed);
}
vec3 waterColour(vec2 uv, float t) {
float swell = waterWave(uv, vec2(1.0, 0.6), 0.55, t) * 0.4
+ waterWave(uv, vec2(-2.0, 1.3), 0.8, t) * 0.3
+ waterWave(uv, vec2(3.0, 0.4), 1.05, t) * 0.25;
float ripple = 0.5 + 0.5 * waterWave(uv, vec2(5.0, -3.0), 1.6, t);
// A narrow, low-contrast blue so the sea reads as calm water rather than a
// busy pattern. Depth drifts only slightly and the highlights are faint.
vec3 deep = vec3(0.078, 0.235, 0.353);
vec3 shallow = vec3(0.098, 0.275, 0.396);
vec3 colour = mix(deep, shallow, clamp(swell * 0.35 + 0.5, 0.0, 1.0));
colour += vec3(0.04, 0.06, 0.07) * pow(clamp(swell, 0.0, 1.0), 3.0);
colour += vec3(0.06, 0.08, 0.09) * pow(ripple, 8.0);
return colour;
}
void main() {
if (uMode < 0.5) {
vec2 uv = vWorld / uTextureRepeat;
@@ -218,7 +216,36 @@ void main() {
// Coastal sand fades the sand texture into non-mountain shores.
base = mix(base, sand, vOverlay.z);
// Sea tiles are replaced by the animated water; the flag is 0 elsewhere.
base = mix(base, waterColour(uv, uTime), vColour.a);
if (vColour.a > 0.0) {
// The coast field holds the distance to land per tile: the wavelets run
// strongest over the shallow shelf and come and go over time. The world
// position is the base frame's, so the wrapped copies sample the same
// tile as the base.
float col = floor(vWorld.x / uHexStep.x + 0.5);
col = mod(col - uCoastOrigin.x, uCoastSize.x) + uCoastOrigin.x;
float row = floor(vWorld.y / uHexStep.y - 0.5 * mod(col, 2.0) + 0.5);
vec2 coastUV = (vec2(col, row) - uCoastOrigin + 0.5) / uCoastSize;
float shore = 1.0 - texture2D(uCoast, coastUV).r;
float pulse = 0.5 + 0.5 * sin(uTime * uWavePulse);
float wavelets = shore * shore * pulse;
// The baked Voronoi normal map, two scrolling layers, gives the surface
// its ripples. There is no large-scale sine field: the only pattern is
// this fine one, scaled up towards the coast.
vec2 wuv = vWorld / uWaterRepeat;
vec4 samp = texture2D(uWaterNormal, wuv + vec2(uTime * 0.02, uTime * 0.013));
vec2 ripple = samp.rg * 2.0 - 1.0;
ripple += texture2D(uWaterNormal, wuv * 1.7 - vec2(uTime * 0.017, uTime * 0.011)).rg * 2.0 - 1.0;
ripple *= uWaterBump * (0.5 + 1.5 * wavelets * uWaveStrength);
vec3 waterNormal = normalize(vec3(ripple, 1.0));
vec3 halfDir = normalize(normalize(vec3(-0.5, -0.6, 0.8)) + vec3(0.0, 0.0, 1.0));
float spec = pow(max(dot(waterNormal, halfDir), 0.0), 48.0);
vec3 deep = vec3(0.016, 0.078, 0.196);
vec3 shallow = vec3(0.043, 0.184, 0.337);
vec3 water = mix(deep, shallow, samp.a * 0.6 + 0.2);
water += vec3(0.75, 0.85, 0.95) * (spec * (0.35 + 0.65 * wavelets));
water += vec3(0.10, 0.15, 0.17) * (wavelets * samp.a * uWaveStrength);
base = mix(base, water, vColour.a);
}
// A light from the upper left rakes over the terrain. The geometry normal
// is built from the height gradient carried per vertex; the mountain normal
// map perturbs it with its surface detail. Dividing the dot product by the
@@ -293,7 +320,10 @@ export function createGLMapRenderer(canvas) {
if (!window.WebGL2RenderingContext && !window.WebGLRenderingContext) return null;
try {
return new GLMapRenderer(canvas);
} catch {
} catch (error) {
// The DOM renderer takes over, but do not hide why: shader and context
// failures are otherwise invisible.
if (typeof console !== "undefined") console.warn("WebGL map renderer unavailable:", error);
return null;
}
}
@@ -307,9 +337,12 @@ export class GLMapRenderer {
this.version = 0;
this._sig = null;
this.view = null;
// One GL texture per source: the legacy atlas plus the three land textures.
// One GL texture per source: the legacy atlas plus the land textures.
this.textures = {};
this.texReady = false;
// The per-tile distance-to-land field the water shader samples for its
// coastal wavelets, built with the world.
this.coastTexture = null;
// One pass per layer: { buffer, count } and, for terrain, its atlas cell.
this.terrain = { buffer: null, count: 0 };
// Terrain meshes for the levels of detail, built lazily as the camera zooms
@@ -407,6 +440,8 @@ export class GLMapRenderer {
this.terrainLevels = [];
for (const texture of Object.values(this.textures)) gl.deleteTexture(texture);
this.textures = {};
if (this.coastTexture) gl.deleteTexture(this.coastTexture);
this.coastTexture = null;
if (this.program) gl.deleteProgram(this.program);
if (this.fullscreenProgram) gl.deleteProgram(this.fullscreenProgram);
if (this.fullscreenQuad) gl.deleteBuffer(this.fullscreenQuad);
@@ -467,6 +502,15 @@ export class GLMapRenderer {
this.uGrassDryHigh = gl.getUniformLocation(program, "uGrassDryHigh");
this.uGrassDryStrength = gl.getUniformLocation(program, "uGrassDryStrength");
this.uGrassDryTint = gl.getUniformLocation(program, "uGrassDryTint");
this.uCoast = gl.getUniformLocation(program, "uCoast");
this.uCoastOrigin = gl.getUniformLocation(program, "uCoastOrigin");
this.uCoastSize = gl.getUniformLocation(program, "uCoastSize");
this.uHexStep = gl.getUniformLocation(program, "uHexStep");
this.uWavePulse = gl.getUniformLocation(program, "uWavePulse");
this.uWaveStrength = gl.getUniformLocation(program, "uWaveStrength");
this.uWaterNormal = gl.getUniformLocation(program, "uWaterNormal");
this.uWaterRepeat = gl.getUniformLocation(program, "uWaterRepeat");
this.uWaterBump = gl.getUniformLocation(program, "uWaterBump");
}
_compile(type, source) {
@@ -607,10 +651,12 @@ export class GLMapRenderer {
return this.fogBlur;
}
// Loads the four sources the terrain shader samples. The atlas is clamped
// (its cells must not bleed into one another); the three land textures repeat,
// so the seam between two tiles continues the same pattern.
// Loads the image sources the terrain shader samples and bakes the water
// normal map. The atlas is clamped (its cells must not bleed into one
// another); the tiling textures repeat, so the seam between two tiles
// continues the same pattern.
_initTexture() {
const gl = this.gl;
this.textures = {};
this.texReady = false;
let pending = 0;
@@ -639,6 +685,17 @@ export class GLMapRenderer {
load(TEXTURE_FOREST, `${TEXTURE_DIR}${TEXTURE_FOREST}.png`, true);
load(TEXTURE_MOUNTAIN, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN}.png`, true);
load(TEXTURE_MOUNTAIN_NORMAL, `${TEXTURE_DIR}${TEXTURE_MOUNTAIN_NORMAL}.png`, true);
// The water normal map is baked rather than loaded, so it is ready at once.
const bump = generateWaterBump(256, WATER_BUMP_CELLS, 1337);
const water = this._createTexture(true);
this.textures.water = water;
gl.bindTexture(gl.TEXTURE_2D, water);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, bump.size, bump.size, 0,
gl.RGBA, gl.UNSIGNED_BYTE, bump.data);
// Its features are about a world pixel, so mipmaps keep them from
// shimmering when the camera pulls back.
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
}
_createTexture(repeat) {
@@ -679,6 +736,7 @@ export class GLMapRenderer {
this.fullscreenQuad = null;
}
this._initTexture();
this.coastTexture = null;
this.lost = false;
if (this.view) this.setWorld(this.view);
} catch {
@@ -774,6 +832,7 @@ export class GLMapRenderer {
this.heightField = createHeightField(view);
this._heightView = view;
this._warp = this._buildWarpSet(view);
this._buildCoastTexture(view);
this.terrainLevels = [];
this.terrain = this._buildTerrainPass(view, 0);
this.terrainLevels[0] = this.terrain;
@@ -784,6 +843,30 @@ export class GLMapRenderer {
this.buildRegionOverlay(view);
}
// The per-tile distance-to-land field the water shader samples for its
// coastal wavelets. Rebuilt with the world; a one-texel far field when there
// is no coast to find, so the shader still has a texture to bind.
_buildCoastTexture(view) {
const gl = this.gl;
const field = view && view.topology ? coastDistanceField(view, COAST_RANGE) : null;
if (!this.coastTexture) this.coastTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.coastTexture);
// One byte per texel, and the width is rarely a multiple of four.
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
if (field) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, field.width, field.height, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, field.data);
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0,
gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8Array([255]));
}
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
// Every lattice vertex a land tile touches, keyed the way `edgePoint` looks
// them up. Open-water vertices are left out, so a sea tile with only sea
// neighbours is drawn as a regular hexagon while the coast still meets the
@@ -1655,6 +1738,12 @@ export class GLMapRenderer {
gl.activeTexture(gl.TEXTURE5);
gl.bindTexture(gl.TEXTURE_2D, this.textures.mountains_normal_map);
gl.uniform1i(this.uMountainNormal, UNIT_MOUNTAIN_NORMAL);
gl.activeTexture(gl.TEXTURE6);
gl.bindTexture(gl.TEXTURE_2D, this.coastTexture);
gl.uniform1i(this.uCoast, UNIT_COAST);
gl.activeTexture(gl.TEXTURE7);
gl.bindTexture(gl.TEXTURE_2D, this.textures.water);
gl.uniform1i(this.uWaterNormal, UNIT_WATER);
gl.enableVertexAttribArray(this.aUV);
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
gl.enableVertexAttribArray(this.aWorld);
@@ -1745,6 +1834,15 @@ export class GLMapRenderer {
gl.uniform1f(this.uGrassDryHigh, GRASS_DRY_HIGH);
gl.uniform1f(this.uGrassDryStrength, GRASS_DRY_STRENGTH);
gl.uniform3f(this.uGrassDryTint, GRASS_DRY_TINT[0], GRASS_DRY_TINT[1], GRASS_DRY_TINT[2]);
gl.uniform1f(this.uWavePulse, COAST_WAVE_PULSE);
gl.uniform1f(this.uWaveStrength, COAST_WAVE_STRENGTH);
gl.uniform1f(this.uWaterRepeat, WATER_REPEAT);
gl.uniform1f(this.uWaterBump, WATER_BUMP);
gl.uniform2f(this.uHexStep, COL_STEP, HEX_H);
if (view.topology) {
gl.uniform2f(this.uCoastOrigin, view.topology.originX, view.topology.originY);
gl.uniform2f(this.uCoastSize, view.topology.width, view.topology.height);
}
gl.uniform1f(this.uTextureRepeat, textureRepeat(view._period || 0));
gl.uniform1f(this.uTime, now);
gl.enable(gl.BLEND);
+58
View File
@@ -0,0 +1,58 @@
import { TestCase } from "./framework/test_case.js";
import { MapTopology, key } from "../shared/hex.js";
import { coastDistanceField } from "../client/js/map_view/coast.js";
function fixture(terrainAt, size = 5) {
const topology = new MapTopology({ x: size, y: size }, false);
const tiles = {};
for (let y = topology.originY; y < topology.originY + size; y++) {
for (let x = topology.originX; x < topology.originX + size; x++) {
tiles[key(x, y)] = { terrainType: terrainAt(x, y), col: 0 };
}
}
return { seed: 1, topology, tiles };
}
function at(field, view, x, y) {
const i = (y - view.topology.originY) * field.width + (x - view.topology.originX);
return field.data[i];
}
export class CoastTest extends TestCase {
test_no_world_has_no_field() {
this.assertNull(coastDistanceField({ topology: null, tiles: {} }, 4));
this.assertNull(coastDistanceField(fixture(() => "Sea"), 0), "a zero range is no field");
}
test_land_is_the_source_and_the_sea_climbs_away() {
const view = fixture((x, y) => (x === 0 && y === 0 ? "Land" : "Sea"));
const field = coastDistanceField(view, 3);
this.assertEqual(field.width, 5);
this.assertEqual(field.height, 5);
this.assertEqual(at(field, view, 0, 0), 0, "the land is the coast");
this.assertEqual(at(field, view, 1, 0), Math.round(255 / 3), "one tile out");
this.assertEqual(at(field, view, 2, -2), 255, "three tiles out is the range");
this.assertEqual(at(field, view, -2, 2), 255, "beyond the range is clamped");
}
test_a_landlocked_sea_reads_as_coast() {
// A one-tile lake is a single sea tile ringed by land: every direction is
// one step from a source, so its whole field is the first band.
const view = fixture((x, y) => (x === 0 && y === 0 ? "Sea" : "Land"));
const field = coastDistanceField(view, 4);
this.assertEqual(at(field, view, 0, 0), Math.round(255 / 4), "the lake is all shore");
}
test_ice_counts_as_land() {
const view = fixture((x, y) => (x === 0 && y === 0 ? "Ice" : "Sea"));
const field = coastDistanceField(view, 4);
this.assertEqual(at(field, view, 0, 0), 0, "ice is a coast");
this.assertEqual(at(field, view, 1, 0), Math.round(255 / 4), "the water beside it");
}
test_an_all_sea_map_has_no_coast() {
const view = fixture(() => "Sea");
const field = coastDistanceField(view, 4);
for (const value of field.data) this.assertEqual(value, 255, "everything is far offshore");
}
}
+59
View File
@@ -0,0 +1,59 @@
import { TestCase } from "./framework/test_case.js";
import { generateWaterBump } from "../client/js/map_view/water.js";
export class WaterTest extends TestCase {
test_is_deterministic_for_a_seed() {
const first = generateWaterBump(64, 4, 7);
const second = generateWaterBump(64, 4, 7);
this.assertEqual(first.data, second.data, "the same seed bakes the same map");
const other = generateWaterBump(64, 4, 8);
this.assertNotEqual(first.data, other.data, "a new seed reshapes the cells");
}
test_the_map_is_the_requested_size() {
const bump = generateWaterBump(32, 4, 1);
this.assertEqual(bump.size, 32);
this.assertEqual(bump.data.length, 32 * 32 * 4, "RGBA bytes");
}
test_every_normal_is_unit_length() {
const bump = generateWaterBump(64, 5, 99);
for (let i = 0; i < bump.data.length; i += 4) {
const nx = (bump.data[i] / 255) * 2 - 1;
const ny = (bump.data[i + 1] / 255) * 2 - 1;
const nz = (bump.data[i + 2] / 255) * 2 - 1;
this.assertApprox(Math.hypot(nx, ny, nz), 1, 0.02, "the normal is unit length");
this.assertTrue(nz > 0, "the normal points out of the surface");
}
}
test_the_height_channel_varies() {
const bump = generateWaterBump(64, 5, 3);
let low = 255;
let high = 0;
for (let i = 3; i < bump.data.length; i += 4) {
low = Math.min(low, bump.data[i]);
high = Math.max(high, bump.data[i]);
}
this.assertGreater(high - low, 40, "the Voronoi crests and troughs span a range");
}
test_the_field_wraps_without_a_seam() {
// The feature points are wrapped and the normal uses wrapped differences,
// so the right-to-left seam must be no rougher than an interior step.
const bump = generateWaterBump(64, 4, 21);
const size = bump.size;
const columnDiff = (x0, x1) => {
let sum = 0;
for (let y = 0; y < size; y++) {
sum += Math.abs(bump.data[(y * size + x0) * 4] - bump.data[(y * size + x1) * 4]);
}
return sum / size;
};
let interior = 0;
for (let x = 0; x < size - 1; x++) interior += columnDiff(x, x + 1);
interior /= size - 1;
this.assertLess(columnDiff(size - 1, 0), interior * 4 + 4,
"the wrapping seam is no rougher than the interior");
}
}