diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 6c4343f..b8ea847 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -12,6 +12,9 @@ const GeneratedLakePool: FishPool = preload( const GeneratedRiverPool: FishPool = preload( "res://fish/pools/generated_river_pool.tres" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FIRST_SEED := 13001 const SECOND_SEED := 13002 const RIVER_FALLBACK_SEED := 13012 @@ -502,6 +505,12 @@ func _validate_generated_region( assert(PROCEDURAL_PROP_IDS.has(prop_id)) var definition := region.get_prop_catalog().definition_for_id(prop_id) assert(definition != null) + if definition.procedural_group in [&"grass_tree", &"sand_tree"]: + assert( + _has_precipitation_occlusion_layer(child), + "Precipitation occlusion layer missing from %s (%s)." + % [child.name, prop_id], + ) var biome_id := StringName( child.get_meta(&"terrain_biome_id", &"") ) @@ -967,10 +976,12 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: assert(pine != null) assert(pine.minimum_visual_scale < pine.maximum_visual_scale) assert(pine.material_variants.is_empty()) + assert(not pine.has_gatherable_surface()) var large_pine := catalog.definition_for_id(&"prop_pine_large") assert(large_pine != null) assert(large_pine.minimum_visual_scale < large_pine.maximum_visual_scale) assert(large_pine.material_variants.is_empty()) + assert(not large_pine.has_gatherable_surface()) var palm := catalog.definition_for_id(&"prop_palm") assert(palm != null) assert(is_equal_approx(palm.minimum_visual_scale, 0.5)) @@ -1180,6 +1191,21 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D: return null +func _has_precipitation_occlusion_layer(root_node: Node) -> bool: + var mesh_instance := root_node as MeshInstance3D + if ( + mesh_instance != null + and mesh_instance.get_layer_mask_value( + PrecipitationOcclusionType.RENDER_LAYER_NUMBER + ) + ): + return true + for child: Node in root_node.get_children(): + if _has_precipitation_occlusion_layer(child): + return true + return false + + func _has_material_variant_override( root_node: Node, variants: Array[Material], diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index c346e93..9a53eff 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -10,6 +10,9 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd") const WorldTimeVisualControllerType = preload( "res://world/world_time_visual_controller.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FishingContextType = preload("res://fishing/fishing_context.gd") const FishingSpotType = preload("res://fishing/fishing_spot.gd") const FishAvailabilityType = preload("res://fish/fish_availability.gd") @@ -526,6 +529,14 @@ func _validate_weather_presentation() -> void: )) var rain_material := rain.process_material as ParticleProcessMaterial assert(rain_material != null) + assert( + rain_material.collision_mode + == ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) + assert(is_equal_approx( + rain.collision_base_size, + WorldTimeVisualControllerType.RAIN_COLLISION_BASE_SIZE, + )) assert(rain_material.emission_box_extents.is_equal_approx( WorldTimeVisualControllerType.RAIN_EMISSION_EXTENTS )) @@ -542,6 +553,26 @@ func _validate_weather_presentation() -> void: assert(rain_mesh.size.is_equal_approx( WorldTimeVisualControllerType.RAIN_DROP_SIZE )) + var rain_collision := visuals.get_node( + "LocalRainCanopyCollision" + ) as GPUParticlesCollisionHeightField3D + assert(rain_collision != null) + assert(rain_collision.follow_camera_enabled) + assert( + rain_collision.resolution + == GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + assert( + rain_collision.update_mode + == GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + assert(rain_collision.size.is_equal_approx( + WorldTimeVisualControllerType.RAIN_COLLISION_SIZE + )) + assert( + rain_collision.heightfield_mask + == PrecipitationOcclusionType.RENDER_LAYER_MASK + ) visuals.call( "_on_weather_changed", WorldWeatherServiceType.Weather.SUNNY, diff --git a/world/environment/precipitation_occlusion.gd b/world/environment/precipitation_occlusion.gd new file mode 100644 index 0000000..8d27a68 --- /dev/null +++ b/world/environment/precipitation_occlusion.gd @@ -0,0 +1,56 @@ +class_name PrecipitationOcclusion +extends RefCounted + +## A secondary visual layer used only when building the local rain height field. +## Canopy meshes remain on layer 1 for ordinary cameras. +const RENDER_LAYER_NUMBER: int = 20 +const RENDER_LAYER_MASK: int = 1 << (RENDER_LAYER_NUMBER - 1) +const CANOPY_MATERIAL_NAMES: Array[String] = [ + "leaf", + "leaf_light", + "leaf_mid", + "leaf_dark", + "pine", + "tree", +] + + +static func mark_canopy_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null and _has_canopy_material(mesh_instance): + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_canopy_meshes(child) + return marked_count + + +## Generated tree definitions are already explicit terrain metadata, so every +## mesh in their visual scene can safely participate. This also supports older +## combined tree meshes whose imported material name does not identify leaves. +static func mark_tree_meshes(root_node: Node) -> int: + if root_node == null: + return 0 + var marked_count: int = 0 + var mesh_instance := root_node as MeshInstance3D + if mesh_instance != null: + mesh_instance.layers |= RENDER_LAYER_MASK + marked_count += 1 + for child: Node in root_node.get_children(): + marked_count += mark_tree_meshes(child) + return marked_count + + +static func _has_canopy_material(mesh_instance: MeshInstance3D) -> bool: + if mesh_instance == null or mesh_instance.mesh == null: + return false + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.mesh.surface_get_material(surface_index) + if material == null: + continue + if material.resource_name.to_lower() in CANOPY_MATERIAL_NAMES: + return true + return false diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index 06406de..c816c3b 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -12,6 +12,9 @@ const PlayerStorageInteractionType = preload( const MeshSurfaceAnchorSamplerType = preload( "res://world/generation/mesh_surface_anchor_sampler.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn") const SALT_WATER_MATERIAL: Material = preload( "res://world/materials/stylized_water.tres" @@ -292,9 +295,21 @@ func _on_generation_completed(summary: Dictionary) -> void: _place_spawn_amenities(spawn_position) _configure_fresh_water(records) _configure_diggable_area() + # Imported prop instances may finish applying their scene state while the + # generated world is assembled. Mark the final decoration tree once every + # prop and material variant is in place so rain canopy occlusion persists. + _mark_precipitation_occluders() world_generated.emit(_current_seed, summary) +func _mark_precipitation_occluders() -> void: + for prop: Node in _decorations.get_children(): + var prop_group := prop.get_meta(&"terrain_prop_group", &"") as StringName + if prop_group not in [&"grass_tree", &"sand_tree"]: + continue + PrecipitationOcclusionType.mark_tree_meshes(prop) + + func _assign_biomes( records: Array[Dictionary], summary: Dictionary, diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 062dd3b..b3224a9 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.65 maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres index 5f5d584..df9076d 100644 --- a/world/generation/props/definitions/prop_pine_large.tres +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -17,4 +17,3 @@ minimum_visual_scale = 0.75 maximum_visual_scale = 1.2 collision_radius = 0.65 collision_height = 9.5 -gatherable_surface_material_names = PackedStringArray("wood") diff --git a/world/regions/starter_island_region.gd b/world/regions/starter_island_region.gd index b787ff1..3af3010 100644 --- a/world/regions/starter_island_region.gd +++ b/world/regions/starter_island_region.gd @@ -11,6 +11,9 @@ const PlayerStorageInteractionType = preload( const FOLIAGE_WIND_SHADER: Shader = preload( "res://world/materials/foliage_wind.gdshader" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const FOLIAGE_MATERIAL_NAMES: Array[StringName] = [ &"leaf", &"leaf_light", @@ -60,6 +63,9 @@ var _foliage_wind_enabled: bool = false func _ready() -> void: if rebuild_terrain_collision_on_ready or not has_terrain_collision(): _build_terrain_collision() + PrecipitationOcclusionType.mark_canopy_meshes( + get_node_or_null(terrain_visual_root_path) + ) _set_foliage_wind_enabled(true) if Engine.is_editor_hint(): update_configuration_warnings() diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index fd743ed..5328c54 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -17,6 +17,8 @@ const LIGHT_RAIN_FIXED_FPS: int = 8 const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) +const RAIN_COLLISION_BASE_SIZE: float = 0.08 +const RAIN_COLLISION_SIZE := Vector3(28.0, 24.0, 28.0) const FOG_DAYLIGHT_SCENE_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS: float = 0.42 const FOG_DAYLIGHT_WATER_BRIGHTNESS: float = 0.42 @@ -37,6 +39,9 @@ const FRESH_WATER_MATERIAL: ShaderMaterial = preload( const LocalStormCloudLayerType = preload( "res://world/environment/local_storm_cloud_layer.gd" ) +const PrecipitationOcclusionType = preload( + "res://world/environment/precipitation_occlusion.gd" +) const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) @@ -88,6 +93,7 @@ var _rain_camera_provider: Callable var _environment: Environment var _sky_material: ShaderMaterial var _rain: GPUParticles3D +var _rain_collision: GPUParticlesCollisionHeightField3D var _storm_clouds: LocalStormCloudLayer var _elapsed: float = 0.0 var _weather_from: WorldWeatherService.Weather = ( @@ -211,6 +217,7 @@ func _prepare_rain() -> void: LIGHT_RAIN_FIXED_FPS if _light_performance_profile else 30 ) _rain.local_coords = false + _rain.collision_base_size = RAIN_COLLISION_BASE_SIZE _rain.visibility_aabb = RAIN_VISIBILITY_AABB var process_material := ParticleProcessMaterial.new() process_material.emission_shape = ( @@ -222,6 +229,9 @@ func _prepare_rain() -> void: process_material.initial_velocity_min = RAIN_VELOCITY_MIN process_material.initial_velocity_max = RAIN_VELOCITY_MAX process_material.gravity = Vector3(0.0, -2.0, 0.0) + process_material.collision_mode = ( + ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT + ) _rain.process_material = process_material var drop_mesh := BoxMesh.new() drop_mesh.size = RAIN_DROP_SIZE @@ -232,9 +242,30 @@ func _prepare_rain() -> void: drop_mesh.material = drop_material _rain.draw_pass_1 = drop_mesh add_child(_rain) + _prepare_rain_collision() _update_rain_position() +func _prepare_rain_collision() -> void: + _rain_collision = GPUParticlesCollisionHeightField3D.new() + _rain_collision.name = "LocalRainCanopyCollision" + _rain_collision.size = RAIN_COLLISION_SIZE + _rain_collision.resolution = ( + GPUParticlesCollisionHeightField3D.RESOLUTION_256 + ) + _rain_collision.update_mode = ( + GPUParticlesCollisionHeightField3D.UPDATE_MODE_WHEN_MOVED + ) + _rain_collision.follow_camera_enabled = true + _rain_collision.heightfield_mask = ( + PrecipitationOcclusionType.RENDER_LAYER_MASK + ) + # Local rain remains on its normal layer; unrelated particle systems do not + # need to query this weather-specific collision field. + _rain_collision.cull_mask = 1 + add_child(_rain_collision) + + func _prepare_storm_clouds() -> void: _storm_clouds = LocalStormCloudLayerType.new() _storm_clouds.name = "LocalStormClouds"