M0.5: detailed scenery, mountain/sea-cliff boundary, Godot 4.7

Scenery (all procedural, seeded, no imported assets):
- Terrain shader: grass/dry grass/dirt/sand/rock strata/snow + detail normals
- 5 tree types, faceted mossy rocks, cliff outcrops, sea stacks, bushes,
  logs and stumps, batched as MultiMesh per 48 m cell
- Wind-animated grass and flowers streamed in tiles around the player
- Lake (depth-shaded water with shore foam), ocean to the horizon,
  cloud sky shader, volumetric fog, glow, softer shadows
- Swimming: float at the surface, swim-jump to climb out

World bounds:
- Steep mountain rim with an invisible wall partway up; one side opens to
  a sheer sea cliff with the wall 1 m inside the edge; whole chunk is an
  island so the mesh edge is never visible
- Respawn safety net if the player leaves the walkable area or the map
- Fix: the cliff edge now tracks the wall in the headlands (it used to
  drop away inside the wall, leaving a pit with lake water hanging over it)

Tests: tests/test_boundary.gd (24-direction escape attempt) and
tests/test_terrain_edges.gd (no ground below waterline near the wall).

Upgrades the project to Godot 4.7 (project.godot rewritten by the 4.7
importer, .uid files added) and documents it in the setup guide.
This commit is contained in:
Paul Bailey
2026-11-03 23:49:39 +00:00
parent 1ebfce08e4
commit 12f7fd677d
32 changed files with 1922 additions and 233 deletions

View File

@@ -0,0 +1,23 @@
// Shared helpers for the world shaders.
// Declares `noise_tex` (a seamless NoiseTexture2D set from terrain.gd) so every
// shader samples the same noise, which keeps grass, terrain and rock colour
// variation lined up with each other.
uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable;
float nz(vec2 p) {
return texture(noise_tex, p).r;
}
// Triplanar noise: samples along all three axes so steep faces (cliffs, rock
// sides) don't show the stretched streaks a top-down projection would give.
float tri_nz(vec3 p, vec3 n, float s) {
vec3 w = pow(abs(n), vec3(4.0));
w /= (w.x + w.y + w.z);
return nz(p.zy * s) * w.x + nz(p.xz * s) * w.y + nz(p.xy * s) * w.z;
}
// Vertex colours are authored as sRGB; lighting needs linear.
vec3 to_linear(vec3 c) {
return pow(c, vec3(2.2));
}

View File

@@ -0,0 +1 @@
uid://dir1djw6ljm4w

34
shaders/foliage.gdshader Normal file
View File

@@ -0,0 +1,34 @@
shader_type spatial;
render_mode cull_back, diffuse_burley;
// Tree canopies and bushes. Colour comes from per-face vertex colours (set by
// proc_mesh.gd), tinted per instance so no two trees are quite the same.
// Wind: slow whole-crown sway growing with height, plus a small leaf flutter.
#include "res://shaders/common.gdshaderinc"
uniform float wind_strength = 0.04;
uniform float flutter = 0.03;
varying vec3 inst_pos;
void vertex() {
inst_pos = MODEL_MATRIX[3].xyz;
float h = max(VERTEX.y - 1.2, 0.0);
float phase = dot(inst_pos.xz, vec2(0.13, 0.17));
float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013));
vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6);
sway *= wind_strength * h * (0.4 + gust);
sway += vec3(sin(TIME * 3.7 + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * clamp(VERTEX.y, 0.0, 1.0);
VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz;
}
void fragment() {
float v = nz(inst_pos.xz * 0.045);
vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v);
vec3 col = to_linear(COLOR.rgb) * tint;
ALBEDO = col;
ROUGHNESS = 0.85;
SPECULAR = 0.3;
BACKLIGHT = col * 0.5; // light glowing through leaves when backlit
}

View File

@@ -0,0 +1 @@
uid://di7bbdh70ax4b

45
shaders/grass.gdshader Normal file
View File

@@ -0,0 +1,45 @@
shader_type spatial;
render_mode cull_disabled, diffuse_burley;
// Grass clumps (MultiMesh). UV.y runs 0 at the root to 1 at the tip and drives
// both wind bend and the root-to-tip colour gradient. Base colour uses the same
// noise lookups as terrain.gdshader so the blades blend into the ground beneath.
#include "res://shaders/common.gdshaderinc"
uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15);
uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22);
uniform vec3 tip_tint : source_color = vec3(0.78, 0.88, 0.50);
uniform float wind_strength = 0.2;
uniform vec2 wind_dir = vec2(1.0, 0.35);
varying vec3 inst_pos;
varying float hfac;
void vertex() {
inst_pos = MODEL_MATRIX[3].xyz;
hfac = UV.y;
vec3 wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
vec2 wd = normalize(wind_dir);
float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd);
float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4;
float k = wind_strength * hfac * hfac;
vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k);
VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz;
}
void fragment() {
float macro = nz(inst_pos.xz * 0.0035);
float mid = nz(inst_pos.xz * 0.021 + 0.37);
vec3 base = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35));
base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17);
vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root
col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3);
ALBEDO = col;
ROUGHNESS = 0.9;
SPECULAR = 0.2;
BACKLIGHT = vec3(0.35, 0.4, 0.15) * hfac;
// Light blades as if they faced up (same as the ground), so a field reads as
// one soft surface instead of thousands of flickering cards.
NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
}

View File

@@ -0,0 +1 @@
uid://46hvkgp2jvps

40
shaders/rock.gdshader Normal file
View File

@@ -0,0 +1,40 @@
shader_type spatial;
render_mode cull_back, diffuse_burley;
// Faceted low-poly rocks: triplanar grain, per-rock tint, moss on upward faces
// (on some rocks more than others), snow on top at altitude.
#include "res://shaders/common.gdshaderinc"
uniform vec3 moss_col : source_color = vec3(0.27, 0.38, 0.15);
uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98);
uniform float snow_height = 40.0;
varying vec3 w_pos;
varying vec3 w_nrm;
varying vec3 inst_pos;
void vertex() {
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
inst_pos = MODEL_MATRIX[3].xyz;
}
void fragment() {
float a = tri_nz(w_pos, w_nrm, 0.3);
float b = tri_nz(w_pos, w_nrm, 1.4);
vec3 base = to_linear(COLOR.rgb) * (0.7 + 0.6 * a) * (0.88 + 0.24 * b);
base *= mix(vec3(1.04, 1.0, 0.94), vec3(0.94, 0.97, 1.03), nz(inst_pos.xz * 0.07));
float mossy = smoothstep(0.3, 0.7, nz(inst_pos.xz * 0.021 + 0.5));
float moss = smoothstep(0.45, 0.8, w_nrm.y + (a - 0.5) * 0.7) * mossy;
vec3 col = mix(base, moss_col * (0.75 + 0.5 * b), moss);
float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0);
snow *= smoothstep(0.35, 0.7, w_nrm.y);
col = mix(col, snow_col, snow);
ALBEDO = col;
ROUGHNESS = mix(mix(0.8, 0.95, moss), 0.6, snow);
SPECULAR = 0.35;
}

View File

@@ -0,0 +1 @@
uid://dnqc6nl4l50ih

41
shaders/sky.gdshader Normal file
View File

@@ -0,0 +1,41 @@
shader_type sky;
// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome.
// Deliberately no TIME: a time-varying sky forces Godot to rebuild the ambient
// radiance map every frame.
uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72);
uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85);
uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32);
uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62);
uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable;
uniform float cloud_cover = 0.5;
uniform float energy = 1.0;
void sky() {
vec3 d = EYEDIR;
float h = d.y;
vec3 col = mix(horizon_col, zenith_col, pow(smoothstep(0.0, 0.6, h), 0.7));
col = mix(col, ground_col, smoothstep(0.0, -0.12, h));
float sd = 0.0;
if (LIGHT0_ENABLED) {
sd = max(dot(d, LIGHT0_DIRECTION), 0.0);
col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h));
col += sun_tint * pow(sd, 64.0) * 0.6;
col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0;
}
if (h > 0.0) {
vec2 uv = d.xz / (h + 0.12);
float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35;
float cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c);
cov *= smoothstep(0.0, 0.18, h);
float shade = smoothstep(0.35, 1.0, c);
vec3 cloud = mix(vec3(1.0), vec3(0.62, 0.66, 0.72), shade * 0.8);
cloud += sun_tint * pow(sd, 4.0) * 0.5;
col = mix(col, cloud, cov * 0.9);
}
COLOR = col * energy;
}

1
shaders/sky.gdshader.uid Normal file
View File

@@ -0,0 +1 @@
uid://c5obebod41tnk

74
shaders/terrain.gdshader Normal file
View File

@@ -0,0 +1,74 @@
shader_type spatial;
render_mode cull_back, diffuse_burley, specular_schlick_ggx;
// Procedural terrain material: no textures to author. Colour comes from world
// position + slope + height, layered at several noise scales so the ground
// doesn't look like one flat colour from any distance.
#include "res://shaders/common.gdshaderinc"
uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable;
uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15);
uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22);
uniform vec3 dirt_col : source_color = vec3(0.34, 0.26, 0.17);
uniform vec3 rock_light : source_color = vec3(0.50, 0.48, 0.45);
uniform vec3 rock_dark : source_color = vec3(0.26, 0.26, 0.27);
uniform vec3 sand_col : source_color = vec3(0.62, 0.56, 0.42);
uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98);
uniform float water_level = -2.6;
uniform float snow_height = 40.0;
varying vec3 w_pos;
varying vec3 w_nrm;
void vertex() {
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
// The terrain mesh has no authored tangents; build a frame for the detail
// normal map. (Terrain normals are never parallel to Z, so this is safe.)
TANGENT = normalize(cross(NORMAL, vec3(0.0, 0.0, 1.0)));
BINORMAL = normalize(cross(TANGENT, NORMAL));
}
void fragment() {
float slope = 1.0 - clamp(w_nrm.y, 0.0, 1.0);
float macro = nz(w_pos.xz * 0.0035);
float mid = nz(w_pos.xz * 0.021 + 0.37);
float fine = nz(w_pos.xz * 0.17);
float micro = nz(w_pos.xz * 0.9);
// Grass: lush/dry regions, then mottling at two finer scales.
vec3 col = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35));
col *= 0.82 + 0.36 * fine;
col *= 0.92 + 0.16 * micro;
// Bare dirt patches.
float dirt_m = smoothstep(0.64, 0.74, mid + (fine - 0.5) * 0.35);
col = mix(col, dirt_col * (0.8 + 0.4 * micro), dirt_m);
// Sandy shoreline, darker mud below the waterline.
float sand_m = 1.0 - smoothstep(water_level + 0.3, water_level + 1.2 + fine * 0.6, w_pos.y);
col = mix(col, sand_col * (0.85 + 0.3 * micro), sand_m);
col = mix(col, dirt_col * 0.6, 1.0 - smoothstep(water_level - 1.8, water_level - 0.2, w_pos.y));
// Rock on steep ground, with horizontal strata so cliffs read as layered stone.
float tn = tri_nz(w_pos, w_nrm, 0.08);
float tn2 = tri_nz(w_pos, w_nrm, 0.45);
float strata = sin(w_pos.y * 2.2 + tn * 9.0) * 0.5 + 0.5;
vec3 rock = mix(rock_dark, rock_light, clamp(tn * 0.9 + strata * 0.35 - 0.1, 0.0, 1.0));
rock *= 0.82 + 0.36 * tn2;
float rock_m = smoothstep(0.28, 0.42, slope + (fine - 0.5) * 0.18);
col = mix(col, rock, rock_m);
// Snow on the flatter parts of the peaks.
float snow_m = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (mid - 0.5) * 14.0);
snow_m *= 1.0 - smoothstep(0.5, 0.7, slope);
col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m);
ALBEDO = col;
ROUGHNESS = mix(mix(0.95, 0.82, rock_m), 0.6, snow_m);
SPECULAR = 0.3;
NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb;
NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7);
}

View File

@@ -0,0 +1 @@
uid://cvhdrgrkmjy8i

47
shaders/water.gdshader Normal file
View File

@@ -0,0 +1,47 @@
shader_type spatial;
render_mode blend_mix, cull_disabled, diffuse_burley, specular_schlick_ggx;
// Lake water: two scrolling normal maps for ripples, colour and opacity from
// the depth of water under each pixel (clear shallows, dark deep water), and
// foam where the surface meets the shore.
#include "res://shaders/common.gdshaderinc"
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;
uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable;
uniform vec3 shallow_col : source_color = vec3(0.22, 0.44, 0.42);
uniform vec3 deep_col : source_color = vec3(0.03, 0.12, 0.17);
uniform float clarity = 2.5;
varying vec3 w_pos;
void vertex() {
w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
vec2 uv = w_pos.xz;
vec3 n1 = texture(normal_tex, uv * 0.045 + TIME * vec2(0.012, 0.008)).rgb;
vec3 n2 = texture(normal_tex, uv * 0.11 + TIME * vec2(-0.010, 0.017)).rgb;
NORMAL_MAP = mix(n1, n2, 0.5);
NORMAL_MAP_DEPTH = 0.7;
// Thickness of water between the surface and whatever is behind it.
// (Godot 4.3+ uses reversed-Z: raw depth is the NDC z.)
float depth = texture(DEPTH_TEXTURE, SCREEN_UV).r;
vec4 behind = INV_PROJECTION_MATRIX * vec4(SCREEN_UV * 2.0 - 1.0, depth, 1.0);
behind.xyz /= behind.w;
float thick = max(VERTEX.z - behind.z, 0.0);
float t = 1.0 - exp(-thick / clarity);
vec3 col = mix(shallow_col, deep_col, t);
float foam_n = nz(uv * 0.6 + TIME * 0.02);
float foam = (1.0 - smoothstep(0.0, 0.25 + foam_n * 0.35, thick)) * 0.8;
col = mix(col, vec3(0.88, 0.93, 0.93), foam);
ALBEDO = col;
ALPHA = clamp(mix(0.3, 0.93, t) + foam, 0.0, 1.0);
ROUGHNESS = 0.04;
SPECULAR = 0.6;
METALLIC = 0.0;
}

View File

@@ -0,0 +1 @@
uid://cuumgc10rnbll