From 91335310d274140fcb1784d5590238f3aea9597d Mon Sep 17 00:00:00 2001 From: Nils van Lueck Date: Fri, 4 Sep 2026 06:16:35 +0200 Subject: [PATCH] feat(world-render): render grass via GPU scatter and indirect draws - Cache ground data on the GPU and exclude fields, roads, and water - Add quality-based grass range and density settings --- Cargo.lock | 1 + STATUS.md | 37 + crates/app/src/settings.rs | 38 +- crates/i18n/locales/de/main.ftl | 2 +- crates/i18n/locales/en/main.ftl | 2 +- crates/world-render/Cargo.toml | 3 + crates/world-render/src/farmland.rs | 3 + crates/world-render/src/grass.wgsl | 213 ---- crates/world-render/src/grass/blades.wgsl | 199 ++++ crates/world-render/src/grass/ground.rs | 411 +++++++ crates/world-render/src/grass/ground.wgsl | 70 ++ crates/world-render/src/grass/mod.rs | 243 ++++ crates/world-render/src/grass/render.rs | 608 ++++++++++ crates/world-render/src/grass/scatter.wgsl | 336 ++++++ crates/world-render/src/lib.rs | 17 +- crates/world-render/src/plants.rs | 1244 +++----------------- crates/world-render/src/roads.rs | 3 + crates/world-render/src/water.rs | 3 + crates/world-render/src/weather.rs | 6 - 19 files changed, 2076 insertions(+), 1363 deletions(-) delete mode 100644 crates/world-render/src/grass.wgsl create mode 100644 crates/world-render/src/grass/blades.wgsl create mode 100644 crates/world-render/src/grass/ground.rs create mode 100644 crates/world-render/src/grass/ground.wgsl create mode 100644 crates/world-render/src/grass/mod.rs create mode 100644 crates/world-render/src/grass/render.rs create mode 100644 crates/world-render/src/grass/scatter.wgsl diff --git a/Cargo.lock b/Cargo.lock index db3e0a5c..ab243a3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11261,6 +11261,7 @@ dependencies = [ "glam 0.32.1", "sim-core", "track-model", + "wgpu", "world-coords", ] diff --git a/STATUS.md b/STATUS.md index b4dc092d..c51b45da 100644 --- a/STATUS.md +++ b/STATUS.md @@ -575,6 +575,43 @@ As of 2026-08-31 · `cargo test --workspace`: **1136 tests green** · clippy and spent in query order, so a field five hundred metres away filled in while the one under the window stayed bare. And a hero card drew a painted card inside the model standing on it. +- **Meadow grass on the GPU (2026-09-04, `world_render::grass`):** the default terrain's + grass was first grown like the crops — every 32 m cell within reach of the camera a mesh + of a million vertices, built on the main thread the frame the camera came near, uploaded + whole, and drawn through the full PBR path with all three LOD levels in one mesh. A train + at line speed entered a new row of cells every second, and each one was a visible hitch. + Nothing of the meadow is built on the CPU any more. **(1) A ground cache:** the terrain + tiles around the camera and the fields, roads and waters draped on them are drawn once, + top down, into a 1024² texture of (height, grass weight) by a pass of its own over the + meshes already on the GPU — highest surface wins, an excluded surface lifted a little so + it wins the coplanar fight against the ground under it — and drawn again only when the + camera has left a 64 m margin, a tile has streamed in or out, or the origin was rebased. + **(2) A scatter compute pass** each frame over a grid of 4 m patches: a patch out of the + frustum costs one box test; a patch in view lays its blades out on Roberts' R2 sequence + (any prefix of it is evenly spread, which is what lets a prefix be the thinned stand), + keeps a blade where its rank is below the density its own distance asks for — so coming + closer only ever adds blades and never moves one — reads its foot off the ground cache, + culls it against the frustum, and appends the survivors to one of three instance lists, + whose counts land straight in the indirect draw arguments. **(3) Three indirect draws** + in the opaque pass, one per level of detail (eleven, seven and three vertices), through + Bevy's own mesh pipeline for the view's key with the shaders and the mesh bind group + swapped — so MSAA, HDR, the shadow filter, the fog and the atmosphere are whatever the + camera has, with no second copy of any of it. The vertex shader grows each 32-byte + record into a quadratic Bézier bent by its own lean and the weather's wind (a gust front + travelling downwind, a ripple on it, each blade's own flutter, less of all three on a + stiff blade), tapers it, keeps it at least a pixel and a half wide so far grass does not + shimmer, and turns the normal across the blade so it lights as a rounded stalk; the + fragment shader lights it with diffuse transmission for the back-lit glow, a root-to-tip + occlusion gradient, and `weather_pbr` for the rain and the snow. Density is 450 blades/m² + at the camera falling as 1/(1+(d/10 m)²) out to the range — 220 m on High, 160 on + Medium, 110 on Low, the density scaled with it — the survivors widening by 1/√keep so + the sward keeps its cover, and a blade at the threshold growing in rather than popping. + Fields, roads and water carry `GroundSurface::Excluded`, so no blade stands on them; the + splat's gravel and rock take the grass weight away on the formation and on steep ground, + and the edge is dithered so a verge thins into the ballast instead of stopping at a + line. Deep winter takes the meadow away, and the grassland phenology sets its height, so + it is short after the cut. The crop cards and hero models of the fields are untouched. + Nothing is state: every blade is a function of the ground and a hash of its position. - **No two fields on one piece of ground (2026-08-31, `content::farmland`, `fields::geometry`):** a cadastral register's parcels are meant to tile the land and do not quite: neighbours are digitised from either side of a boundary and agree to a few diff --git a/crates/app/src/settings.rs b/crates/app/src/settings.rs index 3ac86d22..e2ed48db 100644 --- a/crates/app/src/settings.rs +++ b/crates/app/src/settings.rs @@ -96,8 +96,8 @@ pub struct Graphics { pub texture_quality: Quality, /// Rendered blade geometry over the painted ground material. pub grass: bool, - /// Reach and immediate-detail radius of rendered grass. High is the full - /// authored sward; the renderer's lossless batching applies to every level. + /// Reach and density of the rendered grass. High is the full authored + /// sward; the lower levels shorten the reach and thin the stand. pub grass_quality: Quality, /// Which anti-aliasing runs on the cab camera. pub anti_aliasing: AntiAliasing, @@ -709,24 +709,16 @@ pub fn ground_quality(graphics: &Graphics) -> world_render::GroundQuality { world_render::GroundQuality { size, anisotropy } } -/// Radial grass bands for the selected quality. High is the authored default; -/// lower levels trade only grass reach and the radius of the densest local layer. +/// Reach and density of the meadow for the selected quality. High is the +/// authored default; lower levels trade reach and blades per square metre, +/// which is what the scatter pass and the three draws cost. fn grass_quality(graphics: &Graphics) -> world_render::GrassRenderSettings { - let (bands, fades) = match graphics.grass_quality { - Quality::Low => ( - Vec4::new(24.0, 52.0, 110.0, 14.0), - Vec4::new(10.0, 10.0, 12.0, 5.0), - ), - Quality::Medium => ( - Vec4::new(28.0, 62.0, 145.0, 18.0), - Vec4::new(11.0, 11.0, 12.0, 6.0), - ), - Quality::High => ( - Vec4::new(30.0, 70.0, 170.0, 22.0), - Vec4::new(12.0, 12.0, 12.0, 8.0), - ), + let (range, density) = match graphics.grass_quality { + Quality::Low => (110.0, 0.4), + Quality::Medium => (160.0, 0.7), + Quality::High => (220.0, 1.0), }; - world_render::GrassRenderSettings::new(graphics.grass, bands, fades) + world_render::GrassRenderSettings::new(graphics.grass, range, density) } /// Generates the ground textures again into the handles the terrain material already @@ -810,7 +802,6 @@ fn apply_scene( quality: Option>, clouds: Option>, grass: Option>, - grass_materials: Option>>, cameras: Query<(Entity, Has), With>, mut projections: Query<&mut bevy::camera::Projection, With>, ) { @@ -837,15 +828,6 @@ fn apply_scene( { *grass = wanted_grass; } - // Existing material instances update immediately; newly grown cells read - // the same resource in `update_field_plants` when their material is made. - if let Some(mut materials) = grass_materials { - for (_, material) in materials.iter_mut() { - if material.extension.grass != wanted_grass.params { - material.extension.grass = wanted_grass.params; - } - } - } if let Some(mut view) = view { view.0 = graphics.view_distance; } diff --git a/crates/i18n/locales/de/main.ftl b/crates/i18n/locales/de/main.ftl index 00e374cf..547ac7a9 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -1582,7 +1582,7 @@ set-texture-quality-hint = Größe und Filterung der erzeugten Bodentexturen. Gi set-grass = Gerendertes Gras set-grass-hint = Zeichnet echte, windbewegte Grashalme über dem Bodenmaterial. Änderungen gelten sofort. set-grass-quality = Grasqualität -set-grass-quality-hint = Reichweite des gerenderten Grases und Größe des besonders dichten, detaillierten Bereichs direkt um die Kamera. +set-grass-quality-hint = Reichweite des gerenderten Grases und wie viele Halme auf einem Quadratmeter stehen. Das Gras wird jedes Bild auf der Grafikkarte verteilt; eine niedrigere Stufe kostet dort weniger. set-shadow-quality = Schattenqualität set-shadow-quality-hint = Kantenlänge der Schattenkarte der Sonne: 1024, 2048 oder 4096 Texel. Eine Stufe kostet die vierfache Zahl an Texeln — die Einstellung, die man zuerst senkt. set-mist-quality = Nebelqualität diff --git a/crates/i18n/locales/en/main.ftl b/crates/i18n/locales/en/main.ftl index 81048201..4b34b756 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -1579,7 +1579,7 @@ set-texture-quality-hint = Size and filtering of the generated ground textures. set-grass = Rendered grass set-grass-hint = Draws real, wind-animated grass blades over the ground material. Changes apply immediately. set-grass-quality = Grass quality -set-grass-quality-hint = Reach of rendered grass and size of the especially dense, detailed area immediately around the camera. +set-grass-quality-hint = Reach of the rendered grass and how many blades stand on a square metre. The grass is laid out on the graphics card every frame; a lower level costs less there. set-shadow-quality = Shadow quality set-shadow-quality-hint = Edge length of the sun's shadow map: 1024, 2048 or 4096 texels. Four times the texels a step, so it is the setting to lower first. set-mist-quality = Mist quality diff --git a/crates/world-render/Cargo.toml b/crates/world-render/Cargo.toml index 8a21360d..fbafcd15 100644 --- a/crates/world-render/Cargo.toml +++ b/crates/world-render/Cargo.toml @@ -20,3 +20,6 @@ glam = { workspace = true } # train moves. A mip chain is the only cure, and DDS is the one format that # carries one and that ImageMagick can write (`tools/cars/import_cars.mjs`). bevy = { workspace = true, features = ["jpeg", "dds"] } +# Only for `wgpu::Color`, the clear value of the grass ground cache; unifies +# with the version Bevy pins. +wgpu = { version = "29", default-features = false } diff --git a/crates/world-render/src/farmland.rs b/crates/world-render/src/farmland.rs index 3bcded98..627aaf0c 100644 --- a/crates/world-render/src/farmland.rs +++ b/crates/world-render/src/farmland.rs @@ -276,6 +276,9 @@ pub fn spawn_fields( // level meshes with their own distances — the patch grows it // when a camera comes near, and takes it back when none does. crate::plants::FieldPlants::default(), + // No meadow grass grows through it: the patch cuts a hole into + // the grass ground cache (`crate::grass`). + crate::grass::GroundSurface::Excluded, )); } }); diff --git a/crates/world-render/src/grass.wgsl b/crates/world-render/src/grass.wgsl deleted file mode 100644 index 4d17a7a0..00000000 --- a/crates/world-render/src/grass.wgsl +++ /dev/null @@ -1,213 +0,0 @@ -// Close meadow foliage: Bevy's standard mesh vertex path with root-pinned -// wind deformation, followed by the same weather-aware PBR fragment path as -// the rest of the outdoor world. - -#import bevy_pbr::{ - mesh_bindings::mesh, - mesh_functions, - forward_io::{VertexOutput, FragmentOutput}, - view_transformations::position_world_to_clip, - mesh_view_bindings::{globals, view}, - pbr_fragment::pbr_input_from_standard_material, - pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, -} -#import world_render::weather::{Weather, weather_pbr} - -@group(#{MATERIAL_BIND_GROUP}) @binding(100) var grass_weather: Weather; - -struct GrassSettings { - bands: vec4, - fades: vec4, - options: vec4, -} - -@group(#{MATERIAL_BIND_GROUP}) @binding(101) var grass_settings: GrassSettings; - -struct GrassVertex { - @builtin(instance_index) instance_index: u32, - @location(0) position: vec3, - @location(1) normal: vec3, - @location(2) color: vec4, - // xy = compressed radial LOD/random; zw = local leaf coordinates. - @location(3) data: vec4, -} - -fn grass_coverage(lod: f32, distance_to_camera: f32) -> f32 { - if grass_settings.options.x < 0.5 { - return 0.0; - } - if lod < 0.5 { - return 1.0 - smoothstep( - grass_settings.bands.x - grass_settings.fades.x, - grass_settings.bands.x + grass_settings.fades.x, - distance_to_camera, - ); - } else if lod < 1.5 { - return smoothstep( - grass_settings.bands.x - grass_settings.fades.x, - grass_settings.bands.x + grass_settings.fades.x, - distance_to_camera, - ) * (1.0 - smoothstep( - grass_settings.bands.y - grass_settings.fades.y, - grass_settings.bands.y + grass_settings.fades.y, - distance_to_camera, - )); - } else if lod < 2.5 { - return smoothstep( - grass_settings.bands.y - grass_settings.fades.y, - grass_settings.bands.y + grass_settings.fades.y, - distance_to_camera, - ) * (1.0 - smoothstep( - grass_settings.bands.z - grass_settings.fades.z, - grass_settings.bands.z + grass_settings.fades.z, - distance_to_camera, - )); - } - return 1.0 - smoothstep( - grass_settings.bands.w - grass_settings.fades.w, - grass_settings.bands.w + grass_settings.fades.w, - distance_to_camera, - ); -} - -// A conservative two-metre guard around the visible interval. Entire blades -// outside it can be rejected in the vertex stage, before triangle setup and -// fragment shading. The guard is wider than any blade, so the visible result -// remains byte-for-byte governed by grass_coverage in the fragment stage. -fn grass_can_be_visible(lod: f32, distance_to_camera: f32) -> bool { - if grass_settings.options.x < 0.5 { - return false; - } - let guard = 2.0; - if lod < 0.5 { - return distance_to_camera < grass_settings.bands.x + grass_settings.fades.x + guard; - } else if lod < 1.5 { - return distance_to_camera > grass_settings.bands.x - grass_settings.fades.x - guard - && distance_to_camera < grass_settings.bands.y + grass_settings.fades.y + guard; - } else if lod < 2.5 { - return distance_to_camera > grass_settings.bands.y - grass_settings.fades.y - guard - && distance_to_camera < grass_settings.bands.z + grass_settings.fades.z + guard; - } - return distance_to_camera < grass_settings.bands.w + grass_settings.fades.w + guard; -} - -@vertex -fn vertex(vertex: GrassVertex) -> VertexOutput { - var out: VertexOutput; - let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); - -#ifdef VERTEX_NORMALS - out.world_normal = mesh_functions::mesh_normal_local_to_world( - vertex.normal, - vertex.instance_index, - ); -#endif - -#ifdef VERTEX_POSITIONS - var world_position = mesh_functions::mesh_position_local_to_world( - world_from_local, - vec4(vertex.position, 1.0), - ); -#ifdef VERTEX_COLORS - // Alpha carries normalized blade height: zero pins the root, one lets the - // pointed tip take the full displacement. Two frequencies keep a whole - // cell from moving as one rigid sheet. - let weight = pow(clamp(vertex.color.a, 0.0, 1.0), 1.65); - let speed = length(grass_weather.wind.xy); - var direction = vec2(0.72, 0.69); - if speed > 0.05 { - direction = normalize(grass_weather.wind.xy); - } - let phase = dot(world_position.xz, vec2(0.19, 0.13)) - + globals.time * (1.25 + speed * 0.09); - let gust = sin(phase) + sin(phase * 2.17 + world_position.x * 0.31) * 0.32; - let amplitude = 0.012 + min(speed, 18.0) * 0.006; - let offset = direction * (gust * amplitude * weight); - world_position = vec4( - world_position.x + offset.x, - world_position.y, - world_position.z + offset.y, - world_position.w, - ); -#endif - out.world_position = world_position; - out.position = position_world_to_clip(world_position.xyz); -#ifdef VERTEX_UVS_A - let distance_to_camera = distance(world_position.xyz, view.world_position.xyz); - if !grass_can_be_visible(vertex.data.x * 3.0, distance_to_camera) { - // z > w is beyond the far clip plane. All vertices of an invisible - // blade take this path, so the rasterizer receives no triangle. - out.position = vec4(0.0, 0.0, 2.0, 1.0); - } -#endif -#endif - -#ifdef VERTEX_UVS_A - out.uv = vertex.data.xy; -#endif -#ifdef VERTEX_UVS_B - out.uv_b = vertex.data.zw; -#endif -#ifdef VERTEX_TANGENTS - out.world_tangent = mesh_functions::mesh_tangent_local_to_world( - world_from_local, - vertex.tangent, - vertex.instance_index, - ); -#endif -#ifdef VERTEX_COLORS - // StandardMaterial must see opaque vertex colour; alpha has already done - // its private job as the bend weight above. - // RGB was normalized into an 8-bit 0..1.5 range in the mesh. Expanding - // restores the authored HDR tint while cutting twelve bytes per vertex. - out.color = vec4(vertex.color.rgb * 1.5, 1.0); -#endif -#ifdef VERTEX_OUTPUT_INSTANCE_INDEX - out.instance_index = vertex.instance_index; -#endif -#ifdef VISIBILITY_RANGE_DITHER - out.visibility_range_dither = mesh_functions::get_visibility_range_dither_level( - vertex.instance_index, - world_from_local[3], - ); -#endif - return out; -} - -@fragment -fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { - // Mesh residency is cell based, but visible detail must never be. UV.x - // identifies close / near / far / hero-detail geometry and UV.y is one - // stable random number for the complete blade. Selecting here makes the - // bands perfectly radial around the current camera every frame; no 32 m - // entity AABB can leak into the picture as a square patch. - let distance_to_camera = distance(in.world_position.xyz, view.world_position.xyz); - let coverage = grass_coverage(in.uv.x * 3.0, distance_to_camera); - if in.uv.y > coverage { - discard; - } - - var pbr_input = pbr_input_from_standard_material(in, is_front); - // UV-B is local to the leaf. A soft central vein, slightly darker edges - // and a lengthwise chlorophyll gradient keep nearby blades from reading - // as flat, uniformly green polygons. UV-A.y adds stable plant-to-plant - // variation rather than animated noise. - let across = abs(in.uv_b.x - 0.5) * 2.0; - let along = clamp(in.uv_b.y, 0.0, 1.0); - let vein = 1.0 - smoothstep(0.035, 0.16, abs(in.uv_b.x - 0.5)); - let edge_shade = 1.0 - 0.13 * smoothstep(0.62, 1.0, across); - let length_shade = mix(0.82, 1.08, along); - let plant_variation = 0.94 + in.uv.y * 0.12; - let blade_tint = edge_shade * length_shade * plant_variation * (1.0 + vein * 0.055); - pbr_input.material.base_color = vec4( - pbr_input.material.base_color.rgb * blade_tint, - pbr_input.material.base_color.a, - ); - pbr_input.material.perceptual_roughness = mix(0.90, 0.76, along); - pbr_input = weather_pbr(grass_weather, globals.time, pbr_input); - - var out: FragmentOutput; - out.color = apply_pbr_lighting(pbr_input); - out.color = main_pass_post_lighting_processing(pbr_input, out.color); - return out; -} diff --git a/crates/world-render/src/grass/blades.wgsl b/crates/world-render/src/grass/blades.wgsl new file mode 100644 index 00000000..b6bed7fd --- /dev/null +++ b/crates/world-render/src/grass/blades.wgsl @@ -0,0 +1,199 @@ +// Grass blades: one indirect draw per level of detail over the instance list +// the scatter pass wrote. The vertex stage grows a blade out of its packed +// record — a quadratic Bézier bent by its own lean and the wind, tapered, +// never thinner than a pixel — and the fragment stage lights it the way the +// rest of the outdoor world is lit: Bevy's PBR with the sun's shadow, the +// atmosphere's fog and the weather's wet and snow. + +#import bevy_pbr::{ + mesh_view_bindings::{view, globals}, + mesh_types::MESH_FLAGS_SHADOW_RECEIVER_BIT, + view_transformations::position_world_to_clip, + pbr_types, + pbr_functions::{ + apply_pbr_lighting, main_pass_post_lighting_processing, prepare_world_normal, + calculate_view, + }, +} +#import world_render::weather::{Weather, weather_pbr} + +struct GrassUniform { + frustum: array, 6>, + camera: vec4, + ground: vec4, + grid: vec4, + density: vec4, + lods: vec4, + look: vec4, + season: vec4, + capacity: vec4, + weather: Weather, +} + +struct Blade { + pos: vec3, + a: u32, + b: u32, + c: u32, + d: u32, + e: u32, +} + +struct LodInfo { + // x = segments along the blade, y = level, zw = unused. + info: vec4, +} + +@group(2) @binding(0) var grass: GrassUniform; +@group(2) @binding(1) var blades: array; +@group(2) @binding(2) var lod: LodInfo; + +const TAU: f32 = 6.28318530718; +const BLADE_MAX_HEIGHT: f32 = 0.6; +const BLADE_MAX_WIDTH: f32 = 0.1; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) world_position: vec4, + @location(1) world_normal: vec3, + // x = along the blade 0…1, y = across it 0…1. + @location(2) uv: vec2, + @location(3) color: vec3, + // x = perceptual roughness, y = ambient occlusion. + @location(4) shade: vec2, +} + +@vertex +fn vertex( + @builtin(vertex_index) vertex_index: u32, + @builtin(instance_index) instance_index: u32, +) -> VertexOutput { + let blade = blades[instance_index]; + let a = unpack2x16unorm(blade.a); + let b = unpack2x16unorm(blade.b); + let c = unpack4x8unorm(blade.c); + let nxz = unpack2x16snorm(blade.d); + let e = unpack2x16unorm(blade.e); + let facing_angle = a.x * TAU; + let height = max(a.y * BLADE_MAX_HEIGHT, 0.01); + let width = b.x * BLADE_MAX_WIDTH; + let bend = b.y; + let hue = c.x; + let light = c.y; + let dry = c.z; + let stiffness = c.w; + let clump = e.x; + let phase = e.y; + let ground_normal = normalize(vec3(nxz.x, sqrt(max(0.0, 1.0 - dot(nxz, nxz))), nxz.y)); + + // 2N + 1 vertices: two per row and the tip. + let segments = lod.info.x; + var row = vertex_index / 2u; + var side = f32(vertex_index & 1u); + if vertex_index >= 2u * segments { + row = segments; + side = 0.5; + } + let t = f32(row) / f32(segments); + + let facing = vec3(cos(facing_angle), 0.0, sin(facing_angle)); + let lateral = vec3(-facing.z, 0.0, facing.x); + // A blade grows up, and a little out of the slope it stands on. + let up = normalize(mix(vec3(0.0, 1.0, 0.0), ground_normal, 0.35)); + let root = blade.pos; + + // Wind: a slow gust front travelling downwind, a finer ripple on it, and + // each blade's own flutter. Stiff blades take less of all three. + let wind = grass.weather.wind.xy; + let speed = length(wind); + var wind_dir = vec2(0.72, 0.69); + if speed > 0.05 { + wind_dir = wind / speed; + } + let time = globals.time; + let front = dot(root.xz, wind_dir) * 0.12 - time * (0.9 + speed * 0.35); + let gust = sin(front) * 0.6 + + sin(front * 2.3 + root.x * 0.7 + root.z * 0.4) * 0.3 + + sin(time * (2.1 + phase * 1.5) + phase * 12.0) * 0.15; + let strength = 0.04 + 0.05 * min(speed, 20.0); + let lean_wind = (0.35 + 0.65 * gust) * strength * (1.4 - stiffness); + let wind_offset = vec3(wind_dir.x, 0.0, wind_dir.y) * lean_wind * height; + + // The curve: root, a control point at half height, and the tip leaning + // out along the blade's facing and the wind. Bending shortens the rise. + let lean = bend * height; + let tip = root + up * height * (1.0 - 0.25 * bend * bend) + facing * lean + wind_offset; + let mid = root + up * height * 0.55 + facing * lean * 0.22 + wind_offset * 0.28; + let omt = 1.0 - t; + var p = omt * omt * root + 2.0 * omt * t * mid + t * t * tip; + let tangent = normalize(2.0 * omt * (mid - root) + 2.0 * t * (tip - mid)); + + // Broad at the foot, pointed at the tip — and never thinner than about a + // pixel and a half, or a far blade shimmers in and out of existence. + var half_width = width * 0.5 * (1.0 - smoothstep(0.2, 1.0, t) * 0.92); + let clip_centre = position_world_to_clip(p); + let pixel = 2.0 * max(clip_centre.w, 0.01) / (view.clip_from_view[1][1] * view.viewport.w); + half_width = max(half_width, 0.75 * pixel); + p += lateral * (side - 0.5) * 2.0 * half_width; + + // Flat blades light flat. Turning the normal across the blade and a + // little towards the sky reads as a rounded stalk and lets the sward + // take skylight. + var normal = normalize(cross(lateral, tangent)); + if dot(normal, facing) < 0.0 { + normal = -normal; + } + normal = normalize(normal + lateral * (side - 0.5) * 0.7 + up * 0.15); + + // Colour: darker and browner at the foot, alive at the tip, every blade + // its own shade, dry straws where the clump noise says. Far away the + // blade-to-blade variation is faded out: at a pixel a blade it is not + // variety but speckle, and the sward has to settle into one green. + let far = smoothstep(15.0, 110.0, distance(root, view.world_position)); + let light_here = mix(light, 0.5, far * 0.8); + let hue_here = mix(hue, 0.5, far * 0.7); + var color = mix(vec3(0.050, 0.100, 0.021), vec3(0.150, 0.270, 0.056), pow(t, 0.8)); + color *= 0.82 + 0.36 * light_here; + color *= mix(vec3(0.92, 1.0, 1.12), vec3(1.14, 1.0, 0.82), hue_here); + color *= 0.88 + 0.24 * clump; + let straw = vec3(0.30, 0.25, 0.09) * (0.6 + 0.6 * t); + color = mix(color, straw, dry * (0.35 + 0.65 * t) * (1.0 - 0.5 * far)); + + var out: VertexOutput; + out.position = position_world_to_clip(p); + out.world_position = vec4(p, 1.0); + out.world_normal = normal; + out.uv = vec2(t, side); + out.color = color; + out.shade = vec2(mix(0.78, 0.6, t), mix(0.30, 1.0, pow(t, 0.7))); + return out; +} + +@fragment +fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> @location(0) vec4 { + var pbr_input = pbr_types::pbr_input_new(); + pbr_input.flags = MESH_FLAGS_SHADOW_RECEIVER_BIT; + pbr_input.material.flags = pbr_types::STANDARD_MATERIAL_FLAGS_FOG_ENABLED_BIT + | pbr_types::STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT + | pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_OPAQUE; + pbr_input.material.base_color = vec4(in.color, 1.0); + pbr_input.material.perceptual_roughness = in.shade.x; + pbr_input.material.reflectance = vec3(0.3); + // A leaf lit from behind glows: diffuse transmission through a thin + // blade, which is most of what makes a meadow against the sun. + pbr_input.material.diffuse_transmission = 0.3; + pbr_input.material.thickness = 0.01; + pbr_input.frag_coord = in.position; + pbr_input.world_position = in.world_position; + pbr_input.is_orthographic = view.clip_from_view[3].w == 1.0; + pbr_input.V = calculate_view(in.world_position, pbr_input.is_orthographic); + pbr_input.world_normal = prepare_world_normal(in.world_normal, true, is_front); + pbr_input.N = normalize(pbr_input.world_normal); + // The foot of a blade stands in the shade of the whole sward. + pbr_input.diffuse_occlusion = vec3(in.shade.y); + pbr_input = weather_pbr(grass.weather, globals.time, pbr_input); + + var color = apply_pbr_lighting(pbr_input); + color = main_pass_post_lighting_processing(pbr_input, color); + return color; +} diff --git a/crates/world-render/src/grass/ground.rs b/crates/world-render/src/grass/ground.rs new file mode 100644 index 00000000..ccb1451a --- /dev/null +++ b/crates/world-render/src/grass/ground.rs @@ -0,0 +1,411 @@ +//! The ground cache: what the meadow stands on, as one top-down texture. +//! +//! Every [`GroundSurface`](super::GroundSurface) around the camera is drawn +//! orthographically into a square of [`TEXELS`]² texels covering the grass +//! range plus a margin. Red is the height in render space, green the grass +//! weight — the terrain's own splat share, zero on a field, a road or a lake, +//! and the sentinel [`INVALID_HEIGHT`] where nothing was drawn at all. The +//! scatter pass reads its blades' feet off this, so a blade stands exactly +//! on the drawn ground, and stands nowhere the ground is covered. +//! +//! It is drawn again only when it has to be: the camera has left the margin, +//! a tile has streamed in or out, the origin was rebased (which moves every +//! surface at once), or a mesh was not on the GPU yet last time. In a normal +//! frame this pass does nothing. + +use std::hash::{Hash, Hasher}; + +use bevy::core_pipeline::Core3dSystems; +use bevy::core_pipeline::schedule::Core3d; +use bevy::mesh::MeshVertexBufferLayoutRef; +use bevy::prelude::*; +use bevy::render::mesh::allocator::MeshAllocator; +use bevy::render::mesh::{RenderMesh, RenderMeshBufferInfo}; +use bevy::render::render_asset::RenderAssets; +use bevy::render::render_resource::binding_types::{storage_buffer_read_only, uniform_buffer}; +use bevy::render::render_resource::{ + BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, + CachedRenderPipelineId, ColorTargetState, ColorWrites, CompareFunction, DepthStencilState, + Extent3d, FragmentState, LoadOp, Operations, PipelineCache, PrimitiveState, + RenderPassColorAttachment, RenderPassDepthStencilAttachment, RenderPassDescriptor, + RenderPipelineDescriptor, ShaderStages, ShaderType, SpecializedMeshPipeline, + SpecializedMeshPipelineError, SpecializedMeshPipelines, StorageBuffer, StoreOp, + TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, TextureView, + TextureViewDescriptor, UniformBuffer, VertexState, +}; +use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}; +use bevy::render::view::ExtractedView; +use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems}; + +use super::{ExtractedGroundSurface, GrassEnvironment, GrassView}; + +/// Texels on a side of the cache. At the widest range (400 m + margin) that +/// is still under a metre a texel, on a terrain whose grid is four. +pub(super) const TEXELS: u32 = 1024; +/// How far past the grass range the cache reaches, and so how far the camera +/// may travel before it is drawn again \[m\]. +const MARGIN: f32 = 64.0; +/// Half the height band the depth test resolves, around the camera \[m\]. +const HEIGHT_RANGE: f32 = 1200.0; +/// The height written where nothing was drawn. Far below any terrain. +pub(super) const INVALID_HEIGHT: f32 = -1.0e9; + +#[derive(ShaderType, Clone, Copy)] +struct GroundUniform { + /// xz = centre, y = reference height, w = half side \[m\]. + centre: Vec4, + /// x = [`HEIGHT_RANGE`], y = [`INVALID_HEIGHT`]. + range: Vec4, +} + +#[derive(ShaderType, Clone, Copy)] +struct GroundDraw { + world_from_local: Mat4, + /// x = 1 for a surface that excludes grass. + flags: UVec4, +} + +struct QueuedDraw { + mesh: AssetId, + pipeline: CachedRenderPipelineId, +} + +/// The cache itself and the bookkeeping that decides when to draw it again. +#[derive(Resource)] +pub(super) struct GroundCache { + pub color_view: TextureView, + depth_view: TextureView, + /// Where the cache was drawn from: xz centre, y reference height. + pub centre: Vec3, + pub half_extent: f32, + /// Drawn at least once, so the scatter pass may read it. + pub valid: bool, + /// Has to be drawn this frame. + dirty: bool, + /// A surface's GPU mesh was missing last time; draw again when it lands. + incomplete: bool, + surfaces: u64, + uniform: UniformBuffer, + draws: StorageBuffer>, + bind_group: Option, + queued: Vec, +} + +#[derive(Resource)] +pub(super) struct GroundPipeline { + shader: Handle, + layout: BindGroupLayoutDescriptor, +} + +impl SpecializedMeshPipeline for GroundPipeline { + type Key = (); + + fn specialize( + &self, + _key: (), + layout: &MeshVertexBufferLayoutRef, + ) -> Result { + let vertex = layout.0.get_layout(&[ + Mesh::ATTRIBUTE_POSITION.at_shader_location(0), + Mesh::ATTRIBUTE_COLOR.at_shader_location(1), + ])?; + Ok(RenderPipelineDescriptor { + label: Some("grass_ground".into()), + layout: vec![self.layout.clone()], + vertex: VertexState { + shader: self.shader.clone(), + buffers: vec![vertex], + ..default() + }, + fragment: Some(FragmentState { + shader: self.shader.clone(), + targets: vec![Some(ColorTargetState { + format: TextureFormat::Rg32Float, + blend: None, + write_mask: ColorWrites::ALL, + })], + ..default() + }), + primitive: PrimitiveState { + cull_mode: None, + ..default() + }, + // Highest surface wins: the depth is the height. + depth_stencil: Some(DepthStencilState { + format: TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(CompareFunction::Greater), + stencil: default(), + bias: default(), + }), + ..default() + }) + } +} + +pub(super) fn plugin(app: &mut App) { + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .init_resource::>() + .add_systems(RenderStartup, init) + .add_systems(Render, prepare.in_set(RenderSystems::PrepareResources)) + .add_systems(Core3d, pass.before(Core3dSystems::Prepass)); +} + +fn init(mut commands: Commands, device: Res, asset_server: Res) { + let size = Extent3d { + width: TEXELS, + height: TEXELS, + depth_or_array_layers: 1, + }; + let color = device.create_texture(&TextureDescriptor { + label: Some("grass_ground_color"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rg32Float, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let depth = device.create_texture(&TextureDescriptor { + label: Some("grass_ground_depth"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Depth32Float, + usage: TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + commands.insert_resource(GroundCache { + color_view: color.create_view(&TextureViewDescriptor::default()), + depth_view: depth.create_view(&TextureViewDescriptor::default()), + centre: Vec3::ZERO, + half_extent: 0.0, + valid: false, + dirty: false, + incomplete: false, + surfaces: 0, + uniform: UniformBuffer::from(GroundUniform { + centre: Vec4::ZERO, + range: Vec4::ZERO, + }), + draws: StorageBuffer::default(), + bind_group: None, + queued: Vec::new(), + }); + commands.insert_resource(GroundPipeline { + shader: asset_server.load("embedded://world_render/grass/ground.wgsl"), + layout: BindGroupLayoutDescriptor::new( + "grass_ground", + &BindGroupLayoutEntries::sequential( + ShaderStages::VERTEX, + ( + uniform_buffer::(false), + storage_buffer_read_only::>(false), + ), + ), + ), + }); +} + +/// Decides whether the cache is drawn this frame and, if so, lays out the +/// draws: one per surface whose mesh is on the GPU. +#[allow(clippy::too_many_arguments)] +pub(super) fn prepare( + mut cache: ResMut, + pipeline: Res, + mut pipelines: ResMut>, + pipeline_cache: Res, + environment: Res, + views: Query<&ExtractedView, With>, + surfaces: Query<&ExtractedGroundSurface>, + meshes: Res>, + device: Res, + queue: Res, +) { + cache.queued.clear(); + cache.dirty = false; + if !environment.settings.enabled { + return; + } + let Some(view) = views.iter().next() else { + return; + }; + let eye = view.world_from_view.translation(); + let half_extent = environment.settings.range + MARGIN; + + // A fingerprint of the surfaces and where they stand. A tile streaming + // in or out changes it, and so does an origin rebase, which moves them + // all at once. + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut count = 0usize; + for surface in &surfaces { + surface.mesh.hash(&mut hasher); + for value in surface.world_from_local.w_axis.to_array() { + value.to_bits().hash(&mut hasher); + } + count += 1; + } + count.hash(&mut hasher); + let surfaces_now = hasher.finish(); + + let moved = (eye.xz() - cache.centre.xz()).length() > MARGIN * 0.75 + || (eye.y - cache.centre.y).abs() > HEIGHT_RANGE * 0.5; + let wanted = !cache.valid + || moved + || surfaces_now != cache.surfaces + || half_extent != cache.half_extent + || cache.incomplete; + if !wanted { + return; + } + cache.centre = eye; + cache.half_extent = half_extent; + cache.surfaces = surfaces_now; + + let mut draws = Vec::new(); + let mut incomplete = false; + for surface in &surfaces { + let Some(mesh) = meshes.get(surface.mesh) else { + // Streamed in this frame; the GPU copy follows next frame. + incomplete = true; + continue; + }; + // A mesh without colours cannot say where its grass is; it is left + // out for good rather than asked again every frame. + let Ok(id) = pipelines.specialize(&pipeline_cache, &pipeline, (), &mesh.layout) else { + continue; + }; + draws.push(GroundDraw { + world_from_local: surface.world_from_local, + flags: UVec4::new(u32::from(surface.excluded), 0, 0, 0), + }); + cache.queued.push(QueuedDraw { + mesh: surface.mesh, + pipeline: id, + }); + } + cache.incomplete = incomplete; + if draws.is_empty() { + cache.bind_group = None; + // Nothing to stand on yet: the cache is drawn (clear) all the same, so + // the scatter pass reads the sentinel rather than stale ground. + cache.dirty = true; + return; + } + + cache.uniform.set(GroundUniform { + centre: Vec4::new(eye.x, eye.y, eye.z, half_extent), + range: Vec4::new(HEIGHT_RANGE, INVALID_HEIGHT, 0.0, 0.0), + }); + cache.uniform.write_buffer(&device, &queue); + cache.draws.set(draws); + cache.draws.write_buffer(&device, &queue); + let layout = pipeline_cache.get_bind_group_layout(&pipeline.layout); + let (Some(uniform), Some(draws)) = (cache.uniform.binding(), cache.draws.binding()) else { + return; + }; + let bind_group = device.create_bind_group( + "grass_ground", + &layout, + &BindGroupEntries::sequential((uniform, draws)), + ); + cache.bind_group = Some(bind_group); + cache.dirty = true; +} + +/// Draws the cache when [`prepare`] asked for it. +pub(super) fn pass( + view: ViewQuery<(), With>, + mut cache: ResMut, + pipeline_cache: Res, + meshes: Res>, + allocator: Res, + mut ctx: RenderContext, +) { + let _ = view; + if !cache.dirty { + return; + } + let mut all_ready = true; + { + let color_attachment = RenderPassColorAttachment { + view: &cache.color_view, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(wgpu::Color { + r: f64::from(INVALID_HEIGHT), + g: 0.0, + b: 0.0, + a: 0.0, + }), + store: StoreOp::Store, + }, + }; + let depth_attachment = RenderPassDepthStencilAttachment { + view: &cache.depth_view, + depth_ops: Some(Operations { + load: LoadOp::Clear(0.0), + store: StoreOp::Store, + }), + stencil_ops: None, + }; + let mut pass = ctx.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("grass_ground_pass"), + color_attachments: &[Some(color_attachment)], + depth_stencil_attachment: Some(depth_attachment), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + if let Some(bind_group) = &cache.bind_group { + pass.set_bind_group(0, bind_group, &[]); + for (k, draw) in cache.queued.iter().enumerate() { + let Some(pipeline) = pipeline_cache.get_render_pipeline(draw.pipeline) else { + // Still compiling: drawn again next frame. + all_ready = false; + continue; + }; + let (Some(mesh), Some(vertices)) = ( + meshes.get(draw.mesh), + allocator.mesh_vertex_slice(&draw.mesh), + ) else { + all_ready = false; + continue; + }; + pass.set_render_pipeline(pipeline); + pass.set_vertex_buffer(0, vertices.buffer.slice(..)); + let instance = k as u32..k as u32 + 1; + match &mesh.buffer_info { + RenderMeshBufferInfo::Indexed { + count, + index_format, + } => { + let Some(indices) = allocator.mesh_index_slice(&draw.mesh) else { + all_ready = false; + continue; + }; + pass.set_index_buffer(indices.buffer.slice(..), *index_format); + pass.draw_indexed( + indices.range.start..indices.range.start + count, + vertices.range.start as i32, + instance, + ); + } + RenderMeshBufferInfo::NonIndexed => { + pass.draw(vertices.range.clone(), instance); + } + } + } + } + } + cache.valid = true; + if !all_ready { + cache.incomplete = true; + } +} diff --git a/crates/world-render/src/grass/ground.wgsl b/crates/world-render/src/grass/ground.wgsl new file mode 100644 index 00000000..ec62dac1 --- /dev/null +++ b/crates/world-render/src/grass/ground.wgsl @@ -0,0 +1,70 @@ +// The grass ground cache: the terrain and the surfaces draped on it, drawn +// top down into a texture of (height, grass weight). One draw per surface; +// `draws[instance_index]` carries its transform and whether it grows grass or +// cuts a hole. + +struct GroundUniform { + // xz = centre of the cache in render space, y = reference height, + // w = half the side of the cache [m]. + centre: vec4, + // x = half the height range around the reference [m], y = the sentinel + // written where nothing is drawn, zw = unused. + range: vec4, +} + +struct GroundDraw { + world_from_local: mat4x4, + // x = 1 where the surface excludes grass. + flags: vec4, +} + +@group(0) @binding(0) var ground: GroundUniform; +@group(0) @binding(1) var draws: array; + +struct VertexIn { + @builtin(instance_index) instance: u32, + @location(0) position: vec3, + @location(1) color: vec4, +} + +struct VertexOut { + @builtin(position) clip: vec4, + @location(0) data: vec2, +} + +@vertex +fn vertex(in: VertexIn) -> VertexOut { + let draw = draws[in.instance]; + let world = draw.world_from_local * vec4(in.position, 1.0); + let half = ground.centre.w; + let excluded = draw.flags.x != 0u; + // Highest surface wins (compare Greater). A field or road draped on the + // terrain lies within centimetres of it, so it is lifted a little here, + // or the ground it covers could win the coplanar fight. + let lift = select(0.0, 0.6, excluded); + let depth = clamp( + (world.y + lift - (ground.centre.y - ground.range.x)) / (2.0 * ground.range.x), + 0.0, + 1.0, + ); + var out: VertexOut; + out.clip = vec4( + (world.x - ground.centre.x) / half, + -(world.z - ground.centre.z) / half, + depth, + 1.0, + ); + // The terrain's splat weights sum to one across r, g, b; the grass share + // is the same figure `terrain_splat.wgsl` blends the ground texture by. + var mask = 0.0; + if !excluded { + mask = in.color.r / max(in.color.r + in.color.g + in.color.b, 1e-4); + } + out.data = vec2(world.y, mask); + return out; +} + +@fragment +fn fragment(in: VertexOut) -> @location(0) vec4 { + return vec4(in.data, 0.0, 0.0); +} diff --git a/crates/world-render/src/grass/mod.rs b/crates/world-render/src/grass/mod.rs new file mode 100644 index 00000000..fc7a1691 --- /dev/null +++ b/crates/world-render/src/grass/mod.rs @@ -0,0 +1,243 @@ +//! Meadow grass around the camera, generated and drawn by the GPU (plan ch. 14). +//! +//! The first version grew the meadow on the CPU: every 32 m cell within reach +//! of the camera became a mesh of a million vertices, built on the main thread +//! the frame the camera came near, uploaded whole, and drawn through the full +//! PBR fragment path three LOD levels deep. A train at line speed entered a new +//! row of cells every second, and every one of them was a visible hitch. +//! +//! Nothing of the meadow is built on the CPU any more. It works the way the +//! grass of a current console title does: +//! +//! 1. **A ground cache.** The terrain tiles around the camera — and the +//! fields, roads and waters draped on them — are drawn once, top down, +//! into a texture of heights and grass weights ([`ground`]). It is drawn +//! again only when the camera has moved a good way or a tile has streamed +//! in or out, so in a normal frame it costs nothing. +//! 2. **A scatter pass.** Each frame a compute shader walks a grid of 4 m +//! patches around the camera. A patch outside the view frustum costs one +//! box test; a patch inside lays out blades on a low-discrepancy sequence, +//! thins them with distance, reads their feet off the ground cache, culls +//! each blade against the frustum, and appends what survives to one of +//! three instance lists ([`render`]). +//! 3. **Three indirect draws.** One draw per level of detail; the vertex +//! shader bends each instance along a quadratic Bézier into the wind, +//! tapers it, keeps it at least a pixel wide, and the fragment shader +//! lights it with the same PBR, shadow, fog and weather path as the rest +//! of the world. +//! +//! The thinning is what makes it seamless: every blade has a rank, and a +//! blade stands wherever its rank is below the density the distance asks +//! for. Coming closer adds blades and never moves one, the survivors widen +//! as their neighbours go so the sward keeps its cover, and a blade at the +//! threshold grows in rather than popping. +//! +//! **Multiplayer.** Nothing here is state. Every blade is a function of the +//! ground and a hash of its own position. + +mod ground; +mod render; + +use bevy::asset::embedded_asset; +use bevy::camera::RenderTarget; +use bevy::ecs::query::QueryItem; +use bevy::prelude::*; +use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin}; +use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin}; +use bevy::render::sync_component::SyncComponent; +use fields::CropClass; +use fields::phenology; + +use crate::{Season, WorldView, sky::Sky, weather::WeatherParams}; + +/// What the graphics settings decide about the meadow. +#[derive(Resource, Clone, Copy, Debug, PartialEq)] +pub struct GrassRenderSettings { + pub enabled: bool, + /// How far from the camera the last blade stands \[m\]. + pub range: f32, + /// Scale on the stand's density, 1 being the authored meadow. + pub density: f32, +} + +impl Default for GrassRenderSettings { + fn default() -> Self { + Self::new(true, 220.0, 1.0) + } +} + +impl GrassRenderSettings { + pub fn new(enabled: bool, range: f32, density: f32) -> Self { + Self { + enabled, + range: range.clamp(40.0, 400.0), + density: density.clamp(0.1, 1.5), + } + } +} + +/// A surface the ground cache is drawn from. +/// +/// The terrain carries the grass weight of its splat in the red vertex +/// colour; everything draped on it cuts a hole, so no blade stands in a +/// wheat field, on a carriageway or in a lake. +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundSurface { + /// Grass grows where the splat says. + Terrain, + /// Nothing grows here. + Excluded, +} + +/// A [`GroundSurface`] as the render world sees it: the mesh, where it +/// stands this frame, and whether it grows or excludes. +#[derive(Component, Clone, Debug)] +pub struct ExtractedGroundSurface { + pub mesh: AssetId, + pub world_from_local: Mat4, + pub excluded: bool, +} + +impl SyncComponent for GroundSurface { + type Target = ExtractedGroundSurface; +} + +impl ExtractComponent for GroundSurface { + type QueryData = ( + &'static GroundSurface, + &'static Mesh3d, + &'static GlobalTransform, + ); + type QueryFilter = (); + type Out = ExtractedGroundSurface; + + fn extract_component( + (surface, mesh, transform): QueryItem<'_, '_, Self::QueryData>, + ) -> Option { + Some(ExtractedGroundSurface { + mesh: mesh.id(), + world_from_local: transform.to_matrix(), + excluded: *surface == GroundSurface::Excluded, + }) + } +} + +/// The camera the meadow is scattered around — the one that draws the world, +/// see [`crate::draws_the_world`]. Set every frame by [`mark_view`]. +#[derive(Component, Clone, Copy, Default, ExtractComponent)] +pub struct GrassView; + +/// The one entity the indirect draws hang on. Bevy's render phases want an +/// entity per item; the meadow is one item. +#[derive(Component, Clone, Copy, Default, ExtractComponent)] +pub struct GrassRenderer; + +/// Everything the render world needs from the main world besides the +/// surfaces and the camera: the settings, the weather, and the day. +#[derive(Resource, Clone, Copy, Debug, PartialEq, ExtractResource)] +pub struct GrassEnvironment { + pub settings: GrassRenderSettings, + pub weather: WeatherParams, + pub season: Season, + /// The stand's height today \[m\] — the grassland phenology, so a meadow + /// is short after the cut and long before it. + pub height: f32, +} + +impl Default for GrassEnvironment { + fn default() -> Self { + Self { + settings: GrassRenderSettings::default(), + weather: WeatherParams::default(), + season: Season::default(), + height: 0.22, + } + } +} + +pub(crate) fn plugin(app: &mut App) { + embedded_asset!(app, "ground.wgsl"); + embedded_asset!(app, "scatter.wgsl"); + embedded_asset!(app, "blades.wgsl"); + app.init_resource::() + .init_resource::() + .add_plugins(( + ExtractComponentPlugin::::default(), + ExtractComponentPlugin::::default(), + ExtractComponentPlugin::::default(), + ExtractResourcePlugin::::default(), + )) + .add_systems(Startup, spawn_renderer) + .add_systems(PostUpdate, (mark_view, feed_environment)); + render::plugin(app); +} + +fn spawn_renderer(mut commands: Commands) { + commands.spawn((crate::Persistent, GrassRenderer, Name::new("meadow grass"))); +} + +/// Puts [`GrassView`] on the camera that draws the world, and on no other. +#[allow(clippy::type_complexity)] +fn mark_view( + mut commands: Commands, + cameras: Query< + ( + Entity, + &Camera, + &RenderTarget, + Has, + Has, + ), + With, + >, +) { + let world = cameras + .iter() + .find(|(_, camera, target, world_view, _)| { + crate::draws_the_world(camera, target, *world_view) + }) + .map(|(entity, ..)| entity); + for (entity, _, _, _, marked) in &cameras { + let wanted = Some(entity) == world; + if wanted && !marked { + commands.entity(entity).insert(GrassView); + } else if !wanted && marked { + commands.entity(entity).remove::(); + } + } +} + +/// Writes the day's meadow: the settings, the weather the blades stand in, +/// the season and the height the grassland calendar gives. +fn feed_environment( + sky: Res, + settings: Res, + mut environment: ResMut, +) { + let growth = phenology::growth(CropClass::Grassland, sky.month, sky.day, 0); + let next = GrassEnvironment { + settings: *settings, + weather: WeatherParams::of(&sky), + season: Season::on(sky.month, sky.day), + // A verge is a lawn layer, not knee-high tussocks: the calendar's + // height, held between a fresh cut and a June meadow. + height: growth.height.clamp(0.12, 0.28), + }; + if *environment != next { + *environment = next; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_are_kept_within_what_the_buffers_hold() { + let wild = GrassRenderSettings::new(true, 5_000.0, 9.0); + assert!(wild.range <= 400.0); + assert!(wild.density <= 1.5); + let off = GrassRenderSettings::new(false, 100.0, 0.5); + assert!(!off.enabled); + } +} diff --git a/crates/world-render/src/grass/render.rs b/crates/world-render/src/grass/render.rs new file mode 100644 index 00000000..5b9b5896 --- /dev/null +++ b/crates/world-render/src/grass/render.rs @@ -0,0 +1,608 @@ +//! The scatter pass and the indirect blade draws. +//! +//! Per frame: a compute dispatch over the patch grid around the camera fills +//! three instance lists and their indirect draw arguments, and one phase item +//! in the opaque pass issues the three draws. The vertex and fragment work is +//! `blades.wgsl`; the pipeline for it is Bevy's own mesh pipeline with the +//! shaders and the third bind group swapped, so every view-level detail — +//! MSAA, HDR, the shadow filter, the fog, the atmosphere — is whatever the +//! camera has, without a second copy of the logic here. + +use bevy::core_pipeline::Core3dSystems; +use bevy::core_pipeline::core_3d::{Opaque3d, Opaque3dBatchSetKey, Opaque3dBinKey}; +use bevy::core_pipeline::schedule::Core3d; +use bevy::ecs::query::ROQueryItem; +use bevy::ecs::system::SystemParamItem; +use bevy::ecs::system::lifetimeless::SRes; +use bevy::math::primitives::ViewFrustum; +use bevy::mesh::{ + MeshVertexBufferLayout, MeshVertexBufferLayoutRef, MeshVertexBufferLayouts, PrimitiveTopology, + VertexBufferLayout, +}; +use bevy::pbr::{ + MeshPipeline, MeshPipelineKey, MeshPipelineSystems, SetMeshViewBindGroup, + SetMeshViewBindingArrayBindGroup, ViewKeyCache, +}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::mesh::allocator::MeshSlabs; +use bevy::render::render_phase::{ + AddRenderCommand, BinnedRenderPhaseType, DrawFunctions, InputUniformIndex, PhaseItem, + RenderCommand, RenderCommandResult, SetItemPipeline, TrackedRenderPass, ViewBinnedRenderPhases, +}; +use bevy::render::render_resource::binding_types::{ + storage_buffer_read_only_sized, storage_buffer_sized, texture_2d, uniform_buffer, +}; +use bevy::render::render_resource::{ + BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, Buffer, + BufferBinding, BufferDescriptor, BufferUsages, CachedComputePipelineId, CachedPipelineState, + CachedRenderPipelineId, ComputePassDescriptor, ComputePipelineDescriptor, IndexFormat, + PipelineCache, RawBufferVec, ShaderStages, ShaderType, SpecializedMeshPipeline, + TextureSampleType, UniformBuffer, VertexStepMode, +}; +use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}; +use bevy::render::sync_world::MainEntity; +use bevy::render::view::ExtractedView; +use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems}; + +use super::ground::{self, GroundCache, INVALID_HEIGHT, TEXELS}; +use super::{GrassEnvironment, GrassRenderer, GrassView}; +use crate::weather::WeatherParams; + +/// Side of one patch of the scatter grid \[m\] — one compute workgroup. +pub(super) const PATCH: f32 = 4.0; +/// Threads in a scatter workgroup; `scatter.wgsl` says the same. +const WORKGROUP: u32 = 64; +/// Segments along a blade per level of detail: eleven, seven and three +/// vertices. +const SEGMENTS: [u32; 3] = [5, 3, 1]; +/// Most blades a level may hold in one frame. Sized for the authored density +/// out to the widest range with a wide field of view, plus air; the counters +/// are clamped to it on overflow, so a flying camera loses blades rather than +/// the frame. +const CAPACITY: [u32; 3] = [262_144, 393_216, 524_288]; +/// Bytes of one packed blade — `Blade` in the shaders. +const BLADE_BYTES: u64 = 32; +/// Bytes between two levels' indirect arguments. +const INDIRECT_STRIDE: u64 = 32; +/// Blades per square metre at the camera, before the quality scale. +const DENSITY_AT_CAMERA: f32 = 450.0; +/// Distance at which the density has halved \[m\]. +const DENSITY_FALLOFF: f32 = 10.0; +/// Where the fine and the middle level hand over \[m\]. +const LOD_ENDS: [f32; 2] = [18.0, 60.0]; +/// Width of the fade-out at the range \[m\]. +const RANGE_FADE: f32 = 30.0; + +/// Everything the shaders read besides the instances. Matches `GrassUniform` +/// in `scatter.wgsl` and `blades.wgsl` field for field. +#[derive(ShaderType, Clone, Copy, Default)] +struct GrassUniform { + frustum: [Vec4; 6], + camera: Vec4, + ground: Vec4, + grid: Vec4, + density: Vec4, + lods: Vec4, + look: Vec4, + season: Vec4, + capacity: UVec4, + weather: WeatherParams, +} + +#[derive(ShaderType, Clone, Copy)] +struct LodInfo { + info: UVec4, +} + +#[derive(Resource)] +pub(super) struct GrassBuffers { + uniform: UniformBuffer, + lods: Vec>, + instances: Buffer, + indirect: Buffer, + indices: RawBufferVec, + index_starts: [u32; 3], + compute_bind_group: Option, + draw_bind_groups: Vec, + patches_per_side: u32, + /// Everything for this frame is in place; the draw command checks it. + ready: bool, +} + +#[derive(Resource)] +struct GrassPipelines { + compute_layout: BindGroupLayoutDescriptor, + draw_layout: BindGroupLayoutDescriptor, + scatter: CachedComputePipelineId, + finish: CachedComputePipelineId, + blades: Handle, + mesh_pipeline: MeshPipeline, + /// A vertex layout with no attributes: the blades have no vertex buffer. + empty_layout: MeshVertexBufferLayoutRef, + draw: HashMap, +} + +impl GrassPipelines { + /// The blade pipeline for a view: Bevy's mesh pipeline for the view's key + /// with our shaders and bind group in place of the mesh's. + fn draw_pipeline( + &mut self, + cache: &PipelineCache, + view_key: MeshPipelineKey, + ) -> Option { + let key = view_key + | MeshPipelineKey::from_primitive_topology_and_strip_index( + PrimitiveTopology::TriangleList, + None, + ); + if let Some(id) = self.draw.get(&key) { + return Some(*id); + } + let mut descriptor = self + .mesh_pipeline + .specialize(key, &self.empty_layout) + .ok()?; + descriptor.label = Some("grass_blades".into()); + descriptor.vertex.shader = self.blades.clone(); + descriptor.vertex.buffers.clear(); + if let Some(fragment) = &mut descriptor.fragment { + fragment.shader = self.blades.clone(); + } + // [view, view binding arrays, mesh] — the mesh group becomes ours. + descriptor.layout.truncate(2); + descriptor.layout.push(self.draw_layout.clone()); + // A blade is seen from both sides. + descriptor.primitive.cull_mode = None; + let id = cache.queue_render_pipeline(descriptor); + self.draw.insert(key, id); + Some(id) + } +} + +pub(super) fn plugin(app: &mut App) { + ground::plugin(app); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .init_resource::() + .add_render_command::() + .add_systems(RenderStartup, init.after(MeshPipelineSystems)) + .add_systems( + Render, + ( + queue.in_set(RenderSystems::QueueMeshes), + prepare + .in_set(RenderSystems::PrepareResources) + .after(ground::prepare), + ), + ) + .add_systems( + Core3d, + scatter_pass + .before(Core3dSystems::Prepass) + .after(ground::pass), + ); +} + +fn init( + mut commands: Commands, + device: Res, + queue: Res, + asset_server: Res, + pipeline_cache: Res, + mesh_pipeline: Res, +) { + let compute_layout = BindGroupLayoutDescriptor::new( + "grass_scatter", + &BindGroupLayoutEntries::sequential( + ShaderStages::COMPUTE, + ( + uniform_buffer::(false), + texture_2d(TextureSampleType::Float { filterable: false }), + storage_buffer_sized(false, None), + storage_buffer_sized(false, None), + ), + ), + ); + let draw_layout = BindGroupLayoutDescriptor::new( + "grass_blades", + &BindGroupLayoutEntries::sequential( + ShaderStages::VERTEX_FRAGMENT, + ( + uniform_buffer::(false), + storage_buffer_read_only_sized(false, None), + uniform_buffer::(false), + ), + ), + ); + let scatter_shader = asset_server.load("embedded://world_render/grass/scatter.wgsl"); + let scatter = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("grass_scatter".into()), + layout: vec![compute_layout.clone()], + shader: scatter_shader.clone(), + entry_point: Some("scatter".into()), + ..default() + }); + let finish = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("grass_finish".into()), + layout: vec![compute_layout.clone()], + shader: scatter_shader, + entry_point: Some("finish".into()), + ..default() + }); + + // One index list for all three levels, back to back. A blade of N + // segments has 2N + 1 vertices: two per row and the tip. + let mut indices = RawBufferVec::new(BufferUsages::INDEX); + let mut index_starts = [0u32; 3]; + for (lod, &segments) in SEGMENTS.iter().enumerate() { + index_starts[lod] = indices.len() as u32; + for row in 0..segments { + let base = 2 * row; + if row + 1 < segments { + for index in [base, base + 1, base + 2, base + 1, base + 3, base + 2] { + indices.push(index); + } + } else { + for index in [base, base + 1, 2 * segments] { + indices.push(index); + } + } + } + } + indices.write_buffer(&device, &queue); + + let instances = device.create_buffer(&BufferDescriptor { + label: Some("grass_instances"), + size: u64::from(CAPACITY.iter().sum::()) * BLADE_BYTES, + usage: BufferUsages::STORAGE, + mapped_at_creation: false, + }); + let indirect = device.create_buffer(&BufferDescriptor { + label: Some("grass_indirect"), + size: 3 * INDIRECT_STRIDE, + usage: BufferUsages::STORAGE | BufferUsages::INDIRECT | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let lods = (0..3) + .map(|lod| { + let mut buffer = UniformBuffer::from(LodInfo { + info: UVec4::new(SEGMENTS[lod], lod as u32, 0, 0), + }); + buffer.write_buffer(&device, &queue); + buffer + }) + .collect(); + + let mut layouts = MeshVertexBufferLayouts::default(); + let empty_layout = layouts.insert(MeshVertexBufferLayout::new( + Vec::new(), + VertexBufferLayout { + array_stride: 0, + step_mode: VertexStepMode::Vertex, + attributes: Vec::new(), + }, + )); + + commands.insert_resource(GrassPipelines { + compute_layout, + draw_layout, + scatter, + finish, + blades: asset_server.load("embedded://world_render/grass/blades.wgsl"), + mesh_pipeline: mesh_pipeline.clone(), + empty_layout, + draw: HashMap::default(), + }); + commands.insert_resource(GrassBuffers { + uniform: UniformBuffer::from(GrassUniform::default()), + lods, + instances, + indirect, + indices, + index_starts, + compute_bind_group: None, + draw_bind_groups: Vec::new(), + patches_per_side: 0, + ready: false, + }); +} + +/// Indirect draw arguments with nothing drawn: the scatter pass counts the +/// instances up from here. +fn reset_indirect(index_starts: &[u32; 3]) -> Vec { + let mut bytes = Vec::with_capacity(3 * INDIRECT_STRIDE as usize); + for (lod, &segments) in SEGMENTS.iter().enumerate() { + let args = [(2 * segments - 1) * 3, 0, index_starts[lod], 0, 0, 0, 0, 0]; + for value in args { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + bytes +} + +/// Writes the frame's uniform, clears the counters and, once, builds the +/// bind groups. +#[allow(clippy::too_many_arguments)] +fn prepare( + mut buffers: ResMut, + pipelines: Res, + pipeline_cache: Res, + ground: Res, + environment: Res, + views: Query<&ExtractedView, With>, + device: Res, + queue: Res, +) { + buffers.ready = false; + // Deep winter takes the meadow away: green blades over snow is the one + // thing worse than none. + if !environment.settings.enabled || environment.season.snow > 0.5 || !ground.valid { + return; + } + let Some(view) = views.iter().next() else { + return; + }; + let compiled = |id| { + matches!( + pipeline_cache.get_compute_pipeline_state(id), + CachedPipelineState::Ok(_) + ) + }; + if !compiled(pipelines.scatter) || !compiled(pipelines.finish) { + return; + } + + let eye = view.world_from_view.translation(); + let clip_from_world = view + .clip_from_world + .unwrap_or_else(|| view.clip_from_view * view.world_from_view.to_matrix().inverse()); + let frustum = ViewFrustum::from_clip_from_world(&clip_from_world); + let mut planes = [Vec4::ZERO; 6]; + for (plane, half_space) in planes.iter_mut().zip(frustum.half_spaces.iter()) { + *plane = half_space.normal_d(); + } + // The far plane is the range; an infinite projection has none anyway. + planes[5] = Vec4::new(0.0, 0.0, 0.0, 1.0); + + let settings = environment.settings; + let patches_per_side = ((2.0 * settings.range / PATCH).ceil() as u32 + 2).next_multiple_of(2); + let origin = (eye.xz() / PATCH).floor() - Vec2::splat(patches_per_side as f32 / 2.0); + let density = DENSITY_AT_CAMERA * settings.density; + let slots = ((density * PATCH * PATCH).ceil() as u32) + .next_multiple_of(WORKGROUP) + .max(WORKGROUP); + + buffers.uniform.set(GrassUniform { + frustum: planes, + camera: eye.extend(0.0), + ground: Vec4::new( + ground.centre.x, + ground.centre.z, + ground.half_extent, + 2.0 * ground.half_extent / TEXELS as f32, + ), + grid: Vec4::new(PATCH, patches_per_side as f32, origin.x, origin.y), + density: Vec4::new(density, DENSITY_FALLOFF, settings.range, slots as f32), + lods: Vec4::new(LOD_ENDS[0], LOD_ENDS[1], RANGE_FADE, 1.0), + look: Vec4::new(environment.height, 0.0, 0.0, 0.0), + season: Vec4::new( + environment.season.snow, + environment.season.autumn, + INVALID_HEIGHT, + 0.0, + ), + capacity: UVec4::new(CAPACITY[0], CAPACITY[1], CAPACITY[2], 0), + weather: environment.weather, + }); + buffers.uniform.write_buffer(&device, &queue); + queue.write_buffer(&buffers.indirect, 0, &reset_indirect(&buffers.index_starts)); + + if buffers.compute_bind_group.is_none() { + let Some(uniform) = buffers.uniform.binding() else { + return; + }; + let layout = pipeline_cache.get_bind_group_layout(&pipelines.compute_layout); + let compute = device.create_bind_group( + "grass_scatter", + &layout, + &BindGroupEntries::sequential(( + uniform.clone(), + &ground.color_view, + buffers.instances.as_entire_binding(), + buffers.indirect.as_entire_binding(), + )), + ); + let layout = pipeline_cache.get_bind_group_layout(&pipelines.draw_layout); + let mut offset = 0u64; + let mut draws = Vec::with_capacity(3); + for (lod, &capacity) in CAPACITY.iter().enumerate() { + let size = u64::from(capacity) * BLADE_BYTES; + let Some(info) = buffers.lods[lod].binding() else { + return; + }; + draws.push(device.create_bind_group( + "grass_blades", + &layout, + &BindGroupEntries::sequential(( + uniform.clone(), + BufferBinding { + buffer: &buffers.instances, + offset, + size: Some(size.try_into().expect("a level holds blades")), + }, + info, + )), + )); + offset += size; + } + buffers.compute_bind_group = Some(compute); + buffers.draw_bind_groups = draws; + } + buffers.patches_per_side = patches_per_side; + buffers.ready = true; +} + +/// Puts the meadow into the world view's opaque phase. +// A Bevy system takes its world access as parameters; the count says nothing here. +#[allow(clippy::too_many_arguments)] +fn queue( + draw_functions: Res>, + mut pipelines: ResMut, + pipeline_cache: Res, + mut phases: ResMut>, + view_keys: Res, + views: Query<&ExtractedView, With>, + renderer: Query<(Entity, &MainEntity), With>, + environment: Res, +) { + let draw_function = draw_functions.read().id::(); + for view in &views { + let Some(phase) = phases.get_mut(&view.retained_view_entity) else { + continue; + }; + for (entity, main_entity) in &renderer { + // The phase is retained: out with last frame's item, in with this + // frame's, so a changed view key or a switched-off meadow takes + // effect at once. + phase.remove(*main_entity); + if !environment.settings.enabled { + continue; + } + let Some(&view_key) = view_keys.get(&view.retained_view_entity) else { + continue; + }; + let Some(pipeline) = pipelines.draw_pipeline(&pipeline_cache, view_key) else { + continue; + }; + phase.add( + Opaque3dBatchSetKey { + draw_function, + pipeline, + material_bind_group_index: None, + lightmap_slab: None, + slabs: MeshSlabs::default(), + }, + Opaque3dBinKey { + asset_id: AssetId::::invalid().untyped(), + }, + (entity, *main_entity), + InputUniformIndex::default(), + BinnedRenderPhaseType::NonMesh, + ); + } + } +} + +/// The compute dispatch: one workgroup per patch, then the clamp. +fn scatter_pass( + view: ViewQuery<(), With>, + buffers: Res, + pipelines: Res, + pipeline_cache: Res, + mut ctx: RenderContext, +) { + let _ = view; + if !buffers.ready { + return; + } + let (Some(scatter), Some(finish), Some(bind_group)) = ( + pipeline_cache.get_compute_pipeline(pipelines.scatter), + pipeline_cache.get_compute_pipeline(pipelines.finish), + buffers.compute_bind_group.as_ref(), + ) else { + return; + }; + let mut pass = ctx + .command_encoder() + .begin_compute_pass(&ComputePassDescriptor { + label: Some("grass_scatter"), + timestamp_writes: None, + }); + pass.set_bind_group(0, bind_group, &[]); + pass.set_pipeline(scatter); + pass.dispatch_workgroups(buffers.patches_per_side, buffers.patches_per_side, 1); + pass.set_pipeline(finish); + pass.dispatch_workgroups(1, 1, 1); +} + +type DrawGrass = ( + SetItemPipeline, + SetMeshViewBindGroup<0>, + SetMeshViewBindingArrayBindGroup<1>, + DrawBlades, +); + +/// Three indirect draws, one per level of detail. +struct DrawBlades; + +impl RenderCommand

for DrawBlades { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = (); + + fn render<'w>( + _item: &P, + _view: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + buffers: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let buffers = buffers.into_inner(); + if !buffers.ready || buffers.draw_bind_groups.len() < 3 { + return RenderCommandResult::Skip; + } + let Some(indices) = buffers.indices.buffer() else { + return RenderCommandResult::Skip; + }; + pass.set_index_buffer(indices.slice(..), IndexFormat::Uint32); + for (lod, bind_group) in buffers.draw_bind_groups.iter().enumerate() { + pass.set_bind_group(2, bind_group, &[]); + pass.draw_indexed_indirect(&buffers.indirect, lod as u64 * INDIRECT_STRIDE); + } + RenderCommandResult::Success + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_blade_has_two_vertices_a_row_and_a_tip() { + // The index list of a level covers 2N + 1 vertices in 2N − 1 + // triangles, and every level's list starts where the last ended. + let mut count = 0; + for &segments in &SEGMENTS { + let triangles = 2 * segments - 1; + count += triangles * 3; + } + let bytes = reset_indirect(&[0, 27, 42]); + assert_eq!(bytes.len(), 3 * INDIRECT_STRIDE as usize); + let words: Vec = bytes + .chunks(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + assert_eq!(words[0], 27, "the fine level draws nine triangles"); + assert_eq!(words[1], 0, "nothing is drawn before the scatter counts"); + assert_eq!(words[8], 15); + assert_eq!( + words[10], 27, + "the middle level's indices follow the fine one's" + ); + assert_eq!(words[16], 3); + assert_eq!(count, 27 + 15 + 3); + } + + #[test] + fn the_instance_buffer_holds_every_level() { + let total: u32 = CAPACITY.iter().sum(); + assert!(u64::from(total) * BLADE_BYTES < 64 << 20, "under 64 MB"); + for capacity in CAPACITY { + // Each level's binding starts on a 256-byte boundary. + assert_eq!((u64::from(capacity) * BLADE_BYTES) % 256, 0); + } + } +} diff --git a/crates/world-render/src/grass/scatter.wgsl b/crates/world-render/src/grass/scatter.wgsl new file mode 100644 index 00000000..b432cea4 --- /dev/null +++ b/crates/world-render/src/grass/scatter.wgsl @@ -0,0 +1,336 @@ +// Grass scatter: lays out the meadow's blades for this frame. +// +// One workgroup per 4 m patch of ground around the camera. A patch that is +// out of the frustum or off the ground cache costs one test; a patch in view +// walks its blade slots, and each slot is a blade whenever its rank is below +// the density its distance asks for. Survivors go into one of three instance +// lists, one per level of detail, and the instance counts land straight in +// the indirect draw arguments. + +#import world_render::weather::Weather + +struct GrassUniform { + // World-space half spaces, normal xyz and distance w; the far plane is + // left open because the range is the far limit. + frustum: array, 6>, + // xyz = camera position in render space. + camera: vec4, + // xy = ground cache centre xz, z = half side [m], w = metres per texel. + ground: vec4, + // x = patch side [m], y = patches per side, zw = first patch's grid index. + grid: vec4, + // x = blades/m² at the camera, y = falloff distance [m], z = range [m], + // w = blade slots per patch. + density: vec4, + // x = end of the fine level [m], y = end of the middle level [m], + // z = width of the range fade [m], w = 1 while enabled. + lods: vec4, + // x = stand height [m], yzw = unused. + look: vec4, + // x = snow 0…1, y = autumn 0…1, z = the ground cache's "nothing drawn" + // sentinel, w = unused. + season: vec4, + // Instance capacity per level of detail. + capacity: vec4, + weather: Weather, +} + +// 32 bytes a blade. Everything but the foot is packed to 16 or 8 bits. +struct Blade { + pos: vec3, + // facing angle (turns), height / BLADE_MAX_HEIGHT + a: u32, + // width / BLADE_MAX_WIDTH, bend + b: u32, + // hue, light, dry, stiffness + c: u32, + // ground normal xz, snorm + d: u32, + // clump, phase + e: u32, +} + +struct IndirectArgs { + index_count: u32, + instance_count: atomic, + first_index: u32, + base_vertex: i32, + first_instance: u32, + pad0: u32, + pad1: u32, + pad2: u32, +} + +@group(0) @binding(0) var grass: GrassUniform; +@group(0) @binding(1) var ground: texture_2d; +@group(0) @binding(2) var blades: array; +@group(0) @binding(3) var indirect: array; + +const WORKGROUP: u32 = 64u; +const BLADE_MAX_HEIGHT: f32 = 0.6; +const BLADE_MAX_WIDTH: f32 = 0.1; +// Roberts' R2 sequence: any prefix of it is evenly spread, which is what +// lets a prefix be the thinned stand. +const R2: vec2 = vec2(0.7548776662466927, 0.5698402909980532); + +fn pcg(v: u32) -> u32 { + let state = v * 747796405u + 2891336453u; + let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + return (word >> 22u) ^ word; +} + +fn rand(seed: u32) -> f32 { + return f32(pcg(seed)) / 4294967296.0; +} + +fn hash2(p: vec2) -> f32 { + return rand(bitcast(p.x) * 0x8DA6B343u ^ bitcast(p.y) * 0xD8163841u); +} + +// Value noise 0…1 — the clumps of a meadow, patches of richer and drier +// grass a few metres across. +fn noise(p: vec2) -> f32 { + let i = vec2(floor(p)); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + let a = hash2(i); + let b = hash2(i + vec2(1, 0)); + let c = hash2(i + vec2(0, 1)); + let d = hash2(i + vec2(1, 1)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// --- Ground cache --------------------------------------------------------- + +fn texel_of(xz: vec2) -> vec2 { + let size = f32(textureDimensions(ground).x); + return ((xz - grass.ground.xy) / (2.0 * grass.ground.z) + 0.5) * size - 0.5; +} + +fn load(t: vec2) -> vec2 { + let dims = vec2(textureDimensions(ground)); + if any(t < vec2(0)) || any(t >= dims) { + return vec2(grass.season.z, 0.0); + } + return textureLoad(ground, t, 0).xy; +} + +fn written(sample: vec2) -> bool { + return sample.x > grass.season.z * 0.5; +} + +struct Ground { + height: f32, + mask: f32, + valid: bool, +} + +// Bilinear over the texels that were drawn; a texel nothing reached takes no +// part, so the edge of the drawn ground does not slope down to the sentinel. +fn sample_ground(xz: vec2) -> Ground { + let t = texel_of(xz); + let i = vec2(floor(t)); + let f = fract(t); + let s00 = load(i); + let s10 = load(i + vec2(1, 0)); + let s01 = load(i + vec2(0, 1)); + let s11 = load(i + vec2(1, 1)); + let w00 = select(0.0, (1.0 - f.x) * (1.0 - f.y), written(s00)); + let w10 = select(0.0, f.x * (1.0 - f.y), written(s10)); + let w01 = select(0.0, (1.0 - f.x) * f.y, written(s01)); + let w11 = select(0.0, f.x * f.y, written(s11)); + let sum = w00 + w10 + w01 + w11; + var out: Ground; + out.valid = sum > 1e-4; + if !out.valid { + return out; + } + let total = s00 * w00 + s10 * w10 + s01 * w01 + s11 * w11; + out.height = total.x / sum; + out.mask = total.y / sum; + return out; +} + +fn ground_normal(xz: vec2, height: f32) -> vec3 { + let step = grass.ground.w * 1.5; + let px = sample_ground(xz + vec2(step, 0.0)); + let nx = sample_ground(xz - vec2(step, 0.0)); + let pz = sample_ground(xz + vec2(0.0, step)); + let nz = sample_ground(xz - vec2(0.0, step)); + let hx0 = select(height, nx.height, nx.valid); + let hx1 = select(height, px.height, px.valid); + let hz0 = select(height, nz.height, nz.valid); + let hz1 = select(height, pz.height, pz.valid); + return normalize(vec3(hx0 - hx1, 2.0 * step, hz0 - hz1)); +} + +// --- Culling and thinning ------------------------------------------------- + +fn density_at(d: f32) -> f32 { + let q = d / grass.density.y; + let falloff = grass.density.x / (1.0 + q * q); + let fade = 1.0 - smoothstep(grass.density.z - grass.lods.z, grass.density.z, d); + return falloff * fade; +} + +fn sphere_visible(centre: vec3, radius: f32) -> bool { + for (var i = 0u; i < 5u; i++) { + let plane = grass.frustum[i]; + if dot(plane.xyz, centre) + plane.w < -radius { + return false; + } + } + return true; +} + +fn box_visible(lo: vec3, hi: vec3) -> bool { + for (var i = 0u; i < 5u; i++) { + let plane = grass.frustum[i]; + let corner = select(lo, hi, plane.xyz > vec3(0.0)); + if dot(plane.xyz, corner) + plane.w < 0.0 { + return false; + } + } + return true; +} + +@compute @workgroup_size(64) +fn scatter( + @builtin(workgroup_id) workgroup: vec3, + @builtin(local_invocation_index) local: u32, +) { + if grass.lods.w < 0.5 { + return; + } + let patch_side = grass.grid.x; + let cell = grass.grid.zw + vec2(workgroup.xy); + let origin = cell * patch_side; + + // The patch's height range, off five samples of the ground. A patch the + // cache holds nothing of grows nothing. + var lo = 1e9; + var hi = -1e9; + var any_valid = false; + for (var k = 0u; k < 5u; k++) { + var at = origin + vec2(0.5, 0.5) * patch_side; + if k > 0u { + let corner = vec2(f32(k & 1u), f32((k >> 1u) & 1u)); + at = origin + corner * patch_side; + } + let g = sample_ground(at); + if g.valid { + lo = min(lo, g.height); + hi = max(hi, g.height); + any_valid = true; + } + } + if !any_valid { + return; + } + let box_lo = vec3(origin.x, lo - 1.0, origin.y); + let box_hi = vec3(origin.x + patch_side, hi + BLADE_MAX_HEIGHT + 1.0, origin.y + patch_side); + if !box_visible(box_lo, box_hi) { + return; + } + + let camera = grass.camera.xyz; + let nearest = clamp(camera, box_lo, box_hi); + let d_min = distance(camera, nearest); + let slots = grass.density.w; + // Everything a blade of this patch can be is within the first `count` + // slots: a blade's own distance is at least the patch's nearest point. + let keep_patch = density_at(d_min) / grass.density.x; + let count = u32(ceil(keep_patch * slots)); + if count == 0u { + return; + } + + let patch_seed = pcg( + bitcast(i32(cell.x)) * 0x9E3779B9u ^ bitcast(i32(cell.y)) * 0x85EBCA6Bu, + ); + let offset = vec2(rand(patch_seed), rand(patch_seed ^ 0x68E31DA4u)); + let spacing = patch_side / sqrt(slots); + + for (var s = local; s < count; s += WORKGROUP) { + let rank = (f32(s) + 0.5) / slots; + let seed = pcg(patch_seed ^ (s * 0x2545F491u + 0x1B56C4E9u)); + // The R2 point, jittered by most of a spacing: the sequence alone has + // a lattice in it that a meadow does not. + let r2 = fract(offset + f32(s) * R2); + let jitter = (vec2(rand(seed), rand(seed ^ 0x1u)) - 0.5) * spacing * 0.9; + let xz = origin + r2 * patch_side + jitter; + let g = sample_ground(xz); + if !g.valid { + continue; + } + let pos = vec3(xz.x, g.height, xz.y); + let d = distance(camera, pos); + let keep = density_at(d) / grass.density.x; + if rank >= keep { + continue; + } + // The splat's edge, dithered: a verge thins into the gravel rather + // than stopping at a line. + if g.mask < 0.15 + 0.55 * rand(seed ^ 0x2u) { + continue; + } + + let clump = noise(xz * 0.35 + 3.7); + let base = grass.look.x; + var height = base * (0.55 + 0.9 * rand(seed ^ 0x3u)) * (0.75 + 0.5 * clump); + // A blade at the thinning threshold grows in instead of popping. + let edge = clamp((keep - rank) / (0.2 * keep), 0.0, 1.0); + height *= 0.55 + 0.45 * edge; + // The stand keeps its cover as it thins: what the missing blades + // hid, the remaining ones grow wide enough to hide. + let widen = clamp(inverseSqrt(max(keep, 1e-3)), 1.0, 3.5); + let width = (0.011 + 0.010 * rand(seed ^ 0x4u)) * (0.6 + 0.4 * height / base) * widen; + let facing = rand(seed ^ 0x5u); + let bend = 0.12 + 0.6 * rand(seed ^ 0x6u); + let hue = rand(seed ^ 0x7u); + let light = rand(seed ^ 0x8u); + let dry = clamp(noise(xz * 0.11 + 11.3) * 1.2 - 0.62 + grass.season.y * 0.7, 0.0, 1.0); + let stiffness = 0.4 + 0.6 * rand(seed ^ 0x9u); + + if !sphere_visible(pos + vec3(0.0, height * 0.5, 0.0), height * 0.8 + 0.1) { + continue; + } + + var lod = 2u; + if d < grass.lods.x { + lod = 0u; + } else if d < grass.lods.y { + lod = 1u; + } + let slot = atomicAdd(&indirect[lod].instance_count, 1u); + if slot >= grass.capacity[lod] { + continue; + } + var first = 0u; + if lod >= 1u { + first += grass.capacity.x; + } + if lod >= 2u { + first += grass.capacity.y; + } + + let normal = ground_normal(xz, g.height); + var blade: Blade; + blade.pos = pos; + blade.a = pack2x16unorm(vec2(facing, clamp(height / BLADE_MAX_HEIGHT, 0.0, 1.0))); + blade.b = pack2x16unorm(vec2(clamp(width / BLADE_MAX_WIDTH, 0.0, 1.0), bend)); + blade.c = pack4x8unorm(vec4(hue, light, dry, stiffness)); + blade.d = pack2x16snorm(normal.xz); + blade.e = pack2x16unorm(vec2(clump, rand(seed ^ 0xAu))); + blades[first + slot] = blade; + } +} + +// The counters run past the capacity when a list overflows; the draw must +// not. +@compute @workgroup_size(1) +fn finish() { + for (var lod = 0u; lod < 3u; lod++) { + atomicMin(&indirect[lod].instance_count, grass.capacity[lod]); + } +} diff --git a/crates/world-render/src/lib.rs b/crates/world-render/src/lib.rs index 2fc570d1..05f95796 100644 --- a/crates/world-render/src/lib.rs +++ b/crates/world-render/src/lib.rs @@ -35,6 +35,7 @@ pub mod buildings; pub mod clouds; pub mod conductors; pub mod farmland; +pub mod grass; pub mod mist; pub mod people; pub mod plants; @@ -52,16 +53,14 @@ pub use conductors::{ConductorMark, ConductorMaterial, ConductorMaterials, spawn pub use farmland::{ CropExt, CropParams, FieldDraw, FieldMaterial, FieldMaterials, FieldSurface, spawn_fields, }; +pub use grass::{GrassRenderSettings, GroundSurface}; pub use people::{ CYCLE_PACE, CYCLE_RATE, CharacterAssets, CharacterGraphs, Dressed, GAIT_FADE, Gait, PASSENGER_CULL, PERSON_CULL, Passengers, PeopleClock, Person, Stroller, WALKING_ABOVE, WalkwayHost, WalkwaysBound, bind_walkways, gait, move_strollers, person_bundle, play_gait, spawn_seated, spawn_strollers, }; -pub use plants::{ - FieldPlants, GrassMaterial, GrassParams, GrassRenderSettings, PlantMaterials, - update_field_plants, -}; +pub use plants::{FieldPlants, PlantMaterials, update_field_plants}; pub use roads::{RoadDraw, RoadMaterial, RoadMaterials, RoadSurfaceMark, spawn_roads}; pub use scatter::{ OBJECT_CULL, PendingTrees, Scattered, SceneryIndex, TREE_CULL, TreeModels, Wood, WorldCatalog, @@ -110,7 +109,7 @@ impl Plugin for WorldRenderPlugin { clouds::plugin, mist::plugin, precipitation::plugin, - plants::plugin, + grass::plugin, weather::plugin, windscreen::plugin, track::plugin, @@ -622,11 +621,9 @@ pub fn spawn_terrain_tile( MeshMaterial3d(material.clone()), Transform::from_translation(translation).with_rotation(rotation), anchored, - // The adaptive card/model system grows the default terrain's grass - // only near the world camera. Overlay surfaces are indexed as holes, - // so blades do not poke through fields, roads or water. - plants::TerrainGrass::new(tile), - plants::FieldPlants::default(), + // The meadow grass stands on this: the tile is drawn into the + // ground cache the GPU scatters the blades from (`grass`). + grass::GroundSurface::Terrain, )); scatter::spawn_scatter( &mut entity, diff --git a/crates/world-render/src/plants.rs b/crates/world-render/src/plants.rs index 50ad8968..4e5b9d52 100644 --- a/crates/world-render/src/plants.rs +++ b/crates/world-render/src/plants.rs @@ -1,4 +1,4 @@ -//! Camera-local standing vegetation: field crops and the default terrain grass. +//! The standing crop: plants on the fields (the field plan's deferred pass). //! //! The painted surface carries a field at any distance but one: close up a //! crop is not a colour on the ground but things standing on it. A maize @@ -7,9 +7,7 @@ //! field patch grows a crop on its own surface — **real plant models** where //! the camera is close enough to make out a leaf, and **painted cards** //! (quads standing on the patch mesh) under and between them, out to where -//! the paint alone is what a field is. The default meadow reuses the same -//! cell-based LOD system, clipped to the terrain's grass splat and kept out -//! of fields, roads, and water. +//! the paint alone is what a field is. //! //! Everything is a function of the surface mesh and the day: the patch's //! vertex colours carry each field's tint and its own week of the crop year, @@ -45,24 +43,15 @@ use std::collections::HashMap; use std::sync::Arc; -use bevy::asset::{AssetId, AssetPath, LoadState, RenderAssetUsages, embedded_asset}; +use bevy::asset::{AssetId, AssetPath, LoadState, RenderAssetUsages}; use bevy::camera::RenderTarget; use bevy::camera::visibility::VisibilityRange; use bevy::gltf::{Gltf, GltfMesh}; use bevy::image::Image; use bevy::light::NotShadowCaster; -use bevy::mesh::{MeshVertexAttribute, MeshVertexBufferLayoutRef}; -use bevy::pbr::{ - ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline, -}; use bevy::prelude::*; use bevy::render::mesh::{Indices, PrimitiveTopology, VertexAttributeValues}; -use bevy::render::render_resource::{ - AsBindGroup, Extent3d, RenderPipelineDescriptor, ShaderType, SpecializedMeshPipelineError, - TextureDimension, TextureFormat, VertexFormat, -}; -use bevy::shader::ShaderRef; -use content::TerrainTile; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use fields::CropClass; use fields::phenology::{self, Stage}; @@ -70,130 +59,9 @@ use crate::{ Season, TextureMips, WorldView, farmland::{FieldSurface, linear}, sky::Sky, - weather::{WeatherExt, WeatherMaterial, WeatherParams}, + weather::{WeatherExt, WeatherMaterial}, }; -/// Physically lit close grass with wind-deformed geometry. -pub type GrassMaterial = ExtendedMaterial; - -// Bevy's standard colour and UV attributes are fixed to 32-bit floats. Grass -// carries bounded values, so a private packed layout cuts each vertex from 56 -// to 36 bytes while positions and lighting normals remain full precision. -const ATTRIBUTE_GRASS_COLOR: MeshVertexAttribute = - MeshVertexAttribute::new("Grass_Color", 988_540_920, VertexFormat::Unorm8x4); -const ATTRIBUTE_GRASS_DATA: MeshVertexAttribute = - MeshVertexAttribute::new("Grass_Data", 988_540_921, VertexFormat::Unorm16x4); - -#[derive(Clone, Copy, Debug, PartialEq, ShaderType)] -pub struct GrassParams { - /// Close, near and far transition centres, followed by the hero-detail centre [m]. - pub bands: Vec4, - /// Half-width of those four smooth transitions [m]. - pub fades: Vec4, - /// x is one while grass is enabled. Kept as a vector for uniform alignment. - pub options: Vec4, -} - -impl Default for GrassParams { - fn default() -> Self { - Self { - bands: Vec4::new(CLOSE_END, NEAR_END, PLANT_CULL, 22.0), - fades: Vec4::new(12.0, 12.0, 12.0, 8.0), - options: Vec4::X, - } - } -} - -/// Live grass controls shared by streaming and the material. Keeping the same -/// values on both sides means lowering the range also releases the cell meshes -/// instead of merely hiding them in the shader. -#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)] -pub struct GrassRenderSettings { - pub params: GrassParams, -} - -impl GrassRenderSettings { - pub fn new(enabled: bool, bands: Vec4, fades: Vec4) -> Self { - Self { - params: GrassParams { - bands, - fades, - options: if enabled { Vec4::X } else { Vec4::ZERO }, - }, - } - } - - fn enabled(self) -> bool { - self.params.options.x > 0.5 - } -} - -#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)] -pub struct GrassExt { - #[uniform(100)] - pub weather: WeatherParams, - #[uniform(101)] - pub grass: GrassParams, -} - -impl MaterialExtension for GrassExt { - fn vertex_shader() -> ShaderRef { - "embedded://world_render/grass.wgsl".into() - } - - fn fragment_shader() -> ShaderRef { - "embedded://world_render/grass.wgsl".into() - } - - /// No prepass: the vertex layout below is the main pass's own packed - /// format, and the default prepass vertex shader cannot read it — the - /// pipeline fails validation at the first grass cell in range - /// (`pbr_prepass_pipeline`, location 7). A depth written from unmoved - /// positions would disagree with the wind-bent blades that are drawn - /// anyway, and the fragment discards blades by coverage, which a - /// depth-only pass would not — so the main pass writes its own depth. - fn enable_prepass() -> bool { - false - } - - /// No shadow map either: a centimetre blade in a metre shadow texel is - /// noise, not shade — the same reason the conductors stay out. The - /// spawned entities carry `NotShadowCaster` as well; this keeps any - /// future spawn without it from meeting the same broken pipeline. - fn enable_shadows() -> bool { - false - } - - fn specialize( - _pipeline: &MaterialExtensionPipeline, - descriptor: &mut RenderPipelineDescriptor, - layout: &MeshVertexBufferLayoutRef, - _key: MaterialExtensionKey, - ) -> Result<(), SpecializedMeshPipelineError> { - // Main pass only — prepass and shadows are off above, so this packed - // layout never meets the default depth vertex shader. - descriptor.vertex.buffers = vec![layout.0.get_layout(&[ - Mesh::ATTRIBUTE_POSITION.at_shader_location(0), - Mesh::ATTRIBUTE_NORMAL.at_shader_location(1), - ATTRIBUTE_GRASS_COLOR.at_shader_location(2), - ATTRIBUTE_GRASS_DATA.at_shader_location(3), - ])?]; - for define in ["VERTEX_UVS_A", "VERTEX_UVS_B", "VERTEX_COLORS"] { - descriptor.vertex.shader_defs.push(define.into()); - if let Some(fragment) = &mut descriptor.fragment { - fragment.shader_defs.push(define.into()); - } - } - Ok(()) - } -} - -pub(crate) fn plugin(app: &mut App) { - embedded_asset!(app, "grass.wgsl"); - app.init_resource::() - .add_plugins(MaterialPlugin::::default()); -} - /// Where the thickest level of the stand hands over to the next [m]. Inside /// this the crop is at its real spacing — a clump of wheat every 60 cm — and /// that is the only distance at which an eye can tell. @@ -239,9 +107,11 @@ const HERO_TRIANGLE_BUDGET: usize = 40_000; /// Height is baked into the geometry, and it moves slowly enough that a /// quarter metre is the step an eye catches. const HEIGHT_BUCKET: f32 = 0.25; -/// Work units available for plant construction in one frame. A close terrain -/// cell consumes all three because it expands to roughly a million vertices; -/// treating it like a far cell was the source of visible streaming stalls. +/// How many cells may be grown in one frame, over all patches. A cell of the +/// closest level is thousands of tufts and tens of thousands of vertices; +/// two to a frame keeps a train's approach ahead of the camera without the +/// frame noticing. The queue is worked nearest first, so two is two of the +/// right ones. const BUILD_BUDGET: usize = 3; /// One draw for a card, 0 … 1 — deterministic on every machine of a run. @@ -425,10 +295,6 @@ impl CropKey { /// pose a card or a model on it. #[derive(Debug, Clone, Copy)] struct Card { - /// Stable random identity, retained so the close grass mesh can grow - /// several different blades from one sampled tuft without storing every - /// blade in the CPU-side stand. - seed: u64, /// Foot point, in the tile's own frame. pos: Vec3, /// The ground's normal under the foot — the card leans with the field. @@ -989,7 +855,6 @@ fn grow( model: Option<&PlantModel>, stage: Stage, band: Band, - terrain: Option<&TerrainGrass>, ) -> Vec { // Deep winter takes the crop away: the weather uniform has whitened the // paint, and green tufts over snow is the one thing worse than none. @@ -1023,11 +888,7 @@ fn grow( let mut area = 0.0f32; let mut scratch = Vec::new(); let mut poly = Vec::new(); - let candidates: Box + '_> = match terrain { - Some(terrain) => Box::new(terrain.triangles(key).iter().copied()), - None => Box::new(0..tris.len()), - }; - for i in candidates { + for i in 0..tris.len() { let [ia, ib, ic] = tris.at(i); if ia.max(ib).max(ic) >= positions.len() { continue; @@ -1066,29 +927,12 @@ fn grow( // The factor that brings the cell to the cap — a plain ratio, not a // square root: spacing grows with the root of the count, the density // falls with the count. - // The PBR scan closes the ground between plants. Four evenly distributed - // batches per square metre. The base contributes 64 blades/m² and the - // immediate curved layer another 88, without carrying millions of - // alpha-tested quads into the far field. - let wanted_density = if terrain.is_some() { - 4.0 - } else { - density(crop) - }; - let stretch = ((area * wanted_density) / MAX_CARDS as f32).max(1.0); - let density = wanted_density / stretch; + let stretch = ((area * density(crop)) / MAX_CARDS as f32).max(1.0); + let density = density(crop) / stretch; // A cell only ever draws its own level and the coarser ones, and every // coarser level is this one thinned — so a far cell has no use for the // cards its close level would have kept, and does not pay for them. - // Terrain needs the complete deterministic sample in every resident - // band: its close and near meshes choose different numbers of blades per - // tuft, while the far card mesh applies its own coverage-preserving thin. - // Field crops keep their established CPU-side thinning. - let keep = if terrain.is_some() { - 256.0 - } else { - band.stand().0 * 256.0 - }; + let keep = band.stand().0 * 256.0; // The share of cards that stands as a real plant: the model's own // density over the cards', capped by the count the model's triangle cost @@ -1097,8 +941,7 @@ fn grow( Some(model) => { let by_count = MAX_HEROES.min(MIN_HEROES.max(HERO_TRIANGLE_BUDGET / model.tris.max(1))) as f32; - let wanted = model_of(crop, stage).map_or(0.0, |hero| hero.density) - * if terrain.is_some() { 0.5 } else { 1.0 }; + let wanted = model_of(crop, stage).map_or(0.0, |hero| hero.density); let cards = area * density; if cards > 0.0 { (wanted / density).min(by_count / cards).min(1.0) @@ -1135,69 +978,30 @@ fn grow( } for k in 0..count as u64 { let seed = i as u64 * 1_009 + k; - let (r, u, v) = if terrain.is_some() { - // Jittered strata give every part of a terrain triangle its - // share of the stand. Pure random sampling left clusters and - // holes; the previous additive sequence folded into long - // diagonal bands. Hash-jittering a small square lattice has - // neither failure mode. - let side = (count as f64).sqrt().ceil().max(1.0); - let x = (k as f64 % side + draw(seed, salt + 3)) / side; - let y = ((k as f64 / side).floor() + draw(seed, salt + 4)) / side; - (draw(seed, salt + 2), x, y) - } else { - ( - draw(seed, salt + 2), - draw(seed, salt + 3), - draw(seed, salt + 4), - ) - }; - let at = sample_polygon(poly, piece, r, u, v); - if terrain.is_some_and(|terrain| terrain.covered(key, at)) { - continue; - } + let at = sample_polygon( + poly, + piece, + draw(seed, salt + 2), + draw(seed, salt + 3), + draw(seed, salt + 4), + ); let Some(w) = barycentric(at, a.xz(), b.xz(), c.xz()) else { continue; }; - if terrain.is_some() { - // Interpolated splat weight is the exact decision the ground - // shader makes. Once grass is the meaningful surface here, - // keep the complete low-discrepancy stand: stochastic - // thinning turned smooth splat variation into conspicuous - // bare islands. Roads, water and fields are already removed - // by the geometric exclusion index above. - let grass = (colors[ia][0] * w.x + colors[ib][0] * w.y + colors[ic][0] * w.z) - .clamp(0.0, 1.0); - if grass < 0.12 { - continue; - } - } let pos = a * w.x + b * w.y + c * w.z; let up = (Vec3::from(normals[ia]) * w.x + Vec3::from(normals[ib]) * w.y + Vec3::from(normals[ic]) * w.z) .normalize_or_zero(); - let tint = if terrain.is_some() { - 0.5 - } else { - colors[ia][0] * w.x + colors[ib][0] * w.y + colors[ic][0] * w.z - }; - let week = if terrain.is_some() { - 0.5 - } else { - (colors[ia][2] * w.x + colors[ib][2] * w.y + colors[ic][2] * w.z).clamp(0.0, 1.0) - }; + let tint = colors[ia][0] * w.x + colors[ib][0] * w.y + colors[ic][0] * w.z; + let week = + (colors[ia][2] * w.x + colors[ib][2] * w.y + colors[ic][2] * w.z).clamp(0.0, 1.0); // The field's own week decides what the day is here — two wheat // fields in one patch ripen a week apart, and the cards on them // do too. `pick` is the card's own draw from that same field. let pick = draw(seed, salt + 5) as f32; - let mut growth = phenology::growth_offset(crop, today, (week * 2.0 - 1.0) * 7.0); - if terrain.is_some() && growth.stage == Stage::Stubble { - growth.stage = Stage::Growing; - growth.cover = 1.0; - growth.height = growth.height.max(0.12); - } + let growth = phenology::growth_offset(crop, today, (week * 2.0 - 1.0) * 7.0); // Nothing stands on ploughed ground. Stubble keeps a third of // the tufts, short; a thin stand thins with its cover. let (scale, kept) = match growth.stage { @@ -1212,17 +1016,9 @@ fn grow( if (rank as f32) >= keep { continue; } - let mut height = + let height = (growth.height * (0.7 + 0.5 * draw(seed, salt + 9) as f32) * scale).max(0.03); - if terrain.is_some() { - // A default verge is a close lawn layer, not a field of - // knee-high tussocks. A narrow range also makes the fade - // into the detailed ground material read as detail loss - // instead of vegetation suddenly losing volume. - height = height.clamp(0.12, 0.26); - } cards.push(Card { - seed: seed ^ salt, pos, up: if up.length_squared() > 0.5 { up @@ -1230,9 +1026,7 @@ fn grow( Vec3::Y }, yaw: (draw(seed, salt + 7) as f32 - 0.5) * std::f32::consts::TAU, - width: card_width(crop, height) - * (0.8 + 0.4 * draw(seed, salt + 6) as f32) - * if terrain.is_some() { 1.1 } else { 1.0 }, + width: card_width(crop, height) * (0.8 + 0.4 * draw(seed, salt + 6) as f32), height, lean: (draw(seed, salt + 10) as f32 - 0.5) * 0.35, tint, @@ -1373,13 +1167,6 @@ fn plan_area(p: [Vec3; 3]) -> f32 { #[derive(Resource, Default)] pub struct PlantMaterials { by_crop: HashMap>, - /// The default terrain meadow uses a full-width blade sheet instead of - /// the compact crop tuft, so overlapping cards form one continuous mat. - terrain: Option>, - /// Opaque, physically lit blade geometry for the default meadow's close - /// level. It deliberately has no cut-out texture: its silhouette is the - /// mesh itself, which is what removes the crossed-billboard look. - blades_by_crop: HashMap>, /// The cut-out sheets the cards are cut from, one per leaf shape, drawn /// on first use. sheets: HashMap>, @@ -1429,77 +1216,10 @@ impl PlantMaterials { .clone() } - /// The two-sided foliage material used by real blade geometry close to - /// the camera. Diffuse transmission gives a sunlit leaf its bright back - /// side without the cost and sorting problems of transparent blending. - pub fn blades( - &mut self, - crop: CropClass, - assets: &mut Assets, - month: u32, - day: u32, - grass: GrassParams, - ) -> Handle { - self.blades_by_crop - .entry(crop) - .or_insert_with(|| { - assets.add(GrassMaterial { - base: StandardMaterial { - base_color: stand_colour(phenology::growth(crop, month, day, 0)), - perceptual_roughness: 0.82, - reflectance: 0.22, - diffuse_transmission: 0.38, - thickness: 0.008, - double_sided: true, - cull_mode: None, - ..default() - }, - extension: GrassExt { grass, ..default() }, - }) - }) - .clone() - } - - pub fn terrain( - &mut self, - assets: &mut Assets, - images: &mut Assets, - month: u32, - day: u32, - ) -> Handle { - if let Some(material) = &self.terrain { - return material.clone(); - } - let sheet = self - .sheets - .entry(Leaf::Meadow) - .or_insert_with(|| images.add(card_sheet(Leaf::Meadow))) - .clone(); - let material = assets.add(WeatherMaterial { - base: StandardMaterial { - base_color: stand_colour(phenology::growth(CropClass::Grassland, month, day, 0)), - base_color_texture: Some(sheet), - alpha_mode: AlphaMode::Mask(CARD_CUTOFF), - cull_mode: None, - perceptual_roughness: 0.85, - ..default() - }, - extension: WeatherExt::default(), - }); - self.terrain = Some(material.clone()); - material - } - /// Writes the day's colour into every crop's material, if the day moved. /// The stage and the height are the meshes' business — a rebuild; the /// colour is the material's, and costs one write per crop. - pub fn set_date( - &mut self, - assets: &mut Assets, - blades: &mut Assets, - month: u32, - day: u32, - ) -> bool { + pub fn set_date(&mut self, assets: &mut Assets, month: u32, day: u32) -> bool { let today = phenology::day_of_year(month, day); if self.day == Some(today) { return false; @@ -1510,22 +1230,11 @@ impl PlantMaterials { material.base.base_color = stand_colour(phenology::growth(*crop, month, day, 0)); } } - for (crop, handle) in &self.blades_by_crop { - if let Some(mut material) = blades.get_mut(handle) { - material.base.base_color = stand_colour(phenology::growth(*crop, month, day, 0)); - } - } - if let Some(handle) = &self.terrain - && let Some(mut material) = assets.get_mut(handle) - { - material.base.base_color = - stand_colour(phenology::growth(CropClass::Grassland, month, day, 0)); - } true } pub fn is_empty(&self) -> bool { - self.by_crop.is_empty() && self.blades_by_crop.is_empty() && self.terrain.is_none() + self.by_crop.is_empty() } } @@ -1547,12 +1256,11 @@ pub fn follow_date( sky: Res, mut materials: ResMut, mut assets: ResMut>, - mut blades: ResMut>, ) { if materials.is_empty() { return; } - materials.set_date(&mut assets, &mut blades, sky.month, sky.day); + materials.set_date(&mut assets, sky.month, sky.day); } /// The standing crop of one field patch: the cells it is cut into, what they @@ -1574,121 +1282,6 @@ pub struct FieldPlants { grown: Option, } -/// The base terrain as a grass-bearing plant surface. -/// -/// Two compact spatial indexes are built while the streamed [`TerrainTile`] -/// is still available: terrain triangles worth sampling in each plant cell, -/// and the field/road/water triangles where default grass must leave a hole. -/// A cell build therefore scans a few dozen triangles instead of the whole -/// 512 metre tile and needs not retain the complete content tile. -#[derive(Component, Default)] -pub struct TerrainGrass { - triangles: HashMap>, - exclusions: HashMap>, -} - -impl TerrainGrass { - pub(crate) fn new(tile: &TerrainTile) -> Self { - let mut grass = Self::default(); - for (triangle, indices) in tile.indices.as_chunks::<3>().0.iter().enumerate() { - let [ia, ib, ic] = [ - indices[0] as usize, - indices[1] as usize, - indices[2] as usize, - ]; - if ia.max(ib).max(ic) >= tile.positions.len() || ia.max(ib).max(ic) >= tile.splat.len() - { - continue; - } - // No meaningful grass weight means no tuft. This also drops the - // vertical terrain skirt and the gravel formation up front. - let weight = (tile.splat[ia][0] + tile.splat[ib][0] + tile.splat[ic][0]) / 3.0; - if weight < 0.04 { - continue; - } - let points = [ - Vec3::from(tile.positions[ia]).xz(), - Vec3::from(tile.positions[ib]).xz(), - Vec3::from(tile.positions[ic]).xz(), - ]; - if triangle_area(points) <= 1e-4 { - continue; - } - for key in triangle_cells(points) { - grass.triangles.entry(key).or_default().push(triangle); - } - } - for patch in &tile.fields { - grass.add_exclusions(&patch.positions, &patch.indices); - } - for patch in &tile.roads { - grass.add_exclusions(&patch.positions, &patch.indices); - } - for patch in &tile.waters { - grass.add_exclusions(&patch.positions, &patch.indices); - } - grass - } - - fn add_exclusions(&mut self, positions: &[[f32; 3]], indices: &[u32]) { - for indices in indices.as_chunks::<3>().0 { - let [ia, ib, ic] = [ - indices[0] as usize, - indices[1] as usize, - indices[2] as usize, - ]; - if ia.max(ib).max(ic) >= positions.len() { - continue; - } - let points = [ - Vec3::from(positions[ia]).xz(), - Vec3::from(positions[ib]).xz(), - Vec3::from(positions[ic]).xz(), - ]; - if triangle_area(points) <= 1e-4 { - continue; - } - for key in triangle_cells(points) { - self.exclusions.entry(key).or_default().push(points); - } - } - } - - fn triangles(&self, key: IVec2) -> &[usize] { - self.triangles.get(&key).map(Vec::as_slice).unwrap_or(&[]) - } - - fn covered(&self, key: IVec2, point: Vec2) -> bool { - self.exclusions.get(&key).is_some_and(|tris| { - tris.iter() - .any(|triangle| point_in_triangle(point, *triangle)) - }) - } -} - -fn triangle_area(points: [Vec2; 3]) -> f32 { - (points[1] - points[0]) - .perp_dot(points[2] - points[0]) - .abs() - * 0.5 -} - -fn triangle_cells(points: [Vec2; 3]) -> impl Iterator { - let lo = cell_of(points[0].min(points[1]).min(points[2])); - let hi = cell_of(points[0].max(points[1]).max(points[2])); - (lo.x..=hi.x).flat_map(move |x| (lo.y..=hi.y).map(move |y| IVec2::new(x, y))) -} - -fn point_in_triangle(point: Vec2, triangle: [Vec2; 3]) -> bool { - let [a, b, c] = triangle; - let ab = (b - a).perp_dot(point - a); - let bc = (c - b).perp_dot(point - b); - let ca = (a - c).perp_dot(point - c); - let epsilon = 1e-4; - (ab >= -epsilon && bc >= -epsilon && ca >= -epsilon) - || (ab <= epsilon && bc <= epsilon && ca <= epsilon) -} - /// Takes one cell's meshes down: the entities go and their mesh assets with /// them — the geometry is the cell's alone, and leaving it in the asset store /// would pile a copy onto the GPU at every regrow. @@ -1748,43 +1341,12 @@ fn band_for(distance: f32, current: Option) -> Option { } } -/// Terrain meshes are prefetched one complete fade before their shader band -/// begins. The actual selection is per blade in `grass.wgsl`; these larger -/// bounds only decide which cell assets must be resident, never what is -/// visible. That distinction is what prevents the 32 m cell grid becoming a -/// visible LOD grid. -fn terrain_band_for(distance: f32, grass: GrassRenderSettings) -> Option { - if !grass.enabled() { - return None; - } - let bands = grass.params.bands; - let fades = grass.params.fades; - if distance < bands.x + fades.x { - Some(Band::Close) - } else if distance < bands.y + fades.y { - Some(Band::Near) - } else if distance < bands.z + fades.z { - Some(Band::Far) - } else { - None - } -} - /// How far a point is from a box, zero inside it. fn box_distance(p: Vec3, lo: Vec3, hi: Vec3) -> f32 { (lo - p).max(p - hi).max(Vec3::ZERO).length() } -/// Horizontal distance to a cell. Terrain grass is cached as a vertical -/// column around the camera: altitude changes only the shader LOD and never -/// tears down geometry that an immediate descent needs again. -fn planar_box_distance(p: Vec3, lo: Vec3, hi: Vec3) -> f32 { - let delta = (lo.xz() - p.xz()).max(p.xz() - hi.xz()).max(Vec2::ZERO); - delta.length() -} - -/// Grows, regrows and drops the standing crop of every field patch and the -/// default grass on streamed terrain. +/// Grows, regrows and drops the standing crop of every field patch. /// /// A patch measures itself once, then each of its cells grows when the camera /// comes near it and drops again when it leaves. A new day regrows what it @@ -1803,16 +1365,13 @@ pub fn update_field_plants( mut mips: ResMut, mut meshes: ResMut>, mut materials: ResMut>, - mut grass_materials: ResMut>, mut images: ResMut>, mut plants: ResMut, mut models: ResMut, - grass: Res, sky: Res, - mut surfaces: Query<( + mut fields: Query<( Entity, - Option<&FieldSurface>, - Option<&TerrainGrass>, + &FieldSurface, &Mesh3d, &GlobalTransform, &mut FieldPlants, @@ -1832,14 +1391,7 @@ pub fn update_field_plants( // costs nothing and so is not budgeted — and writes down what wants // growing, with the distance the queue is ordered on. let mut wanted: Vec<(f32, Entity, usize, Band)> = Vec::new(); - for (entity, field, terrain, mesh3d, at, mut state) in &mut surfaces { - let terrain = field.is_none().then_some(terrain).flatten(); - let Some(crop) = field - .map(|surface| surface.crop) - .or_else(|| terrain.map(|_| CropClass::Grassland)) - else { - continue; - }; + for (entity, surface, mesh3d, at, mut state) in &mut fields { // The patch's cells and its reach, measured once from its mesh: // everything after this is distance tests against boxes. if !state.surveyed { @@ -1850,13 +1402,8 @@ pub fn update_field_plants( if let Some(found) = survey(mesh) { state.centre = found.centre; state.radius = found.radius; - state.week = if terrain.is_some() { 0.5 } else { found.week }; + state.week = found.week; state.cells = found.cells; - if let Some(terrain) = terrain { - state - .cells - .retain(|cell| !terrain.triangles(cell.key).is_empty()); - } } } if state.cells.is_empty() { @@ -1865,11 +1412,7 @@ pub fn update_field_plants( // The camera in the patch's own frame, so a cell's box can be // measured to without a transform each. let eye_local = at.affine().inverse().transform_point3(eye); - let reach = if terrain.is_some() { - eye_local.xz().distance(state.centre.xz()) - state.radius - } else { - eye_local.distance(state.centre) - state.radius - }; + let reach = eye_local.distance(state.centre) - state.radius; // Out of sight: the meshes go, the patch keeps its cells. if reach > DEMATERIALISE_AT { clear(&mut commands, &mut state); @@ -1883,26 +1426,19 @@ pub fn update_field_plants( // patch grows as painted cards, and the missing flag in the key // brings it back here the frame the model lands. The stage picks the // model as well as the size — a cut field stands straw, not wheat. - let mut key = CropKey::of(crop, sky.month, sky.day, state.week, false); - // The crop calendar's grassland is cut three times a year. Default - // verge and meadow terrain is not one synchronised managed field, so - // it keeps the grass model and merely follows the seasonal height. - if terrain.is_some() && key.stage == Stage::Stubble { - key.stage = Stage::Growing; - } - key.heroes = terrain.is_none() - && models - .model( - crop, - key.stage, - &assets, - &gltfs, - &gltf_meshes, - &meshes, - &standards, - &mut mips, - ) - .is_some(); + let mut key = CropKey::of(surface.crop, sky.month, sky.day, state.week, false); + key.heroes = models + .model( + surface.crop, + key.stage, + &assets, + &gltfs, + &gltf_meshes, + &meshes, + &standards, + &mut mips, + ) + .is_some(); // Grown for the day already? The day's colour rode in with the // material; only stage, height, winter and a landed model rebuild. if state.grown != Some(key) { @@ -1912,16 +1448,8 @@ pub fn update_field_plants( for at in 0..state.cells.len() { let cell = &state.cells[at]; - let distance = if terrain.is_some() { - planar_box_distance(eye_local, cell.lo, cell.hi) - } else { - box_distance(eye_local, cell.lo, cell.hi) - }; - let want = if terrain.is_some() { - terrain_band_for(distance, *grass) - } else { - band_for(distance, cell.band) - }; + let distance = box_distance(eye_local, cell.lo, cell.hi); + let want = band_for(distance, cell.band); if want == cell.band { continue; } @@ -1935,64 +1463,45 @@ pub fn update_field_plants( if wanted.is_empty() { return; } - // Nearest first. Close terrain cells are over ten times heavier than the - // coarser levels, so count work rather than cells: three close rebuilds in - // one update caused a long main-thread spike whenever grass reappeared. - wanted.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); - let mut work = 0; - wanted.retain(|&(_, _, _, band)| { - let cost = if band == Band::Close { 3 } else { 1 }; - if work + cost > BUILD_BUDGET { - false - } else { - work += cost; - true - } - }); + // Nearest first, and only as many as one frame can afford. A cell is a + // couple of thousand cards; the rest of the queue is built over the next + // few frames, and the painted surface underneath is right the whole time. + if wanted.len() > BUILD_BUDGET { + wanted.select_nth_unstable_by(BUILD_BUDGET, |a, b| a.0.total_cmp(&b.0)); + wanted.truncate(BUILD_BUDGET); + } for (_, entity, at, band) in wanted { - let Ok((entity, field, terrain, mesh3d, _, mut state)) = surfaces.get_mut(entity) else { - continue; - }; - let terrain = field.is_none().then_some(terrain).flatten(); - let Some(crop) = field - .map(|surface| surface.crop) - .or_else(|| terrain.map(|_| CropClass::Grassland)) - else { + let Ok((entity, surface, mesh3d, _, mut state)) = fields.get_mut(entity) else { continue; }; let Some(key) = state.grown else { continue; }; drop_cell(&mut commands, &mut state.cells[at]); - let model = if terrain.is_some() { - None - } else { - models.model( - crop, - key.stage, - &assets, - &gltfs, - &gltf_meshes, - &meshes, - &standards, - &mut mips, - ) - }; + let model = models.model( + surface.crop, + key.stage, + &assets, + &gltfs, + &gltf_meshes, + &meshes, + &standards, + &mut mips, + ); let cards = { let Some(surface_mesh) = meshes.get(&mesh3d.0) else { continue; }; grow( surface_mesh, - crop, + surface.crop, sky.month, sky.day, state.cells[at].key, model.as_deref(), key.stage, band, - terrain, ) }; // The cell has had its turn either way: an empty one is a cell of @@ -2005,21 +1514,20 @@ pub fn update_field_plants( // The painted cards' colour is the crop's stand colour, per day; the // real plants repaint themselves from it in their own vertices. - let growth = phenology::growth(crop, sky.month, sky.day, 0); + let growth = phenology::growth(surface.crop, sky.month, sky.day, 0); let stand = [ linear(growth.color[0]), linear(growth.color[1]), linear(growth.color[2]), ]; - let material = if terrain.is_some() { - plants.terrain(&mut materials, &mut images, sky.month, sky.day) - } else { - plants.get(crop, &mut materials, &mut images, sky.month, sky.day) - }; - let blade_material = terrain - .is_some() - .then(|| plants.blades(crop, &mut grass_materials, sky.month, sky.day, grass.params)); - let sink = model_of(crop, key.stage).map_or(0.0, |hero| hero.sink); + let material = plants.get( + surface.crop, + &mut materials, + &mut images, + sky.month, + sky.day, + ); + let sink = model_of(surface.crop, key.stage).map_or(0.0, |hero| hero.sink); // One dressed material per part of the model. Both this and the // model above are cached lookups after the first cell of a crop. let skins: Vec>> = model @@ -2035,441 +1543,67 @@ pub fn update_field_plants( let mut spawned = Vec::new(); commands.entity(entity).with_children(|parent| { - if terrain.is_some() { - // Three nested levels use the same actual tapered geometry, - // only fewer blades. Their UVs carry the LOD class; the grass - // shader selects them against the live camera per blade, not - // against this cell entity. - if let Some(blade_material) = &blade_material { - let mut combined = None; - for level in band.upwards() { - let detail = blade_mesh(&cards, level); - if detail.count_vertices() == 0 { + // The cell's own level and every coarser one. Carrying the coarse + // ones as well is what makes the hand-over happen at the distance + // the visibility range names rather than wherever the cell's own + // residency hysteresis happened to let go. + for level in band.upwards() { + let (start, end) = level.range(); + // A cell only ever draws from its own level outwards, so the + // nearest one it carries starts where the camera is. + let start = if level == band { 0.0 } else { start }; + if level.crossed() + && let Some(model) = &model + { + // The real plants, one mesh per material part. They cast + // shadows and the cards do not: a maize field without one + // is a green carpet, and a shadow off a card is a shadow + // off a rectangle. + for (skin, dressed) in skins.iter().enumerate() { + let Some(dressed) = dressed else { + continue; + }; + let mesh = hero_mesh(model, skin, &cards, level, stand, sink); + if mesh.count_vertices() == 0 { continue; } - merge_grass_mesh(&mut combined, detail); - - // Only the innermost level receives the segmented - // hero blades. They add curvature, varied leaf tips - // and another eighty-eight silhouettes per square metre at - // the camera, then disappear before the first normal - // LOD hand-over begins. - if level == Band::Close { - let hero_detail = curved_blade_mesh(&cards); - if hero_detail.count_vertices() > 0 { - merge_grass_mesh(&mut combined, hero_detail); - } - } - } - // All levels have identical vertex layouts and one material. - // Combining them preserves every blade while turning as many as - // four submissions per terrain cell into one draw call. - if let Some(combined) = combined { - let handle = meshes.add(combined); - let id = parent - .spawn(( - Mesh3d(handle.clone()), - MeshMaterial3d(blade_material.clone()), - Transform::IDENTITY, - NotShadowCaster, - )) - .id(); - spawned.push((id, handle.id())); + let handle = meshes.add(mesh); + spawned.push(( + parent + .spawn(( + Mesh3d(handle.clone()), + MeshMaterial3d(dressed.clone()), + Transform::IDENTITY, + range(start, end), + )) + .id(), + handle.id(), + )); } } - } else { - // Field crops retain their specialised three-level stand. - for level in band.upwards() { - let (start, end) = level.range(); - let start = if level == band { 0.0 } else { start }; - if level.crossed() - && let Some(model) = &model - { - for (skin, dressed) in skins.iter().enumerate() { - let Some(dressed) = dressed else { - continue; - }; - let mesh = hero_mesh(model, skin, &cards, level, stand, sink); - if mesh.count_vertices() == 0 { - continue; - } - let handle = meshes.add(mesh); - spawned.push(( - parent - .spawn(( - Mesh3d(handle.clone()), - MeshMaterial3d(dressed.clone()), - Transform::IDENTITY, - range(start, end), - )) - .id(), - handle.id(), - )); - } - } - let mesh = card_mesh(&cards, level, false); - if mesh.count_vertices() == 0 { - continue; - } - let handle = meshes.add(mesh); - spawned.push(( - parent - .spawn(( - Mesh3d(handle.clone()), - MeshMaterial3d(material.clone()), - Transform::IDENTITY, - range(start, end), - NotShadowCaster, - )) - .id(), - handle.id(), - )); + let mesh = card_mesh(&cards, level); + if mesh.count_vertices() == 0 { + continue; } + let handle = meshes.add(mesh); + spawned.push(( + parent + .spawn(( + Mesh3d(handle.clone()), + MeshMaterial3d(material.clone()), + Transform::IDENTITY, + range(start, end), + NotShadowCaster, + )) + .id(), + handle.id(), + )); } }); state.cells[at].lods = spawned; } } -fn merge_grass_mesh(combined: &mut Option, mesh: Mesh) { - if let Some(combined) = combined { - // These meshes are all triangle lists with the same five attributes. - // A mismatch is a programming error, not content that can vary at run time. - combined - .merge(&mesh) - .expect("grass LOD meshes must have matching vertex layouts"); - } else { - *combined = Some(mesh); - } -} - -/// The default meadow's close LOD as actual tapered, curved blade geometry. -/// -/// Each scattered close sample expands into sixteen real tapered blades. -/// The closest level has a curved middle joint while the smaller distance -/// levels collapse to one pointed triangle. The wind shader pins every foot -/// and bends every tip. Distributing the feet across the sample's neighbourhood -/// makes one continuous sward instead of isolated radial tufts. -const GRASS_COLOR_RANGE: f32 = 1.5; - -fn grass_color(value: [f32; 4]) -> [u8; 4] { - let pack = |channel: f32| { - ((channel.clamp(0.0, GRASS_COLOR_RANGE) / GRASS_COLOR_RANGE) * 255.0).round() as u8 - }; - [ - pack(value[0]), - pack(value[1]), - pack(value[2]), - (value[3].clamp(0.0, 1.0) * 255.0).round() as u8, - ] -} - -fn grass_uv(lod: f32, random: f32) -> [u16; 2] { - [ - ((lod / 3.0).clamp(0.0, 1.0) * 65_535.0).round() as u16, - (random.clamp(0.0, 1.0) * 65_535.0).round() as u16, - ] -} - -fn leaf_uv(value: [f32; 2]) -> [u16; 2] { - [ - (value[0].clamp(0.0, 1.0) * 65_535.0).round() as u16, - (value[1].clamp(0.0, 1.0) * 65_535.0).round() as u16, - ] -} - -fn grass_data(uvs: Vec<[u16; 2]>, leaf_uvs: Vec<[u16; 2]>) -> VertexAttributeValues { - debug_assert_eq!(uvs.len(), leaf_uvs.len()); - VertexAttributeValues::Unorm16x4( - uvs.into_iter() - .zip(leaf_uvs) - .map(|([lod, random], [across, along])| [lod, random, across, along]) - .collect(), - ) -} - -fn blade_mesh(cards: &[Card], band: Band) -> Mesh { - let (blades_per_tuft, width_scale, spread_floor) = match band { - Band::Close => (16u64, 0.72, 0.40), - Band::Near => (7u64, 1.55, 0.38), - Band::Far => (1u64, 2.35, 0.24), - }; - let lod_code = match band { - Band::Close => 0.0, - Band::Near => 1.0, - Band::Far => 2.0, - }; - let mut positions = Vec::new(); - let mut normals = Vec::new(); - let mut colors = Vec::new(); - let mut uvs = Vec::new(); - let mut leaf_uvs = Vec::new(); - let mut indices = Vec::new(); - - for card in cards.iter().filter(|card| !card.hero) { - let up = if card.up.length_squared() > 0.5 { - card.up.normalize() - } else { - Vec3::Y - }; - let tangent = if up.y.abs() < 0.9 { - up.cross(Vec3::Y).normalize() - } else { - up.cross(Vec3::X).normalize() - }; - let bitangent = up.cross(tangent).normalize(); - - for blade in 0..blades_per_tuft { - let random = |salt: u64| draw(card.seed ^ blade.wrapping_mul(0x9E37), salt) as f32; - let angle = card.yaw + blade as f32 * 2.399_963_1 + (random(41) - 0.5) * 0.7; - let (sin, cos) = angle.sin_cos(); - let lateral = (tangent * cos + bitangent * sin).normalize(); - let facing = up.cross(lateral).normalize(); - - // Blades start across the whole tussock instead of sharing one - // pinched foot, which removes the evenly spaced "star" pattern. - let foot_angle = card.yaw + blade as f32 * 2.399_963_1 + (random(42) - 0.5) * 0.35; - let (foot_sin, foot_cos) = foot_angle.sin_cos(); - // The sampling point is only the deterministic centre of this - // batch, not a visible tussock. Overlap neighbouring batches so - // the close meadow becomes continuous instead of a field of - // isolated stars. - let spread = (card.width * 0.6).max(spread_floor); - let radial_rank = (blade as f32 + 0.5 + random(43) * 0.2) / blades_per_tuft as f32; - let foot_radius = radial_rank.min(1.0).sqrt() * spread; - let foot = - card.pos + (tangent * foot_cos + bitangent * foot_sin) * foot_radius + up * 0.004; - - let short_leaf = blade % 6 == 0; - let height = - card.height * (0.58 + random(44) * 0.52) * if short_leaf { 0.68 } else { 1.0 }; - let half_width = (0.0045 + height * 0.017) - * (0.72 + random(45) * 0.56) - * width_scale - * if short_leaf { 1.45 } else { 1.0 }; - let curve = height * (0.045 + random(46) * 0.15 + card.lean * 0.32).clamp(-0.08, 0.22); - let p0 = foot; - let tip = foot + up * height + facing * curve; - let width0 = lateral * half_width; - - // Mostly face the blade, but lift the authored normal enough for - // skylight to keep the lower meadow readable. The two-sided PBR - // material flips it correctly on the back face. - let normal = (facing * 0.9 + up * 0.34).normalize(); - let hue = (random(47) - 0.5) * 0.2; - let light = 0.88 + random(48) * 0.22; - let shade = |value: f32, bend: f32| { - let value = value * light; - [value * (1.0 + hue), value, value * (1.0 - hue * 0.7), bend] - }; - let base = positions.len() as u32; - if band == Band::Close { - let middle = foot + up * (height * 0.60) + facing * (curve * 0.30); - let middle_width = lateral * (half_width * 0.52); - positions.extend_from_slice(&[ - (p0 - width0).to_array(), - (p0 + width0).to_array(), - (middle - middle_width).to_array(), - (middle + middle_width).to_array(), - tip.to_array(), - ]); - normals.extend_from_slice(&[[normal.x, normal.y, normal.z]; 5]); - colors.extend_from_slice( - &[ - shade(0.62, 0.0), - shade(0.62, 0.0), - shade(0.94, 0.58), - shade(0.94, 0.58), - shade(1.24, 1.0), - ] - .map(grass_color), - ); - uvs.extend_from_slice(&[grass_uv(lod_code, random(49)); 5]); - leaf_uvs.extend_from_slice( - &[ - [0.0, 0.0], - [1.0, 0.0], - [0.24, 0.60], - [0.76, 0.60], - [0.5, 1.0], - ] - .map(leaf_uv), - ); - indices.extend_from_slice(&[ - base, - base + 1, - base + 3, - base, - base + 3, - base + 2, - base + 2, - base + 3, - base + 4, - ]); - } else { - positions.extend_from_slice(&[ - (p0 - width0).to_array(), - (p0 + width0).to_array(), - tip.to_array(), - ]); - normals.extend_from_slice(&[[normal.x, normal.y, normal.z]; 3]); - colors.extend_from_slice( - &[shade(0.66, 0.0), shade(0.66, 0.0), shade(1.24, 1.0)].map(grass_color), - ); - uvs.extend_from_slice(&[grass_uv(lod_code, random(49)); 3]); - leaf_uvs.extend_from_slice(&[[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]].map(leaf_uv)); - indices.extend_from_slice(&[base, base + 1, base + 2]); - } - } - } - - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); - mesh.insert_attribute( - ATTRIBUTE_GRASS_COLOR, - VertexAttributeValues::Unorm8x4(colors), - ); - mesh.insert_attribute(ATTRIBUTE_GRASS_DATA, grass_data(uvs, leaf_uvs)); - mesh.insert_indices(Indices::U32(indices)); - mesh -} - -/// Segmented hero blades for the camera's immediate surroundings. -/// -/// The broad meadow still comes from [`blade_mesh`]. These thirty extra leaves -/// per sample are deliberately a little longer and carry a curved middle -/// joint, so the nearest grass has overlapping depth, bent tips and a more -/// organic wind silhouette without paying that geometry across the world. -fn curved_blade_mesh(cards: &[Card]) -> Mesh { - // This layer exists only in the camera's immediate radial neighbourhood. - // Thirty leaves per sample bring the local sward to roughly 184 individual - // blades/m²; the coarse world never pays for them. - const BLADES: u64 = 30; - let mut positions = Vec::new(); - let mut normals = Vec::new(); - let mut colors = Vec::new(); - let mut uvs = Vec::new(); - let mut leaf_uvs = Vec::new(); - let mut indices = Vec::new(); - - for card in cards.iter().filter(|card| !card.hero) { - let up = if card.up.length_squared() > 0.5 { - card.up.normalize() - } else { - Vec3::Y - }; - let tangent = if up.y.abs() < 0.9 { - up.cross(Vec3::Y).normalize() - } else { - up.cross(Vec3::X).normalize() - }; - let bitangent = up.cross(tangent).normalize(); - - for blade in 0..BLADES { - let random = |salt: u64| { - draw( - card.seed ^ 0xA24B_AED4_963E_E407 ^ blade.wrapping_mul(0x9E37), - salt, - ) as f32 - }; - let angle = card.yaw + blade as f32 * 2.399_963_1 + (random(61) - 0.5) * 0.55; - let (sin, cos) = angle.sin_cos(); - let lateral = (tangent * cos + bitangent * sin).normalize(); - let facing = up.cross(lateral).normalize(); - - // A stratified disc overlaps neighbouring batches but never - // pinches all leaves into the same visible star-shaped root. - let foot_angle = angle + (random(62) - 0.5) * 0.7; - let (foot_sin, foot_cos) = foot_angle.sin_cos(); - let radius = ((blade as f32 + 0.35 + random(63) * 0.3) / BLADES as f32).sqrt() * 0.34; - let foot = card.pos + (tangent * foot_cos + bitangent * foot_sin) * radius + up * 0.006; - - let short = blade % 4 == 0; - let height = - (card.height * (0.72 + random(64) * 0.58) * if short { 0.64 } else { 1.0 }) - .clamp(0.09, 0.34); - let half_width = (0.004 + height * 0.014) - * (0.76 + random(65) * 0.48) - * if short { 1.25 } else { 1.0 }; - let curve = height * (0.07 + random(66) * 0.19 + card.lean.abs() * 0.22); - let middle = foot + up * (height * 0.60) + facing * (curve * 0.30); - let tip = foot + up * height + facing * curve; - let root_width = lateral * half_width; - let middle_width = lateral * (half_width * 0.52); - - let normal = (facing * 0.88 + up * 0.38).normalize(); - let hue = (random(67) - 0.5) * 0.24; - let light = 0.90 + random(68) * 0.24; - let shade = |value: f32, bend: f32| { - let value = value * light; - [value * (1.0 + hue), value, value * (1.0 - hue * 0.7), bend] - }; - - let base = positions.len() as u32; - positions.extend_from_slice(&[ - (foot - root_width).to_array(), - (foot + root_width).to_array(), - (middle - middle_width).to_array(), - (middle + middle_width).to_array(), - tip.to_array(), - ]); - normals.extend_from_slice(&[[normal.x, normal.y, normal.z]; 5]); - colors.extend_from_slice( - &[ - shade(0.58, 0.0), - shade(0.58, 0.0), - shade(0.92, 0.58), - shade(0.92, 0.58), - shade(1.28, 1.0), - ] - .map(grass_color), - ); - // x = exact radial shader LOD (3 is immediate detail), y = one - // stable random threshold shared by every vertex of this leaf. - uvs.extend_from_slice(&[grass_uv(3.0, random(69)); 5]); - leaf_uvs.extend_from_slice( - &[ - [0.0, 0.0], - [1.0, 0.0], - [0.24, 0.60], - [0.76, 0.60], - [0.5, 1.0], - ] - .map(leaf_uv), - ); - indices.extend_from_slice(&[ - base, - base + 1, - base + 3, - base, - base + 3, - base + 2, - base + 2, - base + 3, - base + 4, - ]); - } - } - - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); - mesh.insert_attribute( - ATTRIBUTE_GRASS_COLOR, - VertexAttributeValues::Unorm8x4(colors), - ); - mesh.insert_attribute(ATTRIBUTE_GRASS_DATA, grass_data(uvs, leaf_uvs)); - mesh.insert_indices(Indices::U32(indices)); - mesh -} - /// One card as quads: two crossed at a right angle for the near level, one /// wider one for the far level, where half the cards are kept. /// @@ -2490,13 +1624,9 @@ fn curved_blade_mesh(cards: &[Card]) -> Mesh { /// the material is drawn from both sides *without* `double_sided`, so the far /// side of a card keeps this normal instead of its negation — a card seen /// from behind is the same plant in the same light, not a hole in the field. -fn card_mesh(cards: &[Card], band: Band, terrain: bool) -> Mesh { - let (_, field_width) = band.stand(); - // Far terrain uses many modest overlapping tufts instead of a handful of - // enormous crosses. Their projected coverage matches the middle blade - // LOD, so the hand-over changes detail rather than turning grass off. - let wider = if terrain { 4.2 } else { field_width }; - let crossed = terrain || band.crossed(); +fn card_mesh(cards: &[Card], band: Band) -> Mesh { + let (_, wider) = band.stand(); + let crossed = band.crossed(); let mut positions = Vec::new(); let mut normals = Vec::new(); let mut colors = Vec::new(); @@ -2504,8 +1634,7 @@ fn card_mesh(cards: &[Card], band: Band, terrain: bool) -> Mesh { let mut indices = Vec::new(); for card in cards { - let kept = if terrain { true } else { band.keeps(card) }; - if !kept { + if !band.keeps(card) { continue; } // A real plant stands here at the levels that draw them, and a quad @@ -2707,8 +1836,6 @@ const SHEET_TUFT_H: usize = 224; /// them apart at the distance where the real models have gone. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Leaf { - /// Dense, full-width fine blades for the invariant default-terrain mat. - Meadow, /// Thin, stiff, upright, a third of them carrying an ear — cereal, /// grass, whatever a meadow is made of. Blade, @@ -2756,7 +1883,6 @@ fn card_sheet(leaf: Leaf) -> Image { // widths `card_width` gives, a blade comes out 6 to 14 mm across and a // maize leaf 50 to 105 — which is what they measure in a field. let (count, spread, thick, taper, arch, ears) = match leaf { - Leaf::Meadow => (58u64, 0.48, (0.65f32, 1.35f32), 0.78f32, 0.72f32, 0.0), Leaf::Blade => (26u64, 0.34, (0.8f32, 1.9f32), 0.8f32, 0.55f32, 0.35), Leaf::Broad => (13, 0.42, (2.4, 5.0), 1.3, 1.0, 0.0), Leaf::Rosette => (9, 0.44, (6.5, 12.0), 1.7, 1.7, 0.0), @@ -2877,21 +2003,9 @@ fn stamp( /// cell's cards may stand five hundred metres from the tile's origin, and the /// origin is what a range without `use_aabb` measures to. fn range(start: f32, end: f32) -> VisibilityRange { - const FADE: f32 = 12.0; - range_with_fade(start, end, FADE) -} - -fn range_with_fade(start: f32, end: f32, fade: f32) -> VisibilityRange { VisibilityRange { - // The two neighbouring meshes overlap through complementary screen- - // door dithering. A whole meadow no longer pops between real blades - // and cards on one frame as the camera crosses a round number. - start_margin: if start > 0.0 { - (start - fade)..(start + fade) - } else { - 0.0..0.0 - }, - end_margin: (end - fade)..(end + fade), + start_margin: start..start, + end_margin: end..end, use_aabb: true, } } @@ -2946,30 +2060,6 @@ mod tests { mesh.indices().map(Indices::len).unwrap_or(0) } - /// The blade material stays out of the prepass and the shadow map: its - /// `specialize` overwrites the vertex buffers with the main pass's own - /// packed layout, which the default depth vertex shader cannot read — - /// the first grass cell in range ended the run in - /// `pbr_prepass_pipeline` validation instead. The wind-bent blades - /// would disagree with an unmoved depth anyway, and a centimetre blade - /// is noise in a shadow texel. - #[test] - fn blades_stay_out_of_prepass_and_shadows() { - assert!(!GrassExt::enable_prepass()); - assert!(!GrassExt::enable_shadows()); - } - - #[test] - fn overlay_holes_accept_both_windings_and_reject_the_outside() { - let clockwise = [Vec2::ZERO, Vec2::Y, Vec2::ONE]; - let anticlockwise = [Vec2::ZERO, Vec2::ONE, Vec2::Y]; - for triangle in [clockwise, anticlockwise] { - assert!(point_in_triangle(Vec2::new(0.25, 0.5), triangle)); - assert!(point_in_triangle(Vec2::new(0.0, 0.5), triangle)); - assert!(!point_in_triangle(Vec2::new(0.75, 0.25), triangle)); - } - } - /// One square of ground, `size` metres on a side, as a patch's mesh — /// two triangles, flat, with the vertex colours a field piece carries. fn patch(size: f32) -> Mesh { @@ -3006,19 +2096,7 @@ mod tests { survey .cells .iter() - .flat_map(|cell| { - grow( - mesh, - crop, - month, - day, - cell.key, - None, - stage, - Band::Close, - None, - ) - }) + .flat_map(|cell| grow(mesh, crop, month, day, cell.key, None, stage, Band::Close)) .collect() } @@ -3067,7 +2145,6 @@ mod tests { None, Stage::Ripe, Band::Close, - None, ); assert!( cards.len() <= MAX_CARDS, @@ -3096,7 +2173,6 @@ mod tests { None, Stage::Flowering, Band::Close, - None, ) { let at = card.pos.xz(); assert!( @@ -3127,7 +2203,7 @@ mod tests { // Two quads a card near, one far, and none where a model stands // except at the level that has no models. let heroes = cards.iter().filter(|c| c.hero).count(); - let quads = |band: Band| index_count(&card_mesh(&cards, band, false)) / 6; + let quads = |band: Band| index_count(&card_mesh(&cards, band)) / 6; let kept = |band: Band| cards.iter().filter(|c| band.keeps(c)).count(); assert_eq!(quads(Band::Close), (kept(Band::Close) - heroes) * 2); assert_eq!(quads(Band::Far), kept(Band::Far)); @@ -3175,7 +2251,7 @@ mod tests { #[test] fn a_card_is_two_quads_with_a_base_to_head_gradient() { let cards = grow_all(&patch(40.0), CropClass::WinterCereal, 7, 15); - let mesh = card_mesh(&cards, Band::Close, false); + let mesh = card_mesh(&cards, Band::Close); let positions = mesh .attribute(Mesh::ATTRIBUTE_POSITION) .unwrap() @@ -3227,7 +2303,6 @@ mod tests { fn card_at(height: f32) -> Card { Card { - seed: 17, pos: Vec3::new(10.0, 0.0, -5.0), up: Vec3::Y, yaw: 0.0, @@ -3292,44 +2367,6 @@ mod tests { ); } - #[test] - fn close_meadow_tufts_are_real_blade_geometry() { - let card = Card { - hero: false, - ..card_at(0.34) - }; - let mesh = blade_mesh(&[card], Band::Close); - assert_eq!(mesh.count_vertices(), 16 * 5); - assert_eq!(mesh.indices().unwrap().len(), 16 * 9); - let data = match mesh.attribute(ATTRIBUTE_GRASS_DATA) { - Some(VertexAttributeValues::Unorm16x4(data)) => data, - _ => panic!("blade data carries radial LOD data"), - }; - assert_eq!(data[0][0], 0, "close LOD is encoded per blade"); - assert_eq!( - data[0][..2], - data[1][..2], - "one blade has one stable fade draw" - ); - } - - #[test] - fn immediate_meadow_detail_has_curved_segmented_blades() { - let card = Card { - hero: false, - ..card_at(0.24) - }; - let mesh = curved_blade_mesh(&[card]); - assert_eq!(mesh.count_vertices(), 30 * 5); - assert_eq!(mesh.indices().unwrap().len(), 30 * 9); - let colors = match mesh.attribute(ATTRIBUTE_GRASS_COLOR) { - Some(VertexAttributeValues::Unorm8x4(colors)) => colors, - _ => panic!("blade colours carry their bend weights"), - }; - assert_eq!(colors[0][3], 0, "roots stay pinned in the wind"); - assert_eq!(colors[4][3], 255, "tips take the full wind bend"); - } - #[test] fn a_buried_model_shows_the_height_it_is_asked_for() { // A beet is a rosette with a root under it. Sinking the model must @@ -3538,7 +2575,7 @@ mod tests { fn a_card_sheet_is_cut_out_and_not_a_rectangle() { // The whole point of the sheet: a card has to be a tuft with sky // between its blades. A solid one is the hedge the first stand was. - for leaf in [Leaf::Meadow, Leaf::Blade, Leaf::Broad] { + for leaf in [Leaf::Blade, Leaf::Broad] { let sheet = card_sheet(leaf); let (width, height) = (SHEET_TUFTS * SHEET_TUFT_W, SHEET_TUFT_H); let data = sheet.data.as_ref().expect("the sheet is built on the CPU"); @@ -3583,7 +2620,6 @@ mod tests { /// distance you care to stand at. fn blade_millimetres(crop: CropClass, height: f32) -> (f32, f32) { let thick = match leaf_of(crop) { - Leaf::Meadow => (0.65f32, 1.35f32), Leaf::Blade => (0.8f32, 1.9f32), Leaf::Broad => (2.4, 5.0), Leaf::Rosette => (6.5, 12.0), diff --git a/crates/world-render/src/roads.rs b/crates/world-render/src/roads.rs index d99a063b..ce65fbb4 100644 --- a/crates/world-render/src/roads.rs +++ b/crates/world-render/src/roads.rs @@ -257,6 +257,9 @@ pub fn spawn_roads( MeshMaterial3d(material), // The patch is already in the tile's own frame. Transform::IDENTITY, + // No meadow grass grows through it: the patch cuts a hole into + // the grass ground cache (`crate::grass`). + crate::grass::GroundSurface::Excluded, RoadSurfaceMark { surface: patch.surface, sources: patch.sources.clone(), diff --git a/crates/world-render/src/water.rs b/crates/world-render/src/water.rs index 24c87032..5be90af8 100644 --- a/crates/world-render/src/water.rs +++ b/crates/world-render/src/water.rs @@ -150,6 +150,9 @@ pub fn spawn_waters( MeshMaterial3d(material.clone()), // The patch is already in the tile's own frame. Transform::IDENTITY, + // No meadow grass grows through it: the patch cuts a hole into + // the grass ground cache (`crate::grass`). + crate::grass::GroundSurface::Excluded, WaterSurface { sources: patch.sources.clone(), }, diff --git a/crates/world-render/src/weather.rs b/crates/world-render/src/weather.rs index 5bf5d338..c71d7f75 100644 --- a/crates/world-render/src/weather.rs +++ b/crates/world-render/src/weather.rs @@ -165,7 +165,6 @@ fn update( mut fields: ResMut>, mut water: ResMut>, mut roads: ResMut>, - mut grass: ResMut>, mut last: Local>, ) { let params = WeatherParams::of(&sky); @@ -192,11 +191,6 @@ fn update( for (_, material) in roads.iter_mut() { material.extension.weather = params; } - // Close meadow blades use their own vertex stage so the same wind bends - // them; the fragment stage still receives the world's wetness and snow. - for (_, material) in grass.iter_mut() { - material.extension.weather = params; - } } #[cfg(test)]