Files
Battle-for-Tismo/client/js/map_view/textures.js
T

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,
];
}