diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index ad9f322..ed11443 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,6 +4,13 @@ The simulator itself is licensed under the EUPL v. 1.2, see [LICENSE](LICENSE). Material from other projects that is checked into this repository keeps its own licence; this file lists it. +## Leafy Grass PBR texture in `crates/world-render/src/terrain/` + +The terrain's grass albedo, OpenGL normal map and packed AO/roughness/metallic +map are the 1K JPEG release of [Leafy Grass by Charlotte Baglioni on Poly Haven](https://polyhaven.com/a/leafy_grass). +The asset is dedicated to the public domain under +[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/). + ## Agent skills in `.claude/skills/` The `bevy*` skills and `similarity-rs` are taken from diff --git a/crates/app/src/menu.rs b/crates/app/src/menu.rs index f616ed0..7f7a97e 100644 --- a/crates/app/src/menu.rs +++ b/crates/app/src/menu.rs @@ -366,6 +366,8 @@ impl Entry { enum Setting { ViewDistance, TextureQuality, + Grass, + GrassQuality, Shadows, ShadowQuality, Bloom, @@ -512,6 +514,8 @@ const SETTINGS: [(&str, &[Setting]); 4] = [ &[ Setting::ViewDistance, Setting::TextureQuality, + Setting::Grass, + Setting::GrassQuality, Setting::Shadows, Setting::ShadowQuality, Setting::Bloom, @@ -540,6 +544,8 @@ impl Setting { match self { Setting::ViewDistance => "set-view-distance", Setting::TextureQuality => "set-texture-quality", + Setting::Grass => "set-grass", + Setting::GrassQuality => "set-grass-quality", Setting::Shadows => "set-shadows", Setting::ShadowQuality => "set-shadow-quality", Setting::Bloom => "set-bloom", @@ -575,6 +581,7 @@ impl Setting { Setting::Bloom => Control::Toggle(graphics.bloom), Setting::VolumetricClouds => Control::Toggle(graphics.volumetric_clouds), Setting::Mist => Control::Toggle(graphics.mist), + Setting::Grass => Control::Toggle(graphics.grass), Setting::VSync => Control::Toggle(graphics.vsync), Setting::AntiAliasing | Setting::AaQuality @@ -582,6 +589,7 @@ impl Setting { | Setting::UpscalingQuality | Setting::ShadowQuality | Setting::MistQuality + | Setting::GrassQuality | Setting::TextureQuality | Setting::Window => Control::Choice, // Three steps, so it is dialled like the language rather than switched. @@ -625,6 +633,7 @@ impl Setting { )), Setting::ShadowQuality => t!(dimmed(graphics.shadows, graphics.shadow_quality)), Setting::MistQuality => t!(dimmed(graphics.mist, graphics.mist_quality)), + Setting::GrassQuality => t!(dimmed(graphics.grass, graphics.grass_quality)), Setting::TextureQuality => t!(graphics.texture_quality.key()), Setting::Window => t!(graphics.window.key()), // The top step of the slider is not a rate but the absence of one. @@ -746,6 +755,7 @@ fn change( Setting::Bloom => graphics.bloom = !graphics.bloom, Setting::VolumetricClouds => graphics.volumetric_clouds = !graphics.volumetric_clouds, Setting::Mist => graphics.mist = !graphics.mist, + Setting::Grass => graphics.grass = !graphics.grass, Setting::AntiAliasing => graphics.anti_aliasing = graphics.anti_aliasing.cycle(dir), Setting::AaQuality => graphics.aa_quality = graphics.aa_quality.cycle(dir), // The upscaling row only walks through what this machine can run. @@ -759,6 +769,7 @@ fn change( } Setting::ShadowQuality => graphics.shadow_quality = graphics.shadow_quality.cycle(dir), Setting::MistQuality => graphics.mist_quality = graphics.mist_quality.cycle(dir), + Setting::GrassQuality => graphics.grass_quality = graphics.grass_quality.cycle(dir), Setting::TextureQuality => graphics.texture_quality = graphics.texture_quality.cycle(dir), Setting::Window => graphics.window = graphics.window.cycle(dir), Setting::VSync => graphics.vsync = !graphics.vsync, diff --git a/crates/app/src/settings.rs b/crates/app/src/settings.rs index 576e961..c7d1db7 100644 --- a/crates/app/src/settings.rs +++ b/crates/app/src/settings.rs @@ -90,6 +90,11 @@ pub struct Graphics { pub mist_quality: Quality, /// Size and filtering of the ground textures the simulator generates. 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. + pub grass_quality: Quality, /// Which anti-aliasing runs on the cab camera. pub anti_aliasing: AntiAliasing, /// How hard it works: the sample count for MSAA, the preset for SMAA, the edge @@ -120,6 +125,8 @@ impl Default for Graphics { mist: true, mist_quality: Quality::Medium, texture_quality: Quality::Medium, + grass: true, + grass_quality: Quality::High, // What Bevy does without being asked, so a settings file from before this // page had the row comes up looking the way it did. anti_aliasing: AntiAliasing::Msaa, @@ -697,6 +704,26 @@ 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. +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), + ), + }; + world_render::GrassRenderSettings::new(graphics.grass, bands, fades) +} + /// Generates the ground textures again into the handles the terrain material already /// holds, so a dialled texture quality reaches the terrain standing on screen. /// @@ -777,6 +804,8 @@ fn apply_scene( streamer: Option>, quality: Option>, clouds: Option>, + grass: Option>, + grass_materials: Option>>, cameras: Query<(Entity, Has), With>, ) { // The sun's shadow map is a resource of Bevy's own, rebuilt from it every frame. @@ -796,6 +825,21 @@ fn apply_scene( { clouds.volumetric = graphics.volumetric_clouds; } + let wanted_grass = grass_quality(&graphics); + if let Some(mut grass) = grass + && *grass != wanted_grass + { + *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 114fafb..eb30f44 100644 --- a/crates/i18n/locales/de/main.ftl +++ b/crates/i18n/locales/de/main.ftl @@ -1556,6 +1556,10 @@ set-upscaling-quality = Hochskalierstufe set-upscaling-quality-hint = Wie viel vom Bild beim Hochskalieren wirklich gezeichnet wird: Niedrig am wenigsten, Hoch am meisten. Eine Größenänderung des Fensters setzt das Bild kurz zurück. set-texture-quality = Texturqualität set-texture-quality-hint = Größe und Filterung der erzeugten Bodentexturen. Gilt auch für das Gelände, das schon zu sehen ist. +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-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 06b4f22..a181845 100644 --- a/crates/i18n/locales/en/main.ftl +++ b/crates/i18n/locales/en/main.ftl @@ -1553,6 +1553,10 @@ set-upscaling-quality = Upscaling quality set-upscaling-quality-hint = How much of the picture is really drawn while upscaling is on: Low the least, High the most. A window resize resets the picture for a moment. set-texture-quality = Texture quality set-texture-quality-hint = Size and filtering of the generated ground textures. Applies to the terrain already on screen. +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-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/src/grass.wgsl b/crates/world-render/src/grass.wgsl new file mode 100644 index 0000000..4d17a7a --- /dev/null +++ b/crates/world-render/src/grass.wgsl @@ -0,0 +1,213 @@ +// 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/lib.rs b/crates/world-render/src/lib.rs index be5c2aa..a596a6a 100644 --- a/crates/world-render/src/lib.rs +++ b/crates/world-render/src/lib.rs @@ -13,12 +13,17 @@ use bevy::asset::io::file::FileAssetReader; use bevy::asset::{RenderAssetUsages, embedded_asset}; use bevy::camera::RenderTarget; use bevy::gltf::GltfAssetLabel; -use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::image::{ + CompressedImageFormats, ImageAddressMode, ImageFilterMode, ImageSampler, + ImageSamplerDescriptor, ImageType, +}; use bevy::mesh::{ConeAnchor, CylinderAnchor, MeshBuilder}; use bevy::pbr::{ExtendedMaterial, MaterialExtension}; use bevy::prelude::*; use bevy::render::mesh::{Indices, PrimitiveTopology}; -use bevy::render::render_resource::{AsBindGroup, Extent3d, TextureDimension, TextureFormat}; +use bevy::render::render_resource::{ + AsBindGroup, Extent3d, ShaderType, TextureDimension, TextureFormat, +}; use bevy::shader::ShaderRef; use content::{PersonInstance, SceneryInstance, TerrainTile, Tree}; use sim_core::interlock::{Aspect, DistantAspect, MainAspect, SignalKind, SignalModel}; @@ -53,7 +58,10 @@ pub use people::{ WalkwayHost, WalkwaysBound, bind_walkways, gait, move_strollers, person_bundle, play_gait, spawn_seated, spawn_strollers, }; -pub use plants::{FieldPlants, PlantMaterials, update_field_plants}; +pub use plants::{ + FieldPlants, GrassMaterial, GrassParams, GrassRenderSettings, 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, @@ -102,6 +110,7 @@ impl Plugin for WorldRenderPlugin { clouds::plugin, mist::plugin, precipitation::plugin, + plants::plugin, weather::plugin, windscreen::plugin, track::plugin, @@ -269,9 +278,9 @@ impl WorldAnchored { /// Terrain material: the standard PBR path plus texture splatting (plan ch. 14). pub type TerrainMaterial = ExtendedMaterial; -/// Splat extension — three generated ground textures, blended in -/// `terrain_splat.wgsl` by the vertex-color weights `content::terrain` bakes -/// into every tile (r = grass, g = rock, b = gravel). +/// Splat extension — a scanned grass PBR set plus generated rock and gravel, +/// blended in `terrain_splat.wgsl` by the vertex-color weights +/// `content::terrain` bakes into every tile (r = grass, g = rock, b = gravel). #[derive(Asset, AsBindGroup, TypePath, Debug, Clone)] pub struct TerrainSplat { #[texture(100)] @@ -279,14 +288,30 @@ pub struct TerrainSplat { grass: Handle, #[texture(102)] #[sampler(103)] - rock: Handle, + grass_normal: Handle, #[texture(104)] #[sampler(105)] + grass_arm: Handle, + #[texture(106)] + #[sampler(107)] + rock: Handle, + #[texture(108)] + #[sampler(109)] gravel: Handle, /// What the weather is doing to the ground — the same uniform the objects /// carry, written by `weather::update` (plan 14.1). - #[uniform(106)] + #[uniform(110)] weather: weather::WeatherParams, + /// The scan stays unchanged; the material turns it with the scenario's + /// season in linear space, without baking another copy of every PBR map. + #[uniform(111)] + ground: GroundParams, +} + +#[derive(Clone, Copy, Debug, ShaderType)] +struct GroundParams { + /// x = snow cover, y = autumn colour, z/w reserved. + season: Vec4, } impl MaterialExtension for TerrainSplat { @@ -375,20 +400,17 @@ fn opaque(color: [f32; 3]) -> [f32; 4] { [color[0], color[1], color[2], 1.0] } -/// The one terrain material, its ground textures generated at startup in the -/// colours of `season` — like the sound sources (ch. 13), the repository -/// carries no binary assets. -// ponytail: procedural noise textures instead of authored ones — photographed -// ground goes into a mod once terrain texturing is moddable content. The -// season is baked in at load: a run that drives from October into November -// keeps the ground it started on. +/// The one terrain material. Grass is a compact CC0 photogrammetry PBR scan; +/// rock and gravel retain their generated, season-coloured macro texture. +/// The scan covers two real metres per repeat, so its 1K maps have enough +/// texel density for a cab-height view without a large terrain texture set. pub fn terrain_material( images: &mut Assets, materials: &mut Assets, season: Season, ground: GroundQuality, ) -> Handle { - let [grass, rock, gravel] = ground_textures(season, ground); + let textures = ground_textures(season, ground); materials.add(TerrainMaterial { base: StandardMaterial { perceptual_roughness: 0.95, @@ -396,9 +418,14 @@ pub fn terrain_material( }, extension: TerrainSplat { weather: weather::WeatherParams::default(), - grass: images.add(grass), - rock: images.add(rock), - gravel: images.add(gravel), + ground: GroundParams { + season: Vec4::new(season.snow, season.autumn, 0.0, 0.0), + }, + grass: images.add(textures.grass), + grass_normal: images.add(textures.grass_normal), + grass_arm: images.add(textures.grass_arm), + rock: images.add(textures.rock), + gravel: images.add(textures.gravel), }, }) } @@ -423,31 +450,38 @@ impl Default for GroundQuality { } } -/// Grass, rock and gravel, in that order. -fn ground_textures(season: Season, ground: GroundQuality) -> [Image; 3] { - [ - ground_texture( - season.green([0.20, 0.32, 0.11]), - season.green([0.41, 0.45, 0.18]), - 64, - 1, - ground, - ), - ground_texture( +struct GroundTextures { + grass: Image, + grass_normal: Image, + grass_arm: Image, + rock: Image, + gravel: Image, +} + +const GRASS_DIFFUSE: &[u8] = include_bytes!("terrain/leafy_grass_diff_1k.jpg"); +const GRASS_NORMAL: &[u8] = include_bytes!("terrain/leafy_grass_nor_gl_1k.jpg"); +const GRASS_ARM: &[u8] = include_bytes!("terrain/leafy_grass_arm_1k.jpg"); + +fn ground_textures(season: Season, ground: GroundQuality) -> GroundTextures { + GroundTextures { + grass: scanned_ground(GRASS_DIFFUSE, true, ground), + grass_normal: scanned_ground(GRASS_NORMAL, false, ground), + grass_arm: scanned_ground(GRASS_ARM, false, ground), + rock: ground_texture( season.snowed([0.35, 0.33, 0.31], 0.45), season.snowed([0.55, 0.53, 0.50], 0.45), 48, 2, ground, ), - ground_texture( + gravel: ground_texture( season.snowed([0.39, 0.35, 0.29], 0.7), season.snowed([0.57, 0.54, 0.49], 0.7), 12, 3, ground, ), - ] + } } /// Generates the ground textures again and writes them into the handles the material @@ -463,10 +497,21 @@ pub fn retexture_ground( for (_, material) in materials.iter() { let splat = &material.extension; let made = ground_textures(season, ground); - for (handle, image) in [&splat.grass, &splat.rock, &splat.gravel] - .into_iter() - .zip(made) - { + for (handle, image) in [ + &splat.grass, + &splat.grass_normal, + &splat.grass_arm, + &splat.rock, + &splat.gravel, + ] + .into_iter() + .zip([ + made.grass, + made.grass_normal, + made.grass_arm, + made.rock, + made.gravel, + ]) { // The only way this fails is a handle whose asset has gone, and a material // that lost its texture is not something this can put right. let _ = images.insert(handle.id(), image); @@ -576,6 +621,11 @@ 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(), )); scatter::spawn_scatter( &mut entity, @@ -1050,6 +1100,38 @@ pub fn bind_lamps( /// repeat covers 32 m of terrain (the UV scale above). const GROUND_TEXTURE_SIZE: u32 = 256; +/// Decodes one of the three compact scan maps compiled into the renderer, +/// gives it a full mip chain and the same quality-controlled anisotropy as +/// the generated layers. The 1K scan is deliberately shared by every quality +/// preset: at 12 MiB decoded for the complete PBR set it is cheap, while +/// lowering it is exactly the blur this material is here to remove. +fn scanned_ground(bytes: &[u8], srgb: bool, ground: GroundQuality) -> Image { + let mut image = Image::from_buffer( + bytes, + ImageType::Extension("jpg"), + CompressedImageFormats::NONE, + srgb, + ImageSampler::Default, + RenderAssetUsages::default(), + ) + .expect("compiled-in grass PBR map decodes"); + let _ = build_mip_chain(&mut image, None); + image.sampler = ground_sampler(ground); + image +} + +fn ground_sampler(ground: GroundQuality) -> ImageSampler { + ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: ground.anisotropy, + ..default() + }) +} + /// One tileable ground texture: two octaves of value noise mix `base` towards /// `accent`, `cell` sets the patch size in texels of the default size, so a patch stays /// the same size on the ground when the texture is generated bigger or smaller. @@ -1089,15 +1171,7 @@ fn ground_texture( ); image.data = Some(data); image.texture_descriptor.mip_level_count = mip_level_count; - image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { - address_mode_u: ImageAddressMode::Repeat, - address_mode_v: ImageAddressMode::Repeat, - mag_filter: ImageFilterMode::Linear, - min_filter: ImageFilterMode::Linear, - mipmap_filter: ImageFilterMode::Linear, - anisotropy_clamp: ground.anisotropy, - ..default() - }); + image.sampler = ground_sampler(ground); image } @@ -1513,6 +1587,27 @@ mod tests { use super::*; use sim_core::interlock::SignalPart; + #[test] + fn the_grass_scan_is_a_linear_complete_pbr_set() { + let textures = ground_textures(Season::default(), GroundQuality::default()); + for image in [&textures.grass, &textures.grass_normal, &textures.grass_arm] { + assert_eq!(image.size(), UVec2::splat(1024)); + assert!(image.texture_descriptor.mip_level_count > 1); + } + assert_eq!( + textures.grass.texture_descriptor.format, + TextureFormat::Rgba8UnormSrgb + ); + assert_eq!( + textures.grass_normal.texture_descriptor.format, + TextureFormat::Rgba8Unorm + ); + assert_eq!( + textures.grass_arm.texture_descriptor.format, + TextureFormat::Rgba8Unorm + ); + } + /// An offset anchored object keeps its offset across an origin rebase — /// the sleeper chunks hang at their own centre mid-edge, and the rebase's /// `resync_anchored` must not pull them back to the edge anchor. diff --git a/crates/world-render/src/plants.rs b/crates/world-render/src/plants.rs index 4e5b9d5..dcafe5b 100644 --- a/crates/world-render/src/plants.rs +++ b/crates/world-render/src/plants.rs @@ -1,4 +1,4 @@ -//! The standing crop: plants on the fields (the field plan's deferred pass). +//! Camera-local standing vegetation: field crops and the default terrain grass. //! //! 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,7 +7,9 @@ //! 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 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. //! //! 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, @@ -43,15 +45,24 @@ use std::collections::HashMap; use std::sync::Arc; -use bevy::asset::{AssetId, AssetPath, LoadState, RenderAssetUsages}; +use bevy::asset::{AssetId, AssetPath, LoadState, RenderAssetUsages, embedded_asset}; 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::{Extent3d, TextureDimension, TextureFormat}; +use bevy::render::render_resource::{ + AsBindGroup, Extent3d, RenderPipelineDescriptor, ShaderType, SpecializedMeshPipelineError, + TextureDimension, TextureFormat, VertexFormat, +}; +use bevy::shader::ShaderRef; +use content::TerrainTile; use fields::CropClass; use fields::phenology::{self, Stage}; @@ -59,9 +70,109 @@ use crate::{ Season, TextureMips, WorldView, farmland::{FieldSurface, linear}, sky::Sky, - weather::{WeatherExt, WeatherMaterial}, + weather::{WeatherExt, WeatherMaterial, WeatherParams}, }; +/// 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() + } + + fn specialize( + _pipeline: &MaterialExtensionPipeline, + descriptor: &mut RenderPipelineDescriptor, + layout: &MeshVertexBufferLayoutRef, + _key: MaterialExtensionKey, + ) -> Result<(), SpecializedMeshPipelineError> { + 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. @@ -107,11 +218,9 @@ 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; -/// 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. +/// 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. const BUILD_BUDGET: usize = 3; /// One draw for a card, 0 … 1 — deterministic on every machine of a run. @@ -295,6 +404,10 @@ 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. @@ -855,6 +968,7 @@ 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. @@ -888,7 +1002,11 @@ fn grow( let mut area = 0.0f32; let mut scratch = Vec::new(); let mut poly = Vec::new(); - for i in 0..tris.len() { + let candidates: Box + '_> = match terrain { + Some(terrain) => Box::new(terrain.triangles(key).iter().copied()), + None => Box::new(0..tris.len()), + }; + for i in candidates { let [ia, ib, ic] = tris.at(i); if ia.max(ib).max(ic) >= positions.len() { continue; @@ -927,12 +1045,29 @@ 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. - let stretch = ((area * density(crop)) / MAX_CARDS as f32).max(1.0); - let density = density(crop) / stretch; + // 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; // 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. - let keep = band.stand().0 * 256.0; + // 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 + }; // 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 @@ -941,7 +1076,8 @@ 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); + let wanted = model_of(crop, stage).map_or(0.0, |hero| hero.density) + * if terrain.is_some() { 0.5 } else { 1.0 }; let cards = area * density; if cards > 0.0 { (wanted / density).min(by_count / cards).min(1.0) @@ -978,30 +1114,69 @@ fn grow( } for k in 0..count as u64 { let seed = i as u64 * 1_009 + k; - let at = sample_polygon( - poly, - piece, - draw(seed, salt + 2), - draw(seed, salt + 3), - draw(seed, salt + 4), - ); + 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 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 = 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); + 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) + }; // 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 growth = phenology::growth_offset(crop, today, (week * 2.0 - 1.0) * 7.0); + 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); + } // 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 { @@ -1016,9 +1191,17 @@ fn grow( if (rank as f32) >= keep { continue; } - let height = + let mut 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 @@ -1026,7 +1209,9 @@ 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), + width: card_width(crop, height) + * (0.8 + 0.4 * draw(seed, salt + 6) as f32) + * if terrain.is_some() { 1.1 } else { 1.0 }, height, lean: (draw(seed, salt + 10) as f32 - 0.5) * 0.35, tint, @@ -1167,6 +1352,13 @@ 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>, @@ -1216,10 +1408,77 @@ 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, month: u32, day: u32) -> bool { + pub fn set_date( + &mut self, + assets: &mut Assets, + blades: &mut Assets, + month: u32, + day: u32, + ) -> bool { let today = phenology::day_of_year(month, day); if self.day == Some(today) { return false; @@ -1230,11 +1489,22 @@ 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.by_crop.is_empty() && self.blades_by_crop.is_empty() && self.terrain.is_none() } } @@ -1256,11 +1526,12 @@ 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, sky.month, sky.day); + materials.set_date(&mut assets, &mut blades, sky.month, sky.day); } /// The standing crop of one field patch: the cells it is cut into, what they @@ -1282,6 +1553,121 @@ 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.chunks_exact(3).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.chunks_exact(3) { + 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. @@ -1341,12 +1727,43 @@ 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() } -/// Grows, regrows and drops the standing crop of every field patch. +/// 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. /// /// 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 @@ -1365,13 +1782,16 @@ 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 fields: Query<( + mut surfaces: Query<( Entity, - &FieldSurface, + Option<&FieldSurface>, + Option<&TerrainGrass>, &Mesh3d, &GlobalTransform, &mut FieldPlants, @@ -1391,7 +1811,14 @@ 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, surface, mesh3d, at, mut state) in &mut fields { + 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; + }; // The patch's cells and its reach, measured once from its mesh: // everything after this is distance tests against boxes. if !state.surveyed { @@ -1402,8 +1829,13 @@ pub fn update_field_plants( if let Some(found) = survey(mesh) { state.centre = found.centre; state.radius = found.radius; - state.week = found.week; + state.week = if terrain.is_some() { 0.5 } else { 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() { @@ -1412,7 +1844,11 @@ 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 = eye_local.distance(state.centre) - state.radius; + let reach = if terrain.is_some() { + eye_local.xz().distance(state.centre.xz()) - state.radius + } else { + 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); @@ -1426,19 +1862,26 @@ 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(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(); + 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(); // 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) { @@ -1448,8 +1891,16 @@ pub fn update_field_plants( for at in 0..state.cells.len() { let cell = &state.cells[at]; - let distance = box_distance(eye_local, cell.lo, cell.hi); - let want = band_for(distance, cell.band); + 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) + }; if want == cell.band { continue; } @@ -1463,45 +1914,64 @@ pub fn update_field_plants( if wanted.is_empty() { return; } - // 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); - } + // 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 + } + }); for (_, entity, at, band) in wanted { - let Ok((entity, surface, mesh3d, _, mut state)) = fields.get_mut(entity) else { + 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 { continue; }; let Some(key) = state.grown else { continue; }; drop_cell(&mut commands, &mut state.cells[at]); - let model = models.model( - surface.crop, - key.stage, - &assets, - &gltfs, - &gltf_meshes, - &meshes, - &standards, - &mut mips, - ); + let model = if terrain.is_some() { + None + } else { + models.model( + 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, - surface.crop, + 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 @@ -1514,20 +1984,21 @@ 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(surface.crop, sky.month, sky.day, 0); + let growth = phenology::growth(crop, sky.month, sky.day, 0); let stand = [ linear(growth.color[0]), linear(growth.color[1]), linear(growth.color[2]), ]; - 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); + 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); // 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 @@ -1543,67 +2014,441 @@ pub fn update_field_plants( let mut spawned = Vec::new(); commands.entity(entity).with_children(|parent| { - // 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 { + 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 { continue; } - let handle = meshes.add(mesh); - spawned.push(( - parent - .spawn(( - Mesh3d(handle.clone()), - MeshMaterial3d(dressed.clone()), - Transform::IDENTITY, - range(start, end), - )) - .id(), - handle.id(), - )); + 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 mesh = card_mesh(&cards, level); - if mesh.count_vertices() == 0 { - continue; + } 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 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. /// @@ -1624,9 +2469,13 @@ pub fn update_field_plants( /// 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) -> Mesh { - let (_, wider) = band.stand(); - let crossed = band.crossed(); +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(); let mut positions = Vec::new(); let mut normals = Vec::new(); let mut colors = Vec::new(); @@ -1634,7 +2483,8 @@ fn card_mesh(cards: &[Card], band: Band) -> Mesh { let mut indices = Vec::new(); for card in cards { - if !band.keeps(card) { + let kept = if terrain { true } else { band.keeps(card) }; + if !kept { continue; } // A real plant stands here at the levels that draw them, and a quad @@ -1836,6 +2686,8 @@ 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, @@ -1883,6 +2735,7 @@ 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), @@ -2003,9 +2856,21 @@ 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 { - start_margin: start..start, - end_margin: end..end, + // 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), use_aabb: true, } } @@ -2060,6 +2925,17 @@ mod tests { mesh.indices().map(Indices::len).unwrap_or(0) } + #[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 { @@ -2096,7 +2972,19 @@ mod tests { survey .cells .iter() - .flat_map(|cell| grow(mesh, crop, month, day, cell.key, None, stage, Band::Close)) + .flat_map(|cell| { + grow( + mesh, + crop, + month, + day, + cell.key, + None, + stage, + Band::Close, + None, + ) + }) .collect() } @@ -2145,6 +3033,7 @@ mod tests { None, Stage::Ripe, Band::Close, + None, ); assert!( cards.len() <= MAX_CARDS, @@ -2173,6 +3062,7 @@ mod tests { None, Stage::Flowering, Band::Close, + None, ) { let at = card.pos.xz(); assert!( @@ -2203,7 +3093,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)) / 6; + let quads = |band: Band| index_count(&card_mesh(&cards, band, false)) / 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)); @@ -2251,7 +3141,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); + let mesh = card_mesh(&cards, Band::Close, false); let positions = mesh .attribute(Mesh::ATTRIBUTE_POSITION) .unwrap() @@ -2303,6 +3193,7 @@ 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, @@ -2367,6 +3258,44 @@ 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 @@ -2575,7 +3504,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::Blade, Leaf::Broad] { + for leaf in [Leaf::Meadow, 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"); @@ -2620,6 +3549,7 @@ 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/terrain/leafy_grass_arm_1k.jpg b/crates/world-render/src/terrain/leafy_grass_arm_1k.jpg new file mode 100644 index 0000000..0961d9f Binary files /dev/null and b/crates/world-render/src/terrain/leafy_grass_arm_1k.jpg differ diff --git a/crates/world-render/src/terrain/leafy_grass_diff_1k.jpg b/crates/world-render/src/terrain/leafy_grass_diff_1k.jpg new file mode 100644 index 0000000..76abe0b Binary files /dev/null and b/crates/world-render/src/terrain/leafy_grass_diff_1k.jpg differ diff --git a/crates/world-render/src/terrain/leafy_grass_nor_gl_1k.jpg b/crates/world-render/src/terrain/leafy_grass_nor_gl_1k.jpg new file mode 100644 index 0000000..5edb707 Binary files /dev/null and b/crates/world-render/src/terrain/leafy_grass_nor_gl_1k.jpg differ diff --git a/crates/world-render/src/terrain_splat.wgsl b/crates/world-render/src/terrain_splat.wgsl index 17e882b..7cb660d 100644 --- a/crates/world-render/src/terrain_splat.wgsl +++ b/crates/world-render/src/terrain_splat.wgsl @@ -13,22 +13,94 @@ @group(#{MATERIAL_BIND_GROUP}) @binding(100) var grass_texture: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var grass_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(102) var rock_texture: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(103) var rock_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(104) var gravel_texture: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(105) var gravel_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(106) var weather: Weather; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var grass_normal_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var grass_normal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var grass_arm_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(105) var grass_arm_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(106) var rock_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(107) var rock_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(108) var gravel_texture: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(109) var gravel_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(110) var weather: Weather; + +struct GroundParams { + season: vec4, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(111) var ground: GroundParams; @fragment fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { var pbr_input = pbr_input_from_standard_material(in, is_front); - let w = in.color.rgb; - // Different tilings per layer, so their repetition never lines up. - let grass = textureSample(grass_texture, grass_sampler, in.uv).rgb; + let w = in.color.rgb / max(dot(in.color.rgb, vec3(1.0)), 0.0001); + + // The scan is two metres wide. A second, quarter-turned lookup removes + // the most recognisable clumps while retaining the scan's crisp detail; + // both repeat an integer number of times across a 512 m tile, so their + // blend remains seamless at streamed tile borders. + let grass_uv = in.uv * 16.0; + let grass_a = textureSample(grass_texture, grass_sampler, grass_uv).rgb; + let grass_b = textureSample( + grass_texture, + grass_sampler, + vec2(-grass_uv.y, grass_uv.x) + vec2(0.37, 0.61), + ).rgb; + let scanned_grass = mix(grass_a, grass_b, 0.12); + let scan_luma = dot(scanned_grass, vec3(0.2126, 0.7152, 0.0722)); + // This layer represents the simulator's default living turf. Re-colour by + // luminance in linear space so the photographed relief stays intact while + // matching the brighter Central-European meadow palette of the simulation. + let relief = clamp(0.55 + scan_luma * 2.2, 0.55, 1.38); + var grass = vec3(0.055, 0.15, 0.025) * relief + + scanned_grass * vec3(0.14, 0.08, 0.04); + // Broad colour movement at 32 m stops the two-metre scan reading as a + // stamp. Integer-period waves keep the value continuous across tiles. + let macro_variation = sin(in.uv.x * 6.2831853) * sin(in.uv.y * 6.2831853); + grass *= 0.96 + 0.07 * macro_variation; + + // Turn the scan with the scenario without corrupting its normal or ARM + // data. Autumn keeps the luminance variation; snow leaves a trace of the + // relief underneath instead of becoming a flat white sheet. + let luma = dot(grass, vec3(0.2126, 0.7152, 0.0722)); + let autumn = vec3(luma) * vec3(1.18, 0.94, 0.42); + grass = mix(grass, autumn, ground.season.y); + grass = mix(grass, vec3(0.78, 0.82, 0.90) + (luma - 0.35) * 0.12, ground.season.x); + + // Different tilings per generated layer, so their repetition never + // lines up with the scan or with one another. let rock = textureSample(rock_texture, rock_sampler, in.uv * 0.63).rgb; let gravel = textureSample(gravel_texture, gravel_sampler, in.uv * 1.37).rgb; pbr_input.material.base_color = vec4(grass * w.r + rock * w.g + gravel * w.b, 1.0); + + // PBR surface response from the scan. ARM is AO / roughness / metallic; + // grass is dielectric, so its blue channel is intentionally unused. + let arm = textureSample(grass_arm_texture, grass_arm_sampler, grass_uv).rgb; + let grass_roughness = clamp(arm.g, 0.62, 0.98); + pbr_input.material.perceptual_roughness = + grass_roughness * w.r + 0.91 * w.g + 0.94 * w.b; + pbr_input.diffuse_occlusion *= vec3(mix(1.0, arm.r, w.r * 0.72)); + + // Derivative-built tangent frame: terrain vertices do not need to carry + // tangents, and the normal remains correct on slopes and ENU-rotated + // tiles. OpenGL (+Y) normals match Bevy's convention. + var tangent_normal = textureSample( + grass_normal_texture, + grass_normal_sampler, + grass_uv, + ).xyz * 2.0 - 1.0; + tangent_normal = normalize(vec3(tangent_normal.xy * 0.62, tangent_normal.z)); + let q1 = dpdx(in.world_position.xyz); + let q2 = dpdy(in.world_position.xyz); + let st1 = dpdx(grass_uv); + let st2 = dpdy(grass_uv); + let tangent = normalize(q1 * st2.y - q2 * st1.y); + let bitangent = normalize(-q1 * st2.x + q2 * st1.x); + let mapped_normal = normalize( + tangent * tangent_normal.x + + bitangent * tangent_normal.y + + pbr_input.N * tangent_normal.z, + ); + pbr_input.N = normalize(mix(pbr_input.N, mapped_normal, w.r * (1.0 - ground.season.x * 0.7))); // Rain, snow and the shadow of a cloud, the same way the objects get them. pbr_input = weather_pbr(weather, globals.time, pbr_input); diff --git a/crates/world-render/src/weather.rs b/crates/world-render/src/weather.rs index 3c0464f..b92ad56 100644 --- a/crates/world-render/src/weather.rs +++ b/crates/world-render/src/weather.rs @@ -163,6 +163,7 @@ fn update( mut fields: ResMut>, mut water: ResMut>, mut roads: ResMut>, + mut grass: ResMut>, mut last: Local>, ) { let params = WeatherParams::of(&sky); @@ -189,6 +190,11 @@ 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)]