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

View file

@ -152,6 +152,7 @@ function shd_bind_varyings(src: pointer, word: pointer, collect: bool) -> pointe
var shd_noise: pointer = null
var shd_lighting: pointer = null
var shd_wind: pointer = null
function shd_file(name: pointer) -> pointer {
let s = read_file(`packages/ludic.render3d/shaders/{name}`)
if s == null { err(`shaders: missing packages/ludic.render3d/shaders/{name}\n`); return "" }
@ -204,6 +205,7 @@ function cmd_shaders() -> int {
if list == null { err("shaders: no packages/ludic.render3d/shaders/variants.list\n"); return 1 }
shd_noise = shd_file("noise.glsl")
shd_lighting = shd_file("lighting.glsl")
shd_wind = shd_file("wind.glsl")
var outdir = "packages/ludic.render3d/shaders/spv"
let tmp = tmp_path("shaders")
if check { outdir = tmp_path("shaders-check") }
@ -232,13 +234,13 @@ function cmd_shaders() -> int {
let depth_remap = "\n#undef main\nvoid main() {\n ludic_gl_main();\n gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;\n}\n"
# invariant: the foliage's depth prepass and its lit pass (depth EQUAL) are different variants,
# and without it the compiler may compute their positions a hair apart and fail the test
var vsrc = "#version 460\n" + defines + "invariant gl_Position;\n#define main ludic_gl_main\n" + shd_file(parts[0]) + depth_remap
var vsrc = "#version 460\n" + defines + shd_wind + "invariant gl_Position;\n#define main ludic_gl_main\n" + shd_file(parts[0]) + depth_remap
# A mesh shader stands in for the vertex stage (a variant whose first file is *.mesh): it writes an
# array of positions, so it cannot be wrapped for the depth remap or take invariant gl_Position -
# it remaps depth itself. Its SPIR-V keeps the .vert name, so the manifest and loader are unchanged.
let is_mesh = Text.ends_with(parts[0], ".mesh")
if is_mesh { vsrc = "#version 460\n#extension GL_EXT_mesh_shader : require\n" + defines + shd_file(parts[0]) }
let fsrc = "#version 460\n" + defines + shd_noise + shd_lighting + shd_file(parts[1])
if is_mesh { vsrc = "#version 460\n#extension GL_EXT_mesh_shader : require\n" + defines + shd_wind + shd_file(parts[0]) }
let fsrc = "#version 460\n" + defines + shd_wind + shd_noise + shd_lighting + shd_file(parts[1])
# one binding table for both stages, and one location table for the varyings between them
shd_names = new []pointer
shd_vary = new []pointer