Added coastal water wavelets with a Voronoi normal map
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user