Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions crates/app/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ impl Entry {
enum Setting {
ViewDistance,
TextureQuality,
Grass,
GrassQuality,
Shadows,
ShadowQuality,
Bloom,
Expand Down Expand Up @@ -512,6 +514,8 @@ const SETTINGS: [(&str, &[Setting]); 4] = [
&[
Setting::ViewDistance,
Setting::TextureQuality,
Setting::Grass,
Setting::GrassQuality,
Setting::Shadows,
Setting::ShadowQuality,
Setting::Bloom,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -575,13 +581,15 @@ 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
| Setting::Upscaling
| 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions crates/app/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -777,6 +804,8 @@ fn apply_scene(
streamer: Option<ResMut<TerrainStreamer>>,
quality: Option<ResMut<world_render::mist::Quality>>,
clouds: Option<ResMut<world_render::clouds::Quality>>,
grass: Option<ResMut<world_render::GrassRenderSettings>>,
grass_materials: Option<ResMut<Assets<world_render::GrassMaterial>>>,
cameras: Query<(Entity, Has<Bloom>), With<CabCamera>>,
) {
// The sun's shadow map is a resource of Bevy's own, rebuilt from it every frame.
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions crates/i18n/locales/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/i18n/locales/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
213 changes: 213 additions & 0 deletions crates/world-render/src/grass.wgsl
Original file line number Diff line number Diff line change
@@ -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<uniform> grass_weather: Weather;

struct GrassSettings {
bands: vec4<f32>,
fades: vec4<f32>,
options: vec4<f32>,
}

@group(#{MATERIAL_BIND_GROUP}) @binding(101) var<uniform> grass_settings: GrassSettings;

struct GrassVertex {
@builtin(instance_index) instance_index: u32,
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) color: vec4<f32>,
// xy = compressed radial LOD/random; zw = local leaf coordinates.
@location(3) data: vec4<f32>,
}

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<f32>(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;
}
Loading
Loading