diff --git a/client/js/map_view/coast.js b/client/js/map_view/coast.js new file mode 100644 index 0000000..609fe03 --- /dev/null +++ b/client/js/map_view/coast.js @@ -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 }; +} diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 1c97478..e9df068 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -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 diff --git a/client/js/map_view/water.js b/client/js/map_view/water.js new file mode 100644 index 0000000..9520cdf --- /dev/null +++ b/client/js/map_view/water.js @@ -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 }; +} diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index 471886c..dc6497b 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -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); diff --git a/tests/coast_test.js b/tests/coast_test.js new file mode 100644 index 0000000..a875e1f --- /dev/null +++ b/tests/coast_test.js @@ -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"); + } +} diff --git a/tests/water_test.js b/tests/water_test.js new file mode 100644 index 0000000..15d44aa --- /dev/null +++ b/tests/water_test.js @@ -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"); + } +}