class_name PainterlyMeadow extends Node3D ## Decorative flower drifts. Local seeded placement never touches simulation RNG. const FLOWER_SCENES: Array[PackedScene] = [ preload("res://assets/meadow/cream_daisy.glb"), preload("res://assets/meadow/pink_cosmos.glb"), preload("res://assets/meadow/yellow_buttercup.glb"), preload("res://assets/meadow/lavender_spire.glb"), ] const FLOWER_SHADER := preload("res://world/jajce/materials/meadow_flowers.gdshader") const PROFILE_DENSITY := [1.0, 0.65, 0.32] const PROFILE_DISTANCE := [56.0, 46.0, 34.0] @export var terrain_path := NodePath("../TerrainRoot/Terrain3D") @export_range(0, 450, 1) var clumps_per_species := 420 @export var placement_seed := 57091 ## Local x/z center, then ellipse x/z radii. Each drift favors one flower color. @export var drifts: Array[Vector4] = [ Vector4(7.0, 20.0, 7.0, 3.5), Vector4(15.0, 13.0, 2.4, 4.0), Vector4(-5.0, 20.0, 5.0, 3.0), Vector4(-18.0, -21.0, 4.5, 3.2), Vector4(14.0, -10.0, 3.8, 4.5), Vector4(-25.0, -17.0, 3.5, 4.0), Vector4(8.0, -20.0, 5.0, 2.5), Vector4(-2.0, -24.0, 6.0, 2.2), Vector4(17.0, 24.0, 4.0, 3.0), Vector4(-9.0, 21.0, 3.6, 2.5), Vector4(9.0, 7.2, 3.2, 1.8), Vector4(-16.0, 19.0, 3.0, 2.4), ] var _terrain: Terrain3D var _batches: Array[MultiMeshInstance3D] = [] var _material: ShaderMaterial var _quality := 0 var _clearings: Array[Vector3] = [] var _path_strips: Array[MeshInstance3D] = [] func _ready() -> void: call_deferred("_build_meadow") func apply_presentation_quality(quality: int) -> void: _quality = clampi(quality, 0, PROFILE_DENSITY.size() - 1) for batch in _batches: batch.multimesh.visible_instance_count = floori( batch.multimesh.instance_count * PROFILE_DENSITY[_quality] ) if _material != null: _material.set_shader_parameter("fade_begin", PROFILE_DISTANCE[_quality] - 12.0) _material.set_shader_parameter("fade_end", PROFILE_DISTANCE[_quality]) func get_presentation_stats() -> Dictionary: var total := 0 var visible_count := 0 for batch in _batches: total += batch.multimesh.instance_count visible_count += batch.multimesh.visible_instance_count return { "flower_clump_count": total, "visible_flower_clump_count": visible_count, "multimesh_batch_count": _batches.size(), } func _build_meadow() -> void: if not _batches.is_empty(): return _terrain = get_node_or_null(terrain_path) as Terrain3D if _terrain == null or _terrain.data == null or drifts.is_empty(): return _collect_clearings() _material = ShaderMaterial.new() _material.shader = FLOWER_SHADER for species in FLOWER_SCENES.size(): var source := FLOWER_SCENES[species].instantiate() var mesh := _find_mesh(source) if mesh != null: _add_batch(mesh, species) source.free() apply_presentation_quality(_quality) func _find_mesh(node: Node) -> Mesh: if node is MeshInstance3D: return (node as MeshInstance3D).mesh for child in node.get_children(): var mesh := _find_mesh(child) if mesh != null: return mesh return null func _add_batch(mesh: Mesh, species: int) -> void: var rng := RandomNumberGenerator.new() rng.seed = placement_seed + species * 173 var transforms: Array[Transform3D] = [] var variations: Array[Color] = [] for _attempt in clumps_per_species * 18: if transforms.size() >= clumps_per_species: break var drift_index := rng.randi_range(0, drifts.size() - 1) # Broad color masses with a few companion flowers at the edges. if drift_index % FLOWER_SCENES.size() != species and rng.randf() > 0.12: continue var drift := drifts[drift_index] var angle := rng.randf_range(0.0, TAU) var radius := pow(rng.randf(), 0.7) * (0.9 + 0.12 * sin(angle * 3.0 + drift_index)) var offset := Vector3( drift.x + cos(angle) * radius * drift.z, 0.0, drift.y + sin(angle) * radius * drift.w ) var world_sample := to_global(offset) if _is_cleared(world_sample): continue var height := _terrain.data.get_height(world_sample) if is_nan(height): continue var nearby := _terrain.data.get_height(world_sample + Vector3(0.35, 0.0, 0.35)) if is_nan(nearby) or absf(height - nearby) > 0.32: continue var local_position := to_local(Vector3(world_sample.x, height - 0.018, world_sample.z)) var scale_variation := rng.randf_range(0.80, 1.28) var basis := Basis(Vector3.UP, rng.randf_range(0.0, TAU)).scaled( Vector3.ONE * scale_variation ) transforms.append(Transform3D(basis, local_position)) variations.append(Color(rng.randf(), 0.0, 0.0, 1.0)) var multimesh := MultiMesh.new() multimesh.transform_format = MultiMesh.TRANSFORM_3D # Compatibility needs an explicit neutral color alongside the custom buffer # to preserve the GLB's painted vertex colors. multimesh.use_colors = true multimesh.use_custom_data = true multimesh.mesh = mesh multimesh.instance_count = transforms.size() for index in transforms.size(): multimesh.set_instance_transform(index, transforms[index]) multimesh.set_instance_color(index, Color.WHITE) multimesh.set_instance_custom_data(index, variations[index]) var batch := MultiMeshInstance3D.new() batch.name = "FlowerDrifts_%d" % species batch.multimesh = multimesh batch.material_override = _material batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF batch.extra_cull_margin = 0.4 add_child(batch) _batches.append(batch) func _collect_clearings() -> void: var village := get_node_or_null("../VillageRoot") if village != null: for child in village.get_children(): if child is MeshInstance3D and str(child.name).begins_with("Path_"): _path_strips.append(child) elif child is Node3D: var radius := 4.3 if str(child.name).begins_with("House_") else 4.5 _add_clearing(child, radius) var fortress := get_node_or_null("../FortressBlockout") as Node3D if fortress != null: _add_clearing(fortress, 11.0) for root_path in [ "../WorldObjects/ResourceNodes", "../WorldObjects/StorageSites", "../WorldObjects/ActivitySites", "../WorldObjects/AnimalHabitats", ]: var root := get_node_or_null(root_path) if root != null: for child in root.get_children(): if child is Node3D: _add_worksite_clearing(child) var clusters := get_node_or_null("../WorldObjects/ResourceClusters") if clusters != null: for cluster in clusters.get_children(): var anchors := cluster.get_node_or_null("ResourceAnchors") if anchors == null: continue for anchor in anchors.get_children(): if anchor is Node3D: _add_worksite_clearing(anchor) func _add_worksite_clearing(node: Node3D) -> void: _add_clearing(node, 2.0) var interaction := node.get_node_or_null("InteractionPoint") as Node3D if interaction != null: _add_clearing(interaction, 1.2) func _add_clearing(node: Node3D, radius: float) -> void: _clearings.append(Vector3(node.global_position.x, node.global_position.z, radius)) func _is_cleared(point: Vector3) -> bool: var flat := Vector2(point.x, point.z) for clearing in _clearings: if flat.distance_squared_to(Vector2(clearing.x, clearing.y)) < clearing.z * clearing.z: return true for strip in _path_strips: var box := strip.mesh as BoxMesh if box == null: continue var local := strip.to_local(point) var half_width := box.size.x * 0.5 + 0.35 / strip.global_basis.x.length() if absf(local.x) < half_width and absf(local.z) < box.size.z * 0.5 + 0.08: return true var river_distance := absf(point.x - JajceWatercourse.downstream_center_x(point.z)) return ( point.z >= JajceWatercourse.DOWNSTREAM_START_Z - 3.0 and point.z <= JajceWatercourse.DOWNSTREAM_END_Z and river_distance < JajceWatercourse.downstream_half_width(point.z) + 1.7 )