Added a seeded terrain heightmap with relief lighting and LODs
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
// Tuning constants for the world map view.
|
||||
|
||||
import { HEX_H } from "../../../shared/hex.js";
|
||||
import { EDGE_SEGMENTS } from "./distortion.js";
|
||||
|
||||
export const RESYNC_HOURS = 0.75;
|
||||
export const PREDICTION_GRACE_HOURS = 2.0;
|
||||
export const DRAG_THRESHOLD = 5;
|
||||
@@ -14,6 +17,42 @@ export const ZOOM_MAX = 2.0;
|
||||
export const CAMERA_ELEVATION_DEG = 45;
|
||||
export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180);
|
||||
|
||||
// How far the tallest terrain (normalised height 1, see map_view/heightmap.js)
|
||||
// is lifted off the ground plane, in world pixels. The projection turns a lift
|
||||
// into a northward screen shift of `lift * cos(elevation)`, while the ground
|
||||
// distance between a tile and its northern neighbour is
|
||||
// `HEX_H * sin(elevation) = HEX_H * CAMERA_TILT`. A tile already reaches half a
|
||||
// hex north of its centre, so keeping the lift under half that spacing (here at
|
||||
// 45%) means even a height-1 ridge stops short of the next tile's centre: no
|
||||
// tile north of a peak is eclipsed, and the painter's-order layers below need
|
||||
// no depth buffer.
|
||||
export const HEIGHT_LIFT = HEX_H * CAMERA_TILT * 1; // set by a human
|
||||
|
||||
// The normalised-height band where a mountain's cap texture (rock/snow) fades
|
||||
// in: below `low` the slopes stay grass, above `high` the cap is at full
|
||||
// strength and in between it blends. Tune these to move the snow line.
|
||||
export const MOUNTAIN_ROCK_LOW = 0.3;
|
||||
export const MOUNTAIN_ROCK_HIGH = 0.8;
|
||||
|
||||
// Levels of detail for the terrain mesh, from the closest zoom to the furthest.
|
||||
// `segments` is the wavy-edge subdivision (1 draws straight hexes) and `relief`
|
||||
// scales the normal maps' surface detail so distant terrain does not shimmer
|
||||
// (the height-gradient lighting stays on at every level). The coarsest level's
|
||||
// `minZoom` is -Infinity so every zoom maps to a level.
|
||||
export const TERRAIN_LODS = [
|
||||
{ minZoom: 0.6, segments: EDGE_SEGMENTS, relief: 1.0 },
|
||||
{ minZoom: 0.3, segments: 2, relief: 0.55 },
|
||||
{ minZoom: -Infinity, segments: 1, relief: 0.0 },
|
||||
];
|
||||
|
||||
// The level of detail that serves a camera zoom.
|
||||
export function terrainLod(zoom) {
|
||||
for (let i = 0; i < TERRAIN_LODS.length; i++) {
|
||||
if (zoom >= TERRAIN_LODS[i].minZoom) return i;
|
||||
}
|
||||
return TERRAIN_LODS.length - 1;
|
||||
}
|
||||
|
||||
// A battle spreads the two stacks to either side of the tile centre, with the
|
||||
// battle icon between them. Side offset is in pixels; step is the spacing
|
||||
// between the (half-size) units of one stack.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Deterministic relief for the world map.
|
||||
//
|
||||
// The simulation stays perfectly flat; this module only computes the cosmetic
|
||||
// height every ground vertex is lifted by in the WebGL renderer. It is a pure
|
||||
// function of (seed, mapConfig, terrain), so every client sees the same relief
|
||||
// and a reload does not change the shape of the hills.
|
||||
//
|
||||
// The field has two parts. A fractal noise term, evaluated on a cylinder so it
|
||||
// wraps seamlessly on a cylindrical map, gives a smooth rolling base; a
|
||||
// per-terrain term raises mountains and hills above the plains and keeps the
|
||||
// sea at zero. The terrain part is blended between neighbouring tiles with the
|
||||
// same distance kernel the textures use, so a vertex shared by two tiles (and
|
||||
// every border or road point that crosses between them) computes exactly the
|
||||
// same height and the mesh stays watertight.
|
||||
//
|
||||
// Heights are normalised to [0, 1]. `constants.js` turns that into a screen
|
||||
// lift small enough that a ridge can never rise high enough to eclipse the tile
|
||||
// north of it.
|
||||
|
||||
import { SimplexNoise } from "../../../shared/noise.js";
|
||||
import { HEX_H, COL_STEP, key, mapToLocal, parity } from "../../../shared/hex.js";
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
// Mixed into the world seed so the relief field is independent of the terrain
|
||||
// generator's own noise.
|
||||
const HEIGHT_SALT = 0x68e31da4;
|
||||
|
||||
// Fractal detail of the rolling base. Frequencies are in tile units.
|
||||
export const HEIGHT_NOISE = {
|
||||
frequency: 0.045,
|
||||
octaves: 3,
|
||||
lacunarity: 2.0,
|
||||
gain: 0.5,
|
||||
};
|
||||
|
||||
// Blend radius of the terrain kernel, matching the texture blend so the two
|
||||
// fields share the same seams. A neighbour's centre is about one edge away, so
|
||||
// a shared edge lands at an even mix.
|
||||
export const HEIGHT_BLEND_RADIUS = HEX_H;
|
||||
|
||||
// Floor and relief per terrain type, as fractions of the maximum height. The
|
||||
// floor is the height at the low end of the noise and the relief how much the
|
||||
// noise adds on top, so Mountain ranges from 0.5 to 1.0 and the sea stays flat.
|
||||
const TERRAIN_HEIGHT = {
|
||||
Mountain: { floor: 0.5, relief: 0.5 },
|
||||
Hills: { floor: 0.22, relief: 0.38 },
|
||||
Forest: { floor: 0.08, relief: 0.16 },
|
||||
Land: { floor: 0.04, relief: 0.12 },
|
||||
Desert: { floor: 0.02, relief: 0.08 },
|
||||
Ice: { floor: 0.0, relief: 0.04 },
|
||||
Sea: { floor: 0.0, relief: 0.0 },
|
||||
};
|
||||
|
||||
// A smooth (squared) falloff that reaches zero at the blend radius.
|
||||
function falloff(distance) {
|
||||
if (distance >= HEIGHT_BLEND_RADIUS) return 0;
|
||||
const t = 1 - distance / HEIGHT_BLEND_RADIUS;
|
||||
return t * t;
|
||||
}
|
||||
|
||||
export class HeightField {
|
||||
constructor(view) {
|
||||
this.seed = ((view && view.seed) | 0) >>> 0;
|
||||
this.topology = (view && view.topology) || null;
|
||||
this.tiles = (view && view.tiles) || {};
|
||||
const config = view && view.mapConfig;
|
||||
this.noiseConfig = (config && config.heightNoise) || HEIGHT_NOISE;
|
||||
this._noise = new SimplexNoise(this.seed ^ HEIGHT_SALT);
|
||||
this._cylindrical = !!(this.topology && this.topology.cylindrical &&
|
||||
this.topology.width > 0);
|
||||
this._width = this.topology ? this.topology.width : 0;
|
||||
this._originX = this.topology ? this.topology.originX : 0;
|
||||
this._cache = new Map();
|
||||
}
|
||||
|
||||
// The normalised height of one tile: its terrain floor plus the relief scaled
|
||||
// by the wrapped noise field. Cached, because a vertex blend reads the same
|
||||
// handful of tiles over and over.
|
||||
tileHeight(x, y) {
|
||||
const wrappedX = this._cylindrical ? this.topology.wrapX(x) : x;
|
||||
const k = key(wrappedX, y);
|
||||
const cached = this._cache.get(k);
|
||||
if (cached !== undefined) return cached;
|
||||
const tile = this.tiles[k];
|
||||
const profile = tile && TERRAIN_HEIGHT[tile.terrainType];
|
||||
const height = profile ? profile.floor + profile.relief * this._noise01(wrappedX, y) : 0;
|
||||
this._cache.set(k, height);
|
||||
return height;
|
||||
}
|
||||
|
||||
// The wrapped fractal field at a tile, remapped from [-1, 1] to [0, 1].
|
||||
_noise01(x, y) {
|
||||
let value;
|
||||
if (this._cylindrical) {
|
||||
// Sample the noise on a cylinder whose circumference is the map width, so
|
||||
// the seam joins itself and the relief does not jump across the wrap.
|
||||
const angle = (TAU * (x - this._originX)) / this._width;
|
||||
const radius = this._width / TAU;
|
||||
value = this._noise.fbm(
|
||||
Math.cos(angle) * radius,
|
||||
Math.sin(angle) * radius,
|
||||
y,
|
||||
this.noiseConfig
|
||||
);
|
||||
} else {
|
||||
value = this._noise.fbm(x, y, undefined, this.noiseConfig);
|
||||
}
|
||||
return Math.min(1, Math.max(0, value * 0.5 + 0.5));
|
||||
}
|
||||
|
||||
// The tile a world point falls in, by nearest centre. Direct distances are
|
||||
// fine here: callers pass unwrapped world coordinates, and `tileHeight`
|
||||
// folds the wrapped axis itself.
|
||||
nearest(x, y) {
|
||||
const column = Math.round(x / COL_STEP);
|
||||
const baseY = Math.round(y / HEX_H - 0.5 * parity(column));
|
||||
let best = { x: column, y: baseY };
|
||||
let bestDistance = Infinity;
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const cx = column + dx;
|
||||
const cy = baseY + dy;
|
||||
const local = mapToLocal(cx, cy);
|
||||
const distance = (local.x - x) ** 2 + (local.y - y) ** 2;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { x: cx, y: cy };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A tile and its six (wrapped) neighbours, each with its pixel centre and
|
||||
// height. The set is a pure function of the tile, so the two tiles sharing an
|
||||
// edge build the same field around it.
|
||||
ring(x, y) {
|
||||
const centre = mapToLocal(x, y);
|
||||
const samples = [{ h: this.tileHeight(x, y), x: centre.x, y: centre.y }];
|
||||
if (!this.topology) return samples;
|
||||
const coords = { x, y };
|
||||
for (const neighbour of this.topology.neighbours(x, y)) {
|
||||
const delta = this.topology.pixelDelta(coords, neighbour);
|
||||
samples.push({
|
||||
h: this.tileHeight(neighbour.x, neighbour.y),
|
||||
x: centre.x + delta.x,
|
||||
y: centre.y + delta.y,
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
// Blends a ring's tile heights at a world point by distance. Beyond the blend
|
||||
// radius a tile does not contribute, so the extra tiles in one of two
|
||||
// overlapping rings vanish and the point gets the same value from either.
|
||||
blend(ring, x, y) {
|
||||
let sum = 0;
|
||||
let total = 0;
|
||||
for (const sample of ring) {
|
||||
const weight = falloff(Math.hypot(sample.x - x, sample.y - y));
|
||||
if (weight <= 0) continue;
|
||||
sum += sample.h * weight;
|
||||
total += weight;
|
||||
}
|
||||
if (total > 0) return sum / total;
|
||||
// The containing tile is always within the radius, so this only guards an
|
||||
// empty ring; fall back to the nearest sample.
|
||||
let best = 0;
|
||||
let bestDistance = Infinity;
|
||||
for (const sample of ring) {
|
||||
const distance = Math.hypot(sample.x - x, sample.y - y);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = sample.h;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// The blended height and its gradient at a world point, in normalised height
|
||||
// per world pixel. The gradient is the analytic derivative of the same
|
||||
// distance-weighted average `blend` returns, so terrain lighting can build a
|
||||
// per-vertex normal without sampling the field again. Both tiles of a shared
|
||||
// vertex read the same ring at the same point and so agree on the slope.
|
||||
sample(ring, x, y) {
|
||||
let sum = 0;
|
||||
let total = 0;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
for (const tile of ring) {
|
||||
const dx = tile.x - x;
|
||||
const dy = tile.y - y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance >= HEIGHT_BLEND_RADIUS) continue;
|
||||
const t = 1 - distance / HEIGHT_BLEND_RADIUS;
|
||||
const weight = t * t;
|
||||
// Derivative of the squared falloff with respect to distance, times the
|
||||
// unit direction from the sample to the point (the gradient of the
|
||||
// distance rises towards the point).
|
||||
const slope = (-2 * t) / HEIGHT_BLEND_RADIUS;
|
||||
const inv = distance > 0 ? 1 / distance : 0;
|
||||
const weightX = slope * -dx * inv;
|
||||
const weightY = slope * -dy * inv;
|
||||
sum += tile.h * weight;
|
||||
total += weight;
|
||||
sumX += tile.h * weightX;
|
||||
sumY += tile.h * weightY;
|
||||
totalX += weightX;
|
||||
totalY += weightY;
|
||||
}
|
||||
if (total <= 0) return { h: this.blend(ring, x, y), dx: 0, dy: 0 };
|
||||
const inv = 1 / total;
|
||||
const h = sum * inv;
|
||||
return { h, dx: (sumX - h * totalX) * inv, dy: (sumY - h * totalY) * inv };
|
||||
}
|
||||
|
||||
// The height at an arbitrary world point, for points whose owning tile is not
|
||||
// already known (border and road vertices).
|
||||
at(x, y) {
|
||||
if (!this.topology) return 0;
|
||||
const coords = this.nearest(x, y);
|
||||
return this.blend(this.ring(coords.x, coords.y), x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the field for a view, or null when the view carries no world.
|
||||
export function createHeightField(view) {
|
||||
if (!view || !view.topology || !view.tiles) return null;
|
||||
return new HeightField(view);
|
||||
}
|
||||
+246
-80
@@ -39,22 +39,39 @@ import {
|
||||
vertexTextureWeights,
|
||||
} from "./textures.js";
|
||||
import { blurFog, fogLevel, isFogged } from "./fog.js";
|
||||
import { CAMERA_TILT } from "./constants.js";
|
||||
import {
|
||||
CAMERA_TILT,
|
||||
HEIGHT_LIFT,
|
||||
MOUNTAIN_ROCK_LOW,
|
||||
MOUNTAIN_ROCK_HIGH,
|
||||
TERRAIN_LODS,
|
||||
terrainLod,
|
||||
} from "./constants.js";
|
||||
import { createHeightField } from "./heightmap.js";
|
||||
|
||||
// Full terrain vertex layout, in floats: x, y, u, v, wx, wy, plus the base
|
||||
// colour (grass, sand, atlas) and one weight per overlay. Fog uses a compact
|
||||
// x, y, r, g, b, a layout instead. The flat-colour passes (roads, borders,
|
||||
// region) use x, y, u, v, r, g, b, a.
|
||||
// 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
|
||||
// components. Fog uses a compact x, y, height, r, g, b, a layout instead. The
|
||||
// flat-colour passes (roads, borders, region) use x, y, height, padding,
|
||||
// r, g, b, a.
|
||||
const ATTR_POS = 0;
|
||||
const ATTR_UV = 1;
|
||||
const ATTR_COLOUR = 2;
|
||||
const ATTR_WORLD = 3;
|
||||
const ATTR_OVERLAY = 4;
|
||||
const ATTR_HEIGHT = 5;
|
||||
const ATTR_SLOPE = 6;
|
||||
|
||||
// Floats per vertex in each layout.
|
||||
const TERRAIN_FLOATS = 14;
|
||||
// Floats per vertex in each layout. Terrain is
|
||||
// x, y, u, v, wx, wy, height, four base weights, four overlay weights, then the
|
||||
// two slope components. The flat colour passes are x, y, height, padding,
|
||||
// r, g, b, a. Fog is x, y, height, r, g, b, a.
|
||||
const TERRAIN_FLOATS = 17;
|
||||
const COLOUR_FLOATS = 8;
|
||||
const FOG_FLOATS = 6;
|
||||
const FOG_FLOATS = 7;
|
||||
|
||||
// A flat sample, used when a view has no relief field (tests, stub views).
|
||||
const ZERO_SAMPLE = { h: 0, dx: 0, dy: 0 };
|
||||
|
||||
const ROAD = [138 / 255, 90 / 255, 43 / 255, 1];
|
||||
// The terrain atlas is a single row of equal cells, one per terrain type.
|
||||
@@ -82,26 +99,38 @@ attribute vec2 aUV;
|
||||
attribute vec4 aColour;
|
||||
attribute vec2 aWorld;
|
||||
attribute vec4 aOverlay;
|
||||
attribute float aHeight;
|
||||
attribute vec2 aSlope;
|
||||
uniform vec2 uCamera;
|
||||
uniform vec2 uViewport;
|
||||
uniform float uZoom;
|
||||
uniform float uTilt;
|
||||
uniform float uShiftX;
|
||||
uniform float uHeightScale;
|
||||
varying vec2 vUV;
|
||||
varying vec4 vColour;
|
||||
varying vec2 vWorld;
|
||||
varying vec4 vOverlay;
|
||||
varying vec2 vSlope;
|
||||
varying float vHeight;
|
||||
void main() {
|
||||
// Orthographic camera looking north at uTilt over the ground: the north-south
|
||||
// axis is foreshortened, the east-west axis is untouched.
|
||||
// axis is foreshortened, the east-west axis is untouched. A terrain height
|
||||
// lifts the vertex northward on screen (the projection of the vertical axis),
|
||||
// before the zoom scales the whole map.
|
||||
vec2 world = vec2(aPos.x + uShiftX, aPos.y);
|
||||
vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt) * uZoom + uViewport * 0.5;
|
||||
float lift = aHeight * uHeightScale;
|
||||
vec2 screen = vec2(world.x - uCamera.x, (world.y - uCamera.y) * uTilt - lift) * uZoom + uViewport * 0.5;
|
||||
vec2 clip = (screen / uViewport) * 2.0 - 1.0;
|
||||
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
||||
vUV = aUV;
|
||||
vColour = aColour;
|
||||
vWorld = aWorld;
|
||||
vOverlay = aOverlay;
|
||||
// The gradient rides in normalised height per pixel; scale it into the same
|
||||
// world units the lift uses so the fragment normal is geometrically true.
|
||||
vSlope = aSlope * uHeightScale;
|
||||
vHeight = aHeight;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -122,10 +151,15 @@ uniform sampler2D uMountainNormal;
|
||||
uniform float uTextureRepeat;
|
||||
uniform float uTime;
|
||||
uniform float uMode;
|
||||
uniform float uRelief;
|
||||
uniform float uRockLow;
|
||||
uniform float uRockHigh;
|
||||
varying vec2 vUV;
|
||||
varying vec4 vColour;
|
||||
varying vec2 vWorld;
|
||||
varying vec4 vOverlay;
|
||||
varying vec2 vSlope;
|
||||
varying float vHeight;
|
||||
// The relief normal is rebuilt from the red/green gradient and a fixed up axis,
|
||||
// so the maps' blue channel (which is not a clean 255 at rest) cannot bias the
|
||||
// lighting. A flat texel is (0, 0, 1) and lights to exactly 1.
|
||||
@@ -164,21 +198,29 @@ void main() {
|
||||
vec4 forest = texture2D(uForest, uv);
|
||||
vec4 hills = texture2D(uHills, uv);
|
||||
vec4 mountain = texture2D(uMountain, uv);
|
||||
// Mountain rock only caps the higher ground: the lower slopes reveal the
|
||||
// grass base, so the texture follows the relief instead of filling the
|
||||
// whole mountain tile. The terrain-type weight still decides where the
|
||||
// range is at all.
|
||||
float rock = vOverlay.z * smoothstep(uRockLow, uRockHigh, vHeight);
|
||||
base = mix(base, forest.rgb, forest.a * vOverlay.x);
|
||||
base = mix(base, hills.rgb, hills.a * vOverlay.y);
|
||||
base = mix(base, mountain.rgb, mountain.a * vOverlay.z);
|
||||
base = mix(base, mountain.rgb, mountain.a * rock);
|
||||
// Coastal sand fades the sand texture into non-mountain shores.
|
||||
base = mix(base, sand, vOverlay.w);
|
||||
// Sea tiles are replaced by the animated water; the flag is 0 elsewhere.
|
||||
base = mix(base, waterColour(uv, uTime), vColour.a);
|
||||
// A light from the upper left rakes over the hills and mountains. The term
|
||||
// is one over flat ground, so it only touches the relief overlays.
|
||||
// A light from the upper left rakes over the terrain. The geometry normal
|
||||
// is built from the height gradient carried per vertex; the hill and
|
||||
// mountain normal maps perturb it with their surface detail. Dividing the
|
||||
// dot product by the light's up component makes flat ground light to
|
||||
// exactly 1, so only slopes and relief change.
|
||||
vec3 light = normalize(vec3(-0.5, -0.6, 0.8));
|
||||
float hillsLight = max(dot(reliefNormal(texture2D(uHillsNormal, uv)), light), 0.0) / light.z;
|
||||
float mountainLight = max(dot(reliefNormal(texture2D(uMountainNormal, uv)), light), 0.0) / light.z;
|
||||
float relief = 1.0 + (hillsLight - 1.0) * vOverlay.y
|
||||
+ (mountainLight - 1.0) * vOverlay.z;
|
||||
base *= clamp(relief, 0.55, 1.6);
|
||||
vec2 detail = (reliefNormal(texture2D(uHillsNormal, uv)).xy * vOverlay.y
|
||||
+ reliefNormal(texture2D(uMountainNormal, uv)).xy * rock) * uRelief;
|
||||
vec3 normal = normalize(vec3(-vSlope, 1.0) + vec3(detail * 0.5, 0.0));
|
||||
float shade = max(dot(normal, light), 0.0) / light.z;
|
||||
base *= clamp(shade, 0.55, 1.6);
|
||||
// Tiling land is opaque; the legacy atlas keeps its hexagonal alpha so its
|
||||
// transparent corners are not painted black.
|
||||
float alpha = mix(1.0, atlas.a, vColour.b);
|
||||
@@ -262,6 +304,12 @@ export class GLMapRenderer {
|
||||
this.texReady = false;
|
||||
// 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
|
||||
// out; level 0 is built with the world.
|
||||
this.terrainLevels = [];
|
||||
// The deterministic relief field for the current world.
|
||||
this.heightField = null;
|
||||
this._heightView = null;
|
||||
this.roads = { buffer: null, count: 0 };
|
||||
this.borders = { buffer: null, count: 0 };
|
||||
this.fog = { buffer: null, count: 0 };
|
||||
@@ -333,11 +381,16 @@ export class GLMapRenderer {
|
||||
dispose() {
|
||||
const gl = this.gl;
|
||||
if (!gl) return;
|
||||
for (const pass of [this.terrain, this.roads, this.borders, this.fog, this.regionOverlay]) {
|
||||
if (pass.buffer) gl.deleteBuffer(pass.buffer);
|
||||
for (const pass of [
|
||||
this.terrain, this.roads, this.borders, this.fog, this.regionOverlay,
|
||||
...this.terrainLevels,
|
||||
]) {
|
||||
if (!pass || !pass.buffer) continue;
|
||||
gl.deleteBuffer(pass.buffer);
|
||||
pass.buffer = null;
|
||||
pass.count = 0;
|
||||
}
|
||||
this.terrainLevels = [];
|
||||
for (const texture of Object.values(this.textures)) gl.deleteTexture(texture);
|
||||
this.textures = {};
|
||||
if (this.program) gl.deleteProgram(this.program);
|
||||
@@ -362,6 +415,8 @@ export class GLMapRenderer {
|
||||
gl.bindAttribLocation(program, ATTR_COLOUR, "aColour");
|
||||
gl.bindAttribLocation(program, ATTR_WORLD, "aWorld");
|
||||
gl.bindAttribLocation(program, ATTR_OVERLAY, "aOverlay");
|
||||
gl.bindAttribLocation(program, ATTR_HEIGHT, "aHeight");
|
||||
gl.bindAttribLocation(program, ATTR_SLOPE, "aSlope");
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(`link failed: ${gl.getProgramInfoLog(program)}`);
|
||||
@@ -374,6 +429,8 @@ export class GLMapRenderer {
|
||||
this.aColour = gl.getAttribLocation(program, "aColour");
|
||||
this.aWorld = gl.getAttribLocation(program, "aWorld");
|
||||
this.aOverlay = gl.getAttribLocation(program, "aOverlay");
|
||||
this.aHeight = gl.getAttribLocation(program, "aHeight");
|
||||
this.aSlope = gl.getAttribLocation(program, "aSlope");
|
||||
this.uCamera = gl.getUniformLocation(program, "uCamera");
|
||||
this.uViewport = gl.getUniformLocation(program, "uViewport");
|
||||
this.uZoom = gl.getUniformLocation(program, "uZoom");
|
||||
@@ -390,6 +447,10 @@ export class GLMapRenderer {
|
||||
this.uTextureRepeat = gl.getUniformLocation(program, "uTextureRepeat");
|
||||
this.uTime = gl.getUniformLocation(program, "uTime");
|
||||
this.uMode = gl.getUniformLocation(program, "uMode");
|
||||
this.uHeightScale = gl.getUniformLocation(program, "uHeightScale");
|
||||
this.uRelief = gl.getUniformLocation(program, "uRelief");
|
||||
this.uRockLow = gl.getUniformLocation(program, "uRockLow");
|
||||
this.uRockHigh = gl.getUniformLocation(program, "uRockHigh");
|
||||
}
|
||||
|
||||
_compile(type, source) {
|
||||
@@ -448,6 +509,8 @@ export class GLMapRenderer {
|
||||
gl.disableVertexAttribArray(this.aColour);
|
||||
gl.disableVertexAttribArray(this.aWorld);
|
||||
gl.disableVertexAttribArray(this.aOverlay);
|
||||
gl.disableVertexAttribArray(this.aHeight);
|
||||
gl.disableVertexAttribArray(this.aSlope);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenQuad);
|
||||
gl.enableVertexAttribArray(this.fsPos);
|
||||
gl.vertexAttribPointer(this.fsPos, 2, gl.FLOAT, false, 16, 0);
|
||||
@@ -612,37 +675,42 @@ export class GLMapRenderer {
|
||||
|
||||
// ------------------------------------------------------------ geometry --
|
||||
|
||||
_push(arr, x, y, u, v, colour) {
|
||||
arr.push(x, y, u, v, colour[0], colour[1], colour[2], colour[3]);
|
||||
// Flat-colour vertices (roads, borders, region) are x, y, height, padding,
|
||||
// r, g, b, a. The padding keeps the colour at the same byte offset as in the
|
||||
// terrain layout, which the single shader relies on.
|
||||
_push(arr, x, y, height, colour) {
|
||||
arr.push(x, y, height || 0, 0, colour[0], colour[1], colour[2], colour[3]);
|
||||
}
|
||||
|
||||
// Terrain vertices also carry the undistorted world position, which the
|
||||
// shader turns into a continuous tiling-texture coordinate, plus the base
|
||||
// shader turns into a continuous tiling-texture coordinate, the normalised
|
||||
// height that lifts the vertex and its gradient (for lighting), plus the base
|
||||
// (grass, sand, atlas), the per-overlay weights, the water flag and the
|
||||
// coastal beach weight.
|
||||
_pushTerrain(arr, x, y, u, v, wx, wy, weights) {
|
||||
_pushTerrain(arr, x, y, u, v, wx, wy, height, slopeX, slopeY, weights) {
|
||||
arr.push(
|
||||
x, y, u, v, wx, wy,
|
||||
x, y, u, v, wx, wy, height,
|
||||
weights[0], weights[1], weights[2], weights[6],
|
||||
weights[3], weights[4], weights[5], weights[7]
|
||||
weights[3], weights[4], weights[5], weights[7],
|
||||
slopeX, slopeY
|
||||
);
|
||||
}
|
||||
|
||||
// Fog has no texture, so it uses a compact position/colour-only vertex. That
|
||||
// Fog has no texture, so it uses a compact position/height/colour vertex. That
|
||||
// keeps the buffer (re-uploaded whenever visibility changes) small.
|
||||
_pushSimple(arr, x, y, colour) {
|
||||
arr.push(x, y, colour[0], colour[1], colour[2], colour[3]);
|
||||
_pushSimple(arr, x, y, height, colour) {
|
||||
arr.push(x, y, height || 0, colour[0], colour[1], colour[2], colour[3]);
|
||||
}
|
||||
|
||||
// Adds a convex quad given its four vertices (with per-vertex UVs) as two
|
||||
// Adds a convex quad given its four vertices (with per-vertex heights) as two
|
||||
// triangles. Wind order does not matter: we never cull faces.
|
||||
_quad(arr, corners, colour) {
|
||||
this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
|
||||
this._push(arr, corners[1].x, corners[1].y, corners[1].u || 0, corners[1].v || 0, colour);
|
||||
this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
|
||||
this._push(arr, corners[0].x, corners[0].y, corners[0].u || 0, corners[0].v || 0, colour);
|
||||
this._push(arr, corners[2].x, corners[2].y, corners[2].u || 0, corners[2].v || 0, colour);
|
||||
this._push(arr, corners[3].x, corners[3].y, corners[3].u || 0, corners[3].v || 0, colour);
|
||||
this._push(arr, corners[0].x, corners[0].y, corners[0].h, colour);
|
||||
this._push(arr, corners[1].x, corners[1].y, corners[1].h, colour);
|
||||
this._push(arr, corners[2].x, corners[2].y, corners[2].h, colour);
|
||||
this._push(arr, corners[0].x, corners[0].y, corners[0].h, colour);
|
||||
this._push(arr, corners[2].x, corners[2].y, corners[2].h, colour);
|
||||
this._push(arr, corners[3].x, corners[3].y, corners[3].h, colour);
|
||||
}
|
||||
|
||||
// A rotated, optionally inward-nudged rectangle from a to b.
|
||||
@@ -660,10 +728,10 @@ export class GLMapRenderer {
|
||||
const bx = b.x + ox;
|
||||
const by = b.y + oy;
|
||||
this._quad(arr, [
|
||||
{ x: ax + nx * half, y: ay + ny * half },
|
||||
{ x: bx + nx * half, y: by + ny * half },
|
||||
{ x: bx - nx * half, y: by - ny * half },
|
||||
{ x: ax - nx * half, y: ay - ny * half },
|
||||
{ x: ax + nx * half, y: ay + ny * half, h: a.h },
|
||||
{ x: bx + nx * half, y: by + ny * half, h: b.h },
|
||||
{ x: bx - nx * half, y: by - ny * half, h: b.h },
|
||||
{ x: ax - nx * half, y: ay - ny * half, h: a.h },
|
||||
], colour);
|
||||
}
|
||||
|
||||
@@ -689,20 +757,46 @@ export class GLMapRenderer {
|
||||
// the renderer needs.
|
||||
setWorld(view) {
|
||||
this.view = view;
|
||||
this.buildTerrain(view);
|
||||
this.heightField = createHeightField(view);
|
||||
this._heightView = view;
|
||||
this.terrainLevels = [];
|
||||
this.terrain = this._buildTerrainPass(view, 0);
|
||||
this.terrainLevels[0] = this.terrain;
|
||||
this.buildRoads(view);
|
||||
this.buildBorders(view);
|
||||
this.buildKnowledge(view);
|
||||
this.buildRegionOverlay(view);
|
||||
}
|
||||
|
||||
// The relief field for a view, rebuilt when a builder invoked directly (as the
|
||||
// tests do) passes a world `setWorld` never saw.
|
||||
_heightField(view) {
|
||||
if (!this.heightField || this._heightView !== view) {
|
||||
this.heightField = createHeightField(view);
|
||||
this._heightView = view;
|
||||
}
|
||||
return this.heightField;
|
||||
}
|
||||
|
||||
// Kept for callers (and tests) that build the closest level directly.
|
||||
buildTerrain(view) {
|
||||
const pass = this._buildTerrainPass(view, 0);
|
||||
if (!this.terrainLevels) this.terrainLevels = [];
|
||||
this.terrain = pass;
|
||||
this.terrainLevels[0] = pass;
|
||||
}
|
||||
|
||||
// Builds the terrain mesh for one level of detail and uploads it. Level 0 is
|
||||
// the wavy, fully-subdivided mesh; the coarser levels trade edge detail for
|
||||
// far-out zooms, where the warp is sub-pixel anyway.
|
||||
_buildTerrainPass(view, level) {
|
||||
const segments = (TERRAIN_LODS[level] || TERRAIN_LODS[0]).segments;
|
||||
const field = this._heightField(view);
|
||||
const arr = [];
|
||||
const tiles = view.tiles || {};
|
||||
const topo = view.topology;
|
||||
const seed = view.seed | 0;
|
||||
const period = periodUnits(topo);
|
||||
const segments = EDGE_SEGMENTS;
|
||||
const boundaryLength = CORNERS.length * segments;
|
||||
// Half-texel inset so linear filtering cannot sample the neighbouring atlas
|
||||
// cell along the hexagon's border.
|
||||
@@ -726,34 +820,60 @@ export class GLMapRenderer {
|
||||
// Land textures are blended from the tile and its neighbours; the ring
|
||||
// is only needed when this tile is one of the textured lands.
|
||||
const ring = isTexturedLand(tile) ? this._textureRing(view, x, y, centre) : null;
|
||||
// The relief is sampled from the same ring of tile heights, so the two
|
||||
// tiles that share a boundary vertex compute the same lift and slope.
|
||||
const heightRing = field ? field.ring(x, y) : null;
|
||||
const sampleAt = heightRing
|
||||
? (px, py) => field.sample(heightRing, px, py)
|
||||
: () => ZERO_SAMPLE;
|
||||
// A fan over the wavy boundary: the six points (and the extra border
|
||||
// points) can all be moved independently, which a quad could not do.
|
||||
const boundary = distortedBoundary(seed, centre, period, segments);
|
||||
// The weights are a pure function of the undistorted world position, so
|
||||
// the two tiles that share a boundary point compute the same value.
|
||||
const colours = new Array(boundaryLength);
|
||||
const samples = new Array(boundaryLength);
|
||||
for (let i = 0; i < boundaryLength; i++) {
|
||||
colours[i] = this._textureWeights(tile, ring, boundary[i].idealX, boundary[i].idealY);
|
||||
samples[i] = sampleAt(boundary[i].x, boundary[i].y);
|
||||
}
|
||||
const centreColour = this._textureWeights(tile, ring, centre.x, centre.y);
|
||||
const centreSample = sampleAt(centre.x, centre.y);
|
||||
for (let i = 0; i < CORNERS.length; i++) {
|
||||
for (let j = 0; j < segments; j++) {
|
||||
const a = boundary[i * segments + j];
|
||||
const b = boundary[(i * segments + j + 1) % boundaryLength];
|
||||
const indexA = i * segments + j;
|
||||
const indexB = (indexA + 1) % boundaryLength;
|
||||
const a = boundary[indexA];
|
||||
const b = boundary[indexB];
|
||||
const ua = this._cellUV(column, a.idealX - centre.x, a.idealY - centre.y, uPad, vPad);
|
||||
const ub = this._cellUV(column, b.idealX - centre.x, b.idealY - centre.y, uPad, vPad);
|
||||
this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y, centreColour);
|
||||
this._pushTerrain(arr, a.x, a.y, ua.u, ua.v, a.idealX, a.idealY, colours[i * segments + j]);
|
||||
this._pushTerrain(arr, b.x, b.y, ub.u, ub.v, b.idealX, b.idealY, colours[(i * segments + j + 1) % boundaryLength]);
|
||||
this._pushTerrain(arr, centre.x, centre.y, cu, cv, centre.x, centre.y,
|
||||
centreSample.h, centreSample.dx, centreSample.dy, centreColour);
|
||||
this._pushTerrain(arr, a.x, a.y, ua.u, ua.v, a.idealX, a.idealY,
|
||||
samples[indexA].h, samples[indexA].dx, samples[indexA].dy, colours[indexA]);
|
||||
this._pushTerrain(arr, b.x, b.y, ub.u, ub.v, b.idealX, b.idealY,
|
||||
samples[indexB].h, samples[indexB].dx, samples[indexB].dy, colours[indexB]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rowStart.push(arr.length / TERRAIN_FLOATS);
|
||||
this._upload(this.terrain, arr, undefined, TERRAIN_FLOATS);
|
||||
this.terrain.rowStart = rowStart;
|
||||
this.terrain.originY = originY;
|
||||
this.terrain.rows = topo.height;
|
||||
const pass = { buffer: null, count: 0 };
|
||||
this._upload(pass, arr, undefined, TERRAIN_FLOATS);
|
||||
pass.rowStart = rowStart;
|
||||
pass.originY = originY;
|
||||
pass.rows = topo.height;
|
||||
return pass;
|
||||
}
|
||||
|
||||
// Builds a coarser terrain mesh on first use, so zooming out does not pay for
|
||||
// it until the camera actually reaches that level.
|
||||
_ensureTerrainLevel(view, level) {
|
||||
if (!this.terrainLevels) this.terrainLevels = [];
|
||||
if (this.terrainLevels[level]) return this.terrainLevels[level];
|
||||
const pass = this._buildTerrainPass(view, level);
|
||||
this.terrainLevels[level] = pass;
|
||||
return pass;
|
||||
}
|
||||
|
||||
// The tile itself plus its six neighbours, each with its wrapped pixel
|
||||
@@ -827,11 +947,16 @@ export class GLMapRenderer {
|
||||
const seed = view.seed | 0;
|
||||
const topo = view.topology;
|
||||
const period = periodUnits(topo);
|
||||
const field = this._heightField(view);
|
||||
const lift = (point) => {
|
||||
if (field) point.h = field.at(point.x, point.y);
|
||||
return point;
|
||||
};
|
||||
const steps = 3;
|
||||
const drawn = new Set();
|
||||
for (const k of roads) {
|
||||
const coords = parseKey(k);
|
||||
const centre = mapToLocal(coords.x, coords.y);
|
||||
const centre = lift(mapToLocal(coords.x, coords.y));
|
||||
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
if (!roads.has(nk)) continue;
|
||||
@@ -839,18 +964,18 @@ export class GLMapRenderer {
|
||||
if (drawn.has(pair)) continue;
|
||||
drawn.add(pair);
|
||||
const delta = topo.pixelDelta(coords, neighbour);
|
||||
const target = { x: centre.x + delta.x, y: centre.y + delta.y };
|
||||
const target = lift({ x: centre.x + delta.x, y: centre.y + delta.y });
|
||||
// Cross the border exactly where it is now: the midpoint of the shared
|
||||
// wavy edge, not the ideal straight boundary.
|
||||
const [idealA, idealB] = nearestCorners(centre, target);
|
||||
const mid = edgePoint(seed, idealA, idealB, 0.5, period);
|
||||
const mid = lift(edgePoint(seed, idealA, idealB, 0.5, period));
|
||||
// Half the road drifts in from each tile centre towards that midpoint.
|
||||
const points = [centre];
|
||||
for (let j = 1; j <= steps; j++) {
|
||||
points.push(bentPath(seed, centre, mid, 0x5a17, j / steps));
|
||||
points.push(lift(bentPath(seed, centre, mid, 0x5a17, j / steps)));
|
||||
}
|
||||
for (let j = steps - 1; j >= 1; j--) {
|
||||
points.push(bentPath(seed, target, mid, 0x5a17, j / steps));
|
||||
points.push(lift(bentPath(seed, target, mid, 0x5a17, j / steps)));
|
||||
}
|
||||
points.push(target);
|
||||
this._roadBand(arr, points, 5, ROAD);
|
||||
@@ -858,7 +983,7 @@ export class GLMapRenderer {
|
||||
}
|
||||
for (const k of roads) {
|
||||
const coords = parseKey(k);
|
||||
this._roadJoint(arr, mapToLocal(coords.x, coords.y));
|
||||
this._roadJoint(arr, lift(mapToLocal(coords.x, coords.y)));
|
||||
}
|
||||
this._upload(this.roads, arr);
|
||||
}
|
||||
@@ -893,10 +1018,10 @@ export class GLMapRenderer {
|
||||
const oa = offsets[i];
|
||||
const ob = offsets[i + 1];
|
||||
this._quad(arr, [
|
||||
{ x: a.x + oa.x, y: a.y + oa.y },
|
||||
{ x: b.x + ob.x, y: b.y + ob.y },
|
||||
{ x: b.x - ob.x, y: b.y - ob.y },
|
||||
{ x: a.x - oa.x, y: a.y - oa.y },
|
||||
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
|
||||
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
|
||||
{ x: b.x - ob.x, y: b.y - ob.y, h: b.h },
|
||||
{ x: a.x - oa.x, y: a.y - oa.y, h: a.h },
|
||||
], colour);
|
||||
}
|
||||
}
|
||||
@@ -904,11 +1029,12 @@ export class GLMapRenderer {
|
||||
// A small square that hides the notch where several roads meet at a centre.
|
||||
_roadJoint(arr, point) {
|
||||
const r = 2.5;
|
||||
const h = point.h;
|
||||
this._quad(arr, [
|
||||
{ x: point.x - r, y: point.y - r },
|
||||
{ x: point.x - r, y: point.y + r },
|
||||
{ x: point.x + r, y: point.y + r },
|
||||
{ x: point.x + r, y: point.y - r },
|
||||
{ x: point.x - r, y: point.y - r, h },
|
||||
{ x: point.x - r, y: point.y + r, h },
|
||||
{ x: point.x + r, y: point.y + r, h },
|
||||
{ x: point.x + r, y: point.y - r, h },
|
||||
], ROAD);
|
||||
}
|
||||
|
||||
@@ -928,6 +1054,7 @@ export class GLMapRenderer {
|
||||
}
|
||||
const seed = view.seed | 0;
|
||||
const period = periodUnits(view.topology);
|
||||
const field = this._heightField(view);
|
||||
const glow = 6;
|
||||
const edgesByOwner = new Map();
|
||||
for (const [k, owner] of territory) {
|
||||
@@ -944,6 +1071,9 @@ export class GLMapRenderer {
|
||||
const next = CORNERS[(e + 1) % CORNERS.length];
|
||||
const b = { x: centre.x + next.x, y: centre.y + next.y };
|
||||
const points = this._edgePolyline(seed, centre, e, period);
|
||||
if (field) {
|
||||
for (const point of points) point.h = field.at(point.x, point.y);
|
||||
}
|
||||
// Orient the edge so the owner sits on its left; the stroke then always
|
||||
// offsets towards the owner and neighbouring edges agree on a miter.
|
||||
const dx = b.x - a.x;
|
||||
@@ -1100,8 +1230,8 @@ export class GLMapRenderer {
|
||||
this._quad(arr, [
|
||||
a,
|
||||
b,
|
||||
{ x: b.x + ob.x, y: b.y + ob.y },
|
||||
{ x: a.x + oa.x, y: a.y + oa.y },
|
||||
{ x: b.x + ob.x, y: b.y + ob.y, h: b.h },
|
||||
{ x: a.x + oa.x, y: a.y + oa.y, h: a.h },
|
||||
], colour);
|
||||
}
|
||||
}
|
||||
@@ -1118,6 +1248,7 @@ export class GLMapRenderer {
|
||||
const period = periodUnits(topo);
|
||||
const explored = view.explored || new Set();
|
||||
const visible = view.visible || new Set();
|
||||
const field = this._heightField(view);
|
||||
const segments = EDGE_SEGMENTS;
|
||||
const rowStart = [];
|
||||
const originX = topo.originX;
|
||||
@@ -1134,7 +1265,7 @@ export class GLMapRenderer {
|
||||
return level;
|
||||
};
|
||||
for (let y = originY; y < originY + topo.height; y++) {
|
||||
rowStart.push(arr.length / 6);
|
||||
rowStart.push(arr.length / FOG_FLOATS);
|
||||
for (let x = originX; x < originX + topo.width; x++) {
|
||||
if (!tiles[key(x, y)]) continue;
|
||||
const centre = mapToLocal(x, y);
|
||||
@@ -1156,8 +1287,14 @@ export class GLMapRenderer {
|
||||
vertices[i] = fogAt(boundary[i].x, boundary[i].y);
|
||||
}
|
||||
const centreFog = fogAt(centre.x, centre.y);
|
||||
// Fog sheets over the same raised hexagon the terrain draws, so it has
|
||||
// to read the relief from the same ring.
|
||||
const heightRing = field ? field.ring(x, y) : null;
|
||||
const heightAt = (px, py) =>
|
||||
(field && heightRing ? field.blend(heightRing, px, py) : 0);
|
||||
const push = (point, fog) => {
|
||||
this._pushSimple(arr, point.x, point.y, [fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]);
|
||||
this._pushSimple(arr, point.x, point.y, heightAt(point.x, point.y),
|
||||
[fog.rgb[0], fog.rgb[1], fog.rgb[2], fog.opacity]);
|
||||
};
|
||||
for (let i = 0; i < boundary.length; i++) {
|
||||
const a = boundary[i];
|
||||
@@ -1168,8 +1305,8 @@ export class GLMapRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
rowStart.push(arr.length / 6);
|
||||
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, 6);
|
||||
rowStart.push(arr.length / FOG_FLOATS);
|
||||
this._upload(this.fog, arr, this.gl.DYNAMIC_DRAW, FOG_FLOATS);
|
||||
this.fog.rowStart = rowStart;
|
||||
this.fog.originY = originY;
|
||||
this.fog.rows = topo.height;
|
||||
@@ -1194,17 +1331,22 @@ export class GLMapRenderer {
|
||||
const segments = EDGE_SEGMENTS;
|
||||
const boundaryLength = CORNERS.length * segments;
|
||||
const territory = view.territory;
|
||||
const field = this._heightField(view);
|
||||
for (const [k, cityId] of regions) {
|
||||
if (cityId !== region) continue;
|
||||
const coords = parseKey(k);
|
||||
const centre = mapToLocal(coords.x, coords.y);
|
||||
const boundary = distortedBoundary(seed, centre, period, segments);
|
||||
const heightRing = field ? field.ring(coords.x, coords.y) : null;
|
||||
const heightAt = (px, py) =>
|
||||
(field && heightRing ? field.blend(heightRing, px, py) : 0);
|
||||
const centreHeight = heightAt(centre.x, centre.y);
|
||||
for (let i = 0; i < boundaryLength; i++) {
|
||||
const a = boundary[i];
|
||||
const b = boundary[(i + 1) % boundaryLength];
|
||||
this._push(arr, centre.x, centre.y, 0, 0, REGION_FILL);
|
||||
this._push(arr, a.x, a.y, 0, 0, REGION_FILL);
|
||||
this._push(arr, b.x, b.y, 0, 0, REGION_FILL);
|
||||
this._push(arr, centre.x, centre.y, centreHeight, REGION_FILL);
|
||||
this._push(arr, a.x, a.y, heightAt(a.x, a.y), REGION_FILL);
|
||||
this._push(arr, b.x, b.y, heightAt(b.x, b.y), REGION_FILL);
|
||||
}
|
||||
const owner = territory ? territory.get(k) : undefined;
|
||||
for (const neighbour of topo.neighbours(coords.x, coords.y)) {
|
||||
@@ -1219,6 +1361,8 @@ export class GLMapRenderer {
|
||||
for (let d = 0; d < REGION_DASHES; d++) {
|
||||
const p0 = edgePoint(seed, idealA, idealB, (d + 0.28) / REGION_DASHES, period);
|
||||
const p1 = edgePoint(seed, idealA, idealB, (d + 0.72) / REGION_DASHES, period);
|
||||
p0.h = heightAt(p0.x, p0.y);
|
||||
p1.h = heightAt(p1.x, p1.y);
|
||||
this._segment(arr, p0, p1, REGION_BORDER, REGION_BORDER_COLOUR);
|
||||
}
|
||||
}
|
||||
@@ -1305,23 +1449,34 @@ export class GLMapRenderer {
|
||||
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
||||
gl.enableVertexAttribArray(this.aWorld);
|
||||
gl.vertexAttribPointer(this.aWorld, 2, gl.FLOAT, false, stride, 16);
|
||||
gl.enableVertexAttribArray(this.aHeight);
|
||||
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 24);
|
||||
gl.enableVertexAttribArray(this.aColour);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 24);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 28);
|
||||
gl.enableVertexAttribArray(this.aOverlay);
|
||||
gl.vertexAttribPointer(this.aOverlay, 4, gl.FLOAT, false, stride, 40);
|
||||
gl.vertexAttribPointer(this.aOverlay, 4, gl.FLOAT, false, stride, 44);
|
||||
gl.enableVertexAttribArray(this.aSlope);
|
||||
gl.vertexAttribPointer(this.aSlope, 2, gl.FLOAT, false, stride, 60);
|
||||
} else if (floats === FOG_FLOATS) {
|
||||
// Fog passes only carry position and colour; the disabled attributes keep
|
||||
// their generic defaults, unused in the untextured shader branch.
|
||||
// Fog passes only carry position, height and colour; the disabled
|
||||
// attributes keep their generic defaults, unused in the untextured
|
||||
// shader branch.
|
||||
gl.disableVertexAttribArray(this.aUV);
|
||||
gl.disableVertexAttribArray(this.aWorld);
|
||||
gl.disableVertexAttribArray(this.aOverlay);
|
||||
gl.disableVertexAttribArray(this.aSlope);
|
||||
gl.enableVertexAttribArray(this.aHeight);
|
||||
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 8);
|
||||
gl.enableVertexAttribArray(this.aColour);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 8);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 12);
|
||||
} else {
|
||||
gl.enableVertexAttribArray(this.aUV);
|
||||
gl.vertexAttribPointer(this.aUV, 2, gl.FLOAT, false, stride, 8);
|
||||
gl.disableVertexAttribArray(this.aWorld);
|
||||
gl.disableVertexAttribArray(this.aOverlay);
|
||||
gl.disableVertexAttribArray(this.aSlope);
|
||||
gl.enableVertexAttribArray(this.aHeight);
|
||||
gl.vertexAttribPointer(this.aHeight, 1, gl.FLOAT, false, stride, 8);
|
||||
gl.enableVertexAttribArray(this.aColour);
|
||||
gl.vertexAttribPointer(this.aColour, 4, gl.FLOAT, false, stride, 16);
|
||||
}
|
||||
@@ -1348,6 +1503,13 @@ export class GLMapRenderer {
|
||||
if (this.canvas.height !== bufferHeight) this.canvas.height = bufferHeight;
|
||||
|
||||
const camera = view.camera;
|
||||
// Pick the terrain mesh for the camera distance. A coarser level is built
|
||||
// the first time the camera reaches it, which increments `version`, so this
|
||||
// runs before the frame signature is taken.
|
||||
const level = terrainLod(camera.zoom);
|
||||
const terrainPass = this.texReady && view.topology
|
||||
? this._ensureTerrainLevel(view, level)
|
||||
: this.terrain;
|
||||
// The water animates, so the frame is repainted while the clock advances.
|
||||
// The timestamp is quantised to keep the redraw rate near 24 fps instead of
|
||||
// matching the display refresh exactly.
|
||||
@@ -1365,6 +1527,10 @@ export class GLMapRenderer {
|
||||
gl.uniform2f(this.uViewport, width, height);
|
||||
gl.uniform1f(this.uZoom, camera.zoom);
|
||||
gl.uniform1f(this.uTilt, CAMERA_TILT);
|
||||
gl.uniform1f(this.uHeightScale, HEIGHT_LIFT);
|
||||
gl.uniform1f(this.uRelief, (TERRAIN_LODS[level] || TERRAIN_LODS[0]).relief);
|
||||
gl.uniform1f(this.uRockLow, MOUNTAIN_ROCK_LOW);
|
||||
gl.uniform1f(this.uRockHigh, MOUNTAIN_ROCK_HIGH);
|
||||
gl.uniform1f(this.uTextureRepeat, textureRepeat(view._period || 0));
|
||||
gl.uniform1f(this.uTime, now);
|
||||
gl.enable(gl.BLEND);
|
||||
@@ -1374,7 +1540,7 @@ export class GLMapRenderer {
|
||||
const period = view._period || 0;
|
||||
const shifts = this._neededShifts(view, width);
|
||||
const rows = this._visibleRows(view, width, height);
|
||||
if (this.texReady) this._drawPass(this.terrain, 0, shifts, period, rows);
|
||||
if (this.texReady && terrainPass) this._drawPass(terrainPass, 0, shifts, period, rows);
|
||||
this._drawPass(this.roads, 1, shifts, period);
|
||||
this._drawPass(this.borders, 1, shifts, period);
|
||||
this._drawFog(bufferWidth, bufferHeight, shifts, period, rows);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { MapTopology, key, mapToLocal, HEX_W, HEX_H } from "../shared/hex.js";
|
||||
import { CORNERS, EDGE_SEGMENTS } from "../client/js/map_view/distortion.js";
|
||||
import { HEIGHT_LIFT, MOUNTAIN_ROCK_LOW, MOUNTAIN_ROCK_HIGH, TERRAIN_LODS, CAMERA_TILT, terrainLod } from "../client/js/map_view/constants.js";
|
||||
import { HeightField } from "../client/js/map_view/heightmap.js";
|
||||
|
||||
function fixture(terrainAt, size = 5, cylindrical = false) {
|
||||
const topology = new MapTopology({ x: size, y: size }, cylindrical);
|
||||
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: 90210, topology, tiles, _period: 0 };
|
||||
}
|
||||
|
||||
export class HeightmapTest extends TestCase {
|
||||
test_is_deterministic_for_a_seed() {
|
||||
const view = fixture(() => "Hills");
|
||||
const first = new HeightField(view);
|
||||
const second = new HeightField(view);
|
||||
for (let x = -2; x <= 2; x++) {
|
||||
for (let y = -2; y <= 2; y++) {
|
||||
this.assertEqual(first.tileHeight(x, y), second.tileHeight(x, y), "tile height");
|
||||
}
|
||||
}
|
||||
const point = mapToLocal(0, 0);
|
||||
this.assertApprox(first.at(point.x, point.y), second.at(point.x, point.y), 1e-12, "point height");
|
||||
const other = new HeightField({ ...view, seed: 1234 });
|
||||
this.assertNotEqual(first.tileHeight(1, 0), other.tileHeight(1, 0), "a new seed reshapes the relief");
|
||||
}
|
||||
|
||||
test_terrain_type_raises_the_relief() {
|
||||
const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land")));
|
||||
const mountain = field.tileHeight(0, 0);
|
||||
const land = field.tileHeight(1, 0);
|
||||
this.assertGreater(mountain, land, "mountains sit above the plains");
|
||||
this.assertGreater(mountain, 0.5, "a mountain is more than half height");
|
||||
this.assertLess(land, 0.2, "plains stay low");
|
||||
}
|
||||
|
||||
test_the_sea_stays_flat() {
|
||||
const field = new HeightField(fixture(() => "Sea"));
|
||||
for (let x = -2; x <= 2; x++) {
|
||||
for (let y = -2; y <= 2; y++) {
|
||||
this.assertEqual(field.tileHeight(x, y), 0, "the sea never rises");
|
||||
}
|
||||
}
|
||||
const point = mapToLocal(0, 0);
|
||||
this.assertEqual(field.at(point.x, point.y), 0, "points over the sea stay at zero");
|
||||
}
|
||||
|
||||
test_heights_are_normalised_below_one() {
|
||||
// With the tallest terrain everywhere the blend can only average down, so
|
||||
// every sample must stay within the unit the lift is calibrated for.
|
||||
const field = new HeightField(fixture(() => "Mountain"));
|
||||
for (let x = -3; x <= 3; x += 0.5) {
|
||||
for (let y = -3; y <= 3; y += 0.5) {
|
||||
const height = field.at(x, y);
|
||||
this.assertTrue(Number.isFinite(height), "finite height");
|
||||
this.assertTrue(height >= 0 && height <= 1, `height ${height} is inside [0, 1]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test_displacement_never_exceeds_one_tile() {
|
||||
// A height-1 feature is lifted HEIGHT_LIFT before the zoom; the ground gap
|
||||
// to the northern neighbour is HEX_H * sin(elevation). Keeping the lift at
|
||||
// or under that is the "must not exceed one" bound on the maximum height.
|
||||
this.assertTrue(
|
||||
HEIGHT_LIFT <= HEX_H * CAMERA_TILT,
|
||||
`the lift ${HEIGHT_LIFT} exceeds one tile spacing`
|
||||
);
|
||||
}
|
||||
|
||||
test_shared_corners_agree_so_the_relief_stays_watertight() {
|
||||
// Tile (0,0)'s right corner is tile (1,0)'s top-left corner. Both tiles must
|
||||
// lift it identically or the mesh would tear along their shared edge.
|
||||
const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land")));
|
||||
const centreA = mapToLocal(0, 0);
|
||||
const centreB = mapToLocal(1, 0);
|
||||
const corner = { x: centreA.x + CORNERS[0].x, y: centreA.y + CORNERS[0].y };
|
||||
const fromA = field.blend(field.ring(0, 0), corner.x, corner.y);
|
||||
const fromB = field.blend(field.ring(1, 0), corner.x, corner.y);
|
||||
this.assertApprox(fromA, fromB, 1e-9, "the shared corner is lifted identically");
|
||||
}
|
||||
|
||||
test_the_gradient_points_uphill_and_stays_watertight() {
|
||||
const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land")));
|
||||
const centreA = mapToLocal(0, 0);
|
||||
const corner = { x: centreA.x + CORNERS[0].x, y: centreA.y + CORNERS[0].y };
|
||||
const sample = field.sample(field.ring(0, 0), corner.x, corner.y);
|
||||
this.assertApprox(sample.h, field.blend(field.ring(0, 0), corner.x, corner.y), 1e-9,
|
||||
"sample reports the blended height");
|
||||
// The mountain sits east of the corner, so the ground climbs towards +x.
|
||||
this.assertGreater(sample.dx, 0, "the gradient points up the slope");
|
||||
// The two fans that meet on the shared edge must light it identically.
|
||||
const fromB = field.sample(field.ring(1, 0), corner.x, corner.y);
|
||||
this.assertApprox(sample.dx, fromB.dx, 1e-9, "shared slope x");
|
||||
this.assertApprox(sample.dy, fromB.dy, 1e-9, "shared slope y");
|
||||
}
|
||||
|
||||
test_flat_sea_has_no_gradient() {
|
||||
const field = new HeightField(fixture(() => "Sea"));
|
||||
const centre = mapToLocal(0, 0);
|
||||
const sample = field.sample(field.ring(0, 0), centre.x + 6, centre.y - 4);
|
||||
this.assertEqual(sample.dx, 0);
|
||||
this.assertEqual(sample.dy, 0);
|
||||
}
|
||||
|
||||
test_a_cylindrical_field_wraps_seamlessly() {
|
||||
const view = fixture(() => "Hills", 8, true);
|
||||
const field = new HeightField(view);
|
||||
const width = view.topology.width;
|
||||
for (let y = -3; y <= 3; y++) {
|
||||
this.assertEqual(
|
||||
field.tileHeight(view.topology.originX + width, y),
|
||||
field.tileHeight(view.topology.originX, y),
|
||||
"the seam joins itself"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test_the_mountain_snow_line_band_is_ordered() {
|
||||
// smoothstep needs low < high, and the band has to sit inside the [0, 1]
|
||||
// height range or the cap would never appear (or always would).
|
||||
this.assertGreater(MOUNTAIN_ROCK_HIGH, MOUNTAIN_ROCK_LOW, "the cap band is ordered");
|
||||
this.assertGreaterOrEqual(MOUNTAIN_ROCK_LOW, 0, "the band starts inside the range");
|
||||
this.assertLess(MOUNTAIN_ROCK_HIGH, 1, "the band ends inside the range");
|
||||
}
|
||||
|
||||
test_level_of_detail_coarsens_with_zoom() {
|
||||
this.assertEqual(terrainLod(1.5), 0, "close in uses the detailed mesh");
|
||||
this.assertEqual(terrainLod(0.4), 1, "mid zoom uses the middle mesh");
|
||||
this.assertEqual(terrainLod(0.1), TERRAIN_LODS.length - 1, "far out uses the coarsest");
|
||||
for (let i = 1; i < TERRAIN_LODS.length; i++) {
|
||||
this.assertLess(
|
||||
TERRAIN_LODS[i].segments,
|
||||
TERRAIN_LODS[i - 1].segments,
|
||||
"each level is coarser than the one before"
|
||||
);
|
||||
}
|
||||
this.assertEqual(TERRAIN_LODS[0].segments, EDGE_SEGMENTS, "the closest level keeps the wavy edge");
|
||||
this.assertEqual(HEX_W, 63, "sanity");
|
||||
}
|
||||
}
|
||||
@@ -147,11 +147,11 @@ export class TexturesTest extends TestCase {
|
||||
pass.floats = floats;
|
||||
};
|
||||
renderer.buildTerrain(view);
|
||||
this.assertEqual(captured.length % 14, 0, "fourteen floats per terrain vertex");
|
||||
this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex");
|
||||
// The undistorted world position rides along as UVs; tile (0,0)'s right
|
||||
// corner is the ideal lattice point (31.5, 0), independent of the warp.
|
||||
let found = false;
|
||||
for (let i = 0; i < captured.length; i += 14) {
|
||||
for (let i = 0; i < captured.length; i += 17) {
|
||||
this.assertTrue(Number.isFinite(captured[i + 4]), "world x is finite");
|
||||
this.assertTrue(Number.isFinite(captured[i + 5]), "world y is finite");
|
||||
if (Math.abs(captured[i + 4] - 31.5) < 1e-9 && Math.abs(captured[i + 5]) < 1e-9) {
|
||||
|
||||
@@ -20,12 +20,12 @@ function makeRenderer() {
|
||||
return renderer;
|
||||
}
|
||||
|
||||
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set() } = {}) {
|
||||
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), terrainAt = null } = {}) {
|
||||
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)] = { col: 0 };
|
||||
tiles[key(x, y)] = { col: 0, terrainType: terrainAt ? terrainAt(x, y) : "Land" };
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -159,4 +159,50 @@ export class WebglGeometryTest extends TestCase {
|
||||
this.assertTrue(Number.isFinite(value), "every road vertex is finite");
|
||||
}
|
||||
}
|
||||
|
||||
async test_terrain_vertices_carry_the_relief() {
|
||||
const renderer = makeRenderer();
|
||||
let captured = null;
|
||||
renderer._upload = (pass, arr, usage, floats) => {
|
||||
captured = arr;
|
||||
pass.floats = floats;
|
||||
};
|
||||
const state = view(renderer, {
|
||||
terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"),
|
||||
});
|
||||
renderer._buildTerrainPass(state, 0);
|
||||
this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex");
|
||||
let max = 0;
|
||||
for (let i = 0; i < captured.length; i += 17) {
|
||||
const height = captured[i + 6];
|
||||
this.assertTrue(Number.isFinite(height), "height is finite");
|
||||
this.assertTrue(height >= 0 && height <= 1, "height is normalised");
|
||||
this.assertTrue(Number.isFinite(captured[i + 15]), "slope x is finite");
|
||||
this.assertTrue(Number.isFinite(captured[i + 16]), "slope y is finite");
|
||||
max = Math.max(max, height);
|
||||
}
|
||||
this.assertGreater(max, 0.4, "the mountain lifts its vertices high");
|
||||
}
|
||||
|
||||
async test_a_coarser_level_of_detail_uses_fewer_vertices() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer);
|
||||
let counts = [];
|
||||
renderer._upload = (pass, arr) => { counts.push(arr.length); };
|
||||
renderer._buildTerrainPass(state, 0);
|
||||
renderer._buildTerrainPass(state, 2);
|
||||
this.assertGreater(counts[0], counts[1], "the far level has less geometry");
|
||||
}
|
||||
|
||||
async test_flat_layers_carry_the_relief_too() {
|
||||
const renderer = makeRenderer();
|
||||
const state = view(renderer, { roads: new Set(["0,0"]) });
|
||||
renderer.buildRoads(state);
|
||||
const arr = renderer.captured.roads;
|
||||
let lifted = false;
|
||||
for (let i = 0; i < arr.length; i += 8) {
|
||||
if (arr[i + 2] > 0) lifted = true;
|
||||
}
|
||||
this.assertTrue(lifted, "road vertices ride the heightmap");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user