193 lines
7.7 KiB
JavaScript
193 lines
7.7 KiB
JavaScript
// Texture assignment and blending for the world map.
|
|
//
|
|
// The land is no longer drawn from the per-tile terrain.png atlas. Every land
|
|
// terrain sits on a seamless tiling base (grass, or sand for the desert) and
|
|
// forest, hills and mountains composite their own translucent overlay on top.
|
|
// Sea is flagged for the procedural water shader and ice keeps the legacy atlas
|
|
// art, so neither is textured or blended here.
|
|
//
|
|
// The tiling textures are addressed in *world* space, not per tile: every
|
|
// vertex carries its undistorted world position and the shader divides it by a
|
|
// fixed repeat. Two tiles that share an edge therefore sample the same part of
|
|
// the same texture, so the grain runs continuously across the wavy, distorted
|
|
// hex boundary instead of restarting in every tile. On a cylindrical map the
|
|
// repeat is snapped so the world width is a whole number of repeats, which keeps
|
|
// the pattern coherent across the wrap seam as well.
|
|
//
|
|
// Neighbouring land tiles are blended by a distance kernel over the tile
|
|
// centres. The weight only depends on the world position, so both tiles on a
|
|
// shared edge compute the same mix and the transition has no seam. A wider
|
|
// kernel fades the sand texture into non-mountain coasts as a beach.
|
|
|
|
import { HEX_H } from "../../../shared/hex.js";
|
|
|
|
export const TEXTURE_DIR = "assets/textures/";
|
|
export const TEXTURE_GRASS = "grass";
|
|
export const TEXTURE_SAND = "sand";
|
|
export const TEXTURE_FOREST = "forest";
|
|
export const TEXTURE_HILLS = "hills";
|
|
export const TEXTURE_MOUNTAIN = "mountain";
|
|
// Tangent-space normal maps that give the hills and mountains their relief.
|
|
export const TEXTURE_HILLS_NORMAL = "hills_normal_map";
|
|
export const TEXTURE_MOUNTAIN_NORMAL = "mountains_normal_map";
|
|
|
|
// The overlay layers, in the order the shader composites them.
|
|
export const OVERLAY_TEXTURES = [TEXTURE_FOREST, TEXTURE_HILLS, TEXTURE_MOUNTAIN];
|
|
|
|
// The normal map that lights an overlay, or null when it has none.
|
|
export function overlayNormal(overlay) {
|
|
if (overlay === TEXTURE_HILLS) return TEXTURE_HILLS_NORMAL;
|
|
if (overlay === TEXTURE_MOUNTAIN) return TEXTURE_MOUNTAIN_NORMAL;
|
|
return null;
|
|
}
|
|
|
|
// The tiling textures are 128x128, so one repeat every 128 world pixels keeps
|
|
// them at roughly native density at zoom 1.
|
|
export const TEXTURE_DESIRED_REPEAT = 128;
|
|
|
|
// Which terrains use a tiling base texture and which overlay, if any, sits on
|
|
// top of it. A terrain missing from this table is drawn from the terrain.png
|
|
// atlas.
|
|
const LAND_TEXTURES = {
|
|
Forest: { base: TEXTURE_GRASS, overlay: TEXTURE_FOREST },
|
|
Hills: { base: TEXTURE_GRASS, overlay: TEXTURE_HILLS },
|
|
Land: { base: TEXTURE_GRASS },
|
|
Desert: { base: TEXTURE_SAND },
|
|
Mountain: { base: TEXTURE_GRASS, overlay: TEXTURE_MOUNTAIN },
|
|
};
|
|
|
|
// Blend radius of the land kernel. A neighbour's centre is one edge-width away
|
|
// (about HEX_H), so a shared edge lands at an even 50/50 while a tile centre
|
|
// stays almost entirely its own texture.
|
|
const BLEND_RADIUS = HEX_H;
|
|
|
|
// Beaches reach a little further than the ordinary land blend, so the sand
|
|
// spreads over most of the coastal tile instead of clinging to the shoreline,
|
|
// and are capped below 1 so a hint of the ground shows through.
|
|
const BEACH_RADIUS = HEX_H * 1.35;
|
|
const BEACH_STRENGTH = 1.25;
|
|
|
|
// The base/overlay description for a terrain type, or null for the atlas
|
|
// terrains (Sea and Ice).
|
|
export function landTexture(terrainType) {
|
|
return LAND_TEXTURES[terrainType] || null;
|
|
}
|
|
|
|
export function isTexturedLand(tile) {
|
|
return !!(tile && LAND_TEXTURES[tile.terrainType]);
|
|
}
|
|
|
|
// Sea is drawn by the procedural water shader rather than a tiling texture, but
|
|
// it still rides in the same vertex weights as a flag, so the renderer knows
|
|
// where the water should replace the atlas art.
|
|
export function isWaterTile(tile) {
|
|
return !!tile && tile.terrainType === "Sea";
|
|
}
|
|
|
|
// World pixels per texture repeat. On a cylindrical world the repeat is snapped
|
|
// so that the wrap period is a whole number of repeats; otherwise two copies of
|
|
// the same tile would show different texture phases at the seam.
|
|
export function textureRepeat(periodPixels) {
|
|
if (!periodPixels || periodPixels <= 0) return TEXTURE_DESIRED_REPEAT;
|
|
const whole = Math.max(1, Math.round(periodPixels / TEXTURE_DESIRED_REPEAT));
|
|
return periodPixels / whole;
|
|
}
|
|
|
|
// The layer list for a terrain type, topmost first: the terrain's overlay (when
|
|
// it has one) then its base. Sea and ice return an empty list and keep the
|
|
// atlas art.
|
|
export function terrainLayers(terrainType) {
|
|
const texture = LAND_TEXTURES[terrainType];
|
|
if (!texture) return [];
|
|
const layers = [`${TEXTURE_DIR}${texture.base}.png`];
|
|
if (texture.overlay) layers.unshift(`${TEXTURE_DIR}${texture.overlay}.png`);
|
|
return layers;
|
|
}
|
|
|
|
// A smooth (squared) falloff that reaches zero at the blend radius.
|
|
function falloff(distance) {
|
|
if (distance >= BLEND_RADIUS) return 0;
|
|
const t = 1 - distance / BLEND_RADIUS;
|
|
return t * t;
|
|
}
|
|
|
|
// The wider falloff used for the coastal sand.
|
|
function beachFalloff(distance) {
|
|
if (distance >= BEACH_RADIUS) return 0;
|
|
const t = 1 - distance / BEACH_RADIUS;
|
|
return t * t;
|
|
}
|
|
|
|
// Blends the tiling textures of the land tiles near a world point.
|
|
//
|
|
// `samples` is a list of { tile, dx, dy }: `tile` is the tile data and (dx, dy)
|
|
// is the offset from the point to that tile's centre. The result carries the
|
|
// grass/sand base split (summing to 1) and the opacity of each overlay, or null
|
|
// when no textured-land tile is close enough. The value depends only on the
|
|
// point and its surroundings, never on which tile asked, so shared vertices
|
|
// agree.
|
|
export function blendTextures(samples) {
|
|
let grass = 0;
|
|
let sand = 0;
|
|
let total = 0;
|
|
const overlays = {};
|
|
for (const name of OVERLAY_TEXTURES) overlays[name] = 0;
|
|
for (const sample of samples) {
|
|
const texture = sample.tile && LAND_TEXTURES[sample.tile.terrainType];
|
|
if (!texture) continue;
|
|
const weight = falloff(Math.hypot(sample.dx, sample.dy));
|
|
if (weight <= 0) continue;
|
|
total += weight;
|
|
if (texture.base === TEXTURE_SAND) sand += weight;
|
|
else grass += weight;
|
|
if (texture.overlay) overlays[texture.overlay] += weight;
|
|
}
|
|
if (total <= 0) return null;
|
|
const blend = { grass: grass / total, sand: sand / total };
|
|
for (const name of OVERLAY_TEXTURES) blend[name] = overlays[name] / total;
|
|
return blend;
|
|
}
|
|
|
|
// How much of the sand texture the coast blends into a land tile at a point,
|
|
// from 0 inland to about BEACH_STRENGTH/2 on the shoreline. The value depends
|
|
// only on the point and its surroundings, so both the land side and its
|
|
// neighbouring sea compute the same shoreline.
|
|
export function blendBeach(samples) {
|
|
let sea = 0;
|
|
let total = 0;
|
|
for (const sample of samples) {
|
|
if (!sample.tile) continue;
|
|
const weight = beachFalloff(Math.hypot(sample.dx, sample.dy));
|
|
if (weight <= 0) continue;
|
|
total += weight;
|
|
if (isWaterTile(sample.tile)) sea += weight;
|
|
}
|
|
if (total <= 0) return 0;
|
|
return sea / total;
|
|
}
|
|
|
|
// Per-vertex texture weights, packed for the shader as
|
|
// [grass, sand, atlas, forest, hills, mountain, water, beach]. Atlas terrains
|
|
// are fully atlas; textured land is fully texture, with the base split, the
|
|
// overlay opacities and the coastal sand coming from the neighbouring-tile
|
|
// blend. Mountain coasts stay rocky, so they get no beach.
|
|
export function vertexTextureWeights(tile, samples) {
|
|
const water = isWaterTile(tile) ? 1 : 0;
|
|
if (!isTexturedLand(tile)) return [0, 0, 1, 0, 0, 0, water, 0];
|
|
const blend = blendTextures(samples);
|
|
if (!blend) return [0, 0, 1, 0, 0, 0, water, 0];
|
|
const beach = tile.terrainType === "Mountain"
|
|
? 0
|
|
: Math.min(1, blendBeach(samples) * BEACH_STRENGTH);
|
|
return [
|
|
blend.grass,
|
|
blend.sand,
|
|
0,
|
|
blend[TEXTURE_FOREST],
|
|
blend[TEXTURE_HILLS],
|
|
blend[TEXTURE_MOUNTAIN],
|
|
water,
|
|
beach,
|
|
];
|
|
}
|