feat(render3d): one wind, and everything in the valley is moved by it

The grass had a gust built from two sines; the trees had no gust term AT ALL, only a
per-instance wobble. So the meadow rippled and the canopy above it swayed to an unrelated
rhythm, and nothing ever crossed the valley.

wind.glsl is prepended to every stage (programs.ludic, and shaders.ludic for the SPIR-V
build) so grass, crowns and water read the same field at the same world position. It
declares no uniforms on purpose: u_time and u_wind already exist in several of those
files and redeclaring them is a compile error in whichever stage includes both.

The wavelength is what made it work. At 0.030 the front was 209 m crest to crest -
longer than the meadow you can see - so the whole frame sat in one phase and the gust
read as everything breathing together. At 0.085 it is 74 m and a front crosses a view in
a couple of seconds.

R3D_DEBUG_WIND=1 paints the field on the ground. It is the only honest way to show a gust
in a still image, and two shots 0.3 s apart move by 39/255.

400 frames: GL 6.9 s, VK 7.2 s. Backends agree to 0.68/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-19 18:24:43 +03:00
parent c5693db0e4
commit 9dff8f21bd
116 changed files with 95 additions and 7 deletions

25
changes/one-wind.md Normal file
View file

@ -0,0 +1,25 @@
bump: minor
type: feat
One wind, and everything in the valley is moved by it.
The grass had a gust built from two sines and the trees had NO gust term at all - just a
per-instance wobble - so the meadow rippled and the canopy above it swayed to an unrelated
rhythm and nothing ever crossed the valley. A real gust is a WAVE: you watch it come over
the grass, it reaches you, and it goes on into the trees behind you.
wind.glsl is prepended to EVERY stage, so the grass's vertex shader, the crown's vertex
shader and the water's fragment shader read the same field at the same world position and
cannot drift apart. No uniforms are declared in it on purpose - u_time and u_wind already
exist in several of those files and redeclaring them is a compile error in whichever stage
includes both - so the caller passes what it already has.
Two numbers mattered. The gust's WAVELENGTH has to fit inside a view or nothing is ever
seen to travel: the first cut was 209 m crest to crest, longer than the meadow you can see,
so the whole frame sat in one phase and read as everything breathing together. It is 74 m
now and a front crosses a normal view in a couple of seconds. And `stiff` lets one field
move a blade a long way and a bole hardly at all.
R3D_DEBUG_WIND=1 paints the field on the ground, which is the only way to SEE a gust in a
still: two shots a moment apart show the fronts and show them moving.
Measured: no cost. 400 frames, GL 6.9 s, VK 7.2 s.

View file

@ -17,6 +17,7 @@ var r3d_root_found: bool = false
var r3d_global_defs: string = ""
var r3d_noise_src: string = null
var r3d_lighting_src: string = null
var r3d_wind_src: string = null
function r3d_set_paths(root: string, assets: string) -> void { r3d_root = root; r3d_assets = assets }
@ -51,7 +52,11 @@ function r3d_shader_file(name: string) -> string {
function r3d_shader_src(name: string, defines: string, is_frag: bool) -> string {
if r3d_noise_src == null { r3d_noise_src = r3d_shader_file("noise.glsl") }
if r3d_lighting_src == null { r3d_lighting_src = r3d_shader_file("lighting.glsl") }
var s = "#version 410 core\n" + r3d_global_defs + defines
# the wind goes to BOTH stages: the grass and the crowns move in the vertex shader, the water
# ripples in the fragment one, and they have to be reading the same field or the meadow and
# the lake disagree about the weather
if r3d_wind_src == null { r3d_wind_src = r3d_shader_file("wind.glsl") }
var s = "#version 410 core\n" + r3d_global_defs + defines + r3d_wind_src
if is_frag { s = s + r3d_noise_src + r3d_lighting_src }
return s + r3d_shader_file(name)
}

View file

@ -148,9 +148,11 @@ void main() {
if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); }
vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4));
vec3 n = vec3(0.0, 0.3, 1.0);
float gust = sin(xz.x * 0.09 + u_time * 1.1) * 0.5 + sin(xz.y * 0.13 - u_time * 0.8 + xz.x * 0.05) * 0.5;
// The shared field (wind.glsl), so the gust that crosses this meadow is the same gust that
// reaches the trees behind it a moment later. The two sines that used to live here were a
// standing wobble: the grass moved, but nothing ever travelled.
float ph = u_time * 1.7 + seed * 6.2831 + xz.x * 0.05 + xz.y * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4 + gust) * u_wind;
float sway = windSway(xz, u_time, ph, 0.0) * u_wind;
float hgt = max(p.y, 0.0);
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);

View file

@ -76,7 +76,11 @@ void main() {
// sway grows with height above the base; gust phase from the instance seed
float hgt = max(p.y, 0.0);
float ph = u_time * 1.7 + i_rot.z * 6.2831 + i_pos.x * 0.05 + i_pos.z * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4) * u_wind * i_rot.w;
// A crown had NO gust at all - only this per-instance wobble - so the canopy swayed to a
// rhythm of its own while the meadow under it rippled to another. It is the same field now,
// read at the tree's own place in the world, so a gust crosses the grass and goes on into
// the trees. `stiff` is 0.35 because a bole resists what a blade cannot.
float sway = windSway(i_pos.xz, u_time, ph, 0.35) * u_wind * i_rot.w;
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
#endif

Some files were not shown because too many files have changed in this diff Show more