HungryProton / scatter

Godot engine addon to randomly fill an area with props or other scenes
MIT License
2.18k stars 97 forks source link

Performance/behavior when adding a ScatterShape node to the tree at runtime #162

Closed spiderbyte87 closed 1 year ago

spiderbyte87 commented 1 year ago

I'm trying to add a negative scatter item to the tree during runtime. When I add the node I have to rebuild from script, which obviously takes a hot second to finish. It also reshuffles the scattered objects so all my trees "change" locations.

Is the only solution to this to have ScatterItem nodes in the tree but invisible prior to loading the game and change their properties at runtime? I'm asking because this for an RTS.. when a building is placed I want to clear out the foliage around it.. the problem is I don't know how many buildings there will ultimately be.

This is super low priority, I'm just looking for a cleaner way to handle this edge case. I can work around it if necessary, just looking for advice.

HungryProton commented 1 year ago

I'll check if I can find a way to solve this from the add-on directly, scatter lacks a lot of features when it comes to making changes at runtime.

Right now, the only solution is to access the underlying nodes (when using 'Create copies') or the MultimeshInstance3D nodes (when using instancing) and edit them directly, which is not ideal.

spiderbyte87 commented 1 year ago

I managed to write my own scatter script using a spatial hashing algorithm that removes instances instantly. Here's a vid: https://youtu.be/BZCUeiyF-DU

Here's the script (it's a little messy but it works):

extends MultiMeshInstance3D

@export var heightmap_texture: ViewportTexture
@export var heightmap_scale_factor: float = 50.0 
@export var clustermap_texture: ViewportTexture
@export var density: int = 200
@export var max_instances: int = 1000
@export var terrain_size: float = 320.0 
@export var min_height: float = 5.0  # minimum height where trees can be placed
@export var max_height: float = 45.0  # maximum height where trees can be placed
@export var max_slope: float = 0.3  # maximum allowable gradient magnitude for tree placement
@export var min_scale: Vector3 = Vector3(0.8, 0.8, 0.8)
@export var max_scale: Vector3 = Vector3(1.2, 1.2, 1.2)
@export var instance_scale_factor: float = 1.0
@export var cell_size: float = 10.0  # Adjust as needed

var spatial_hash = {}
var current_instance_count: int = 0

func _ready():
    Globals.building_just_placed.connect(clear_instances)
    await RenderingServer.frame_post_draw
    # Populate the MultiMesh using heightmap
    var heightmap_image = heightmap_texture.get_image()
    populate_multimesh_from_heightmap(heightmap_image)

func _hash_position(pos: Vector3) -> String:
    # Convert world position to cell coordinates
    var cell_x = int(floor(pos.x / cell_size))
    var cell_y = int(floor(pos.y / cell_size))
    var cell_z = int(floor(pos.z / cell_size))

    return "%d,%d,%d" % [cell_x, cell_y, cell_z]

func insert_into_spatial_hash(pos: Vector3, instance_id: int):
    var hash_key = _hash_position(pos)
    if spatial_hash.has(hash_key):
        spatial_hash[hash_key].append({"pos": pos, "id": instance_id})
    else:
        spatial_hash[hash_key] = [{"pos": pos, "id": instance_id}]

func clear_instances(nearby, clear_radius):
    var nearby_instances: Array = get_nearby_instances(nearby, clear_radius)
    hideInstances(nearby_instances)

func hideInstances(instances: Array):
    for instance in instances:
        var instance_id = instance["id"]
        var transform = multimesh.get_instance_transform(instance_id)
        transform.basis = transform.basis.scaled(Vector3(0, 0, 0))
        multimesh.set_instance_transform(instance_id, transform)

func get_nearby_instances(pos: Vector3, radius: float) -> Array:
    var instances_within_radius = []

    # Determine how many cells the radius might span in each dimension
    var cells_in_radius = int(ceil(radius / cell_size))

    # Compute the central cell of the provided position
    var central_cell = _hash_position(pos).split(",")
    var central_cell_x = int(central_cell[0])
    var central_cell_y = int(central_cell[1])
    var central_cell_z = int(central_cell[2])

    # Loop through all potential cells that might intersect the radius
    for x in range(central_cell_x - cells_in_radius, central_cell_x + cells_in_radius + 1):
        for y in range(central_cell_y - cells_in_radius, central_cell_y + cells_in_radius + 1):
            for z in range(central_cell_z - cells_in_radius, central_cell_z + cells_in_radius + 1):
                var hash_key = "%d,%d,%d" % [x, y, z]
                if spatial_hash.has(hash_key):
                    for instance_data in spatial_hash[hash_key]:
                # Check if the instance is within the radius
                        if instance_data["pos"].distance_to(pos) <= radius:
                            instances_within_radius.append(instance_data)  # Note: Appending the entire dictionary here
    return instances_within_radius

func get_slope(heightmap_image, x, y):
    # Define the Sobel kernels for x and y directions
    var sobel_x = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]
    var sobel_y = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]

    var dx = 0.0
    var dy = 0.0

    # Loop through the 3x3 neighborhood around the pixel
    for i in range(-1, 2):
        for j in range(-1, 2):
            # Ensure we're within the bounds of the image
            var nx = clamp(x + i, 0, heightmap_image.get_width() - 1)
            var ny = clamp(y + j, 0, heightmap_image.get_height() - 1)

            var height = heightmap_image.get_pixel(nx, ny).r * heightmap_scale_factor

            dx += sobel_x[i + 1][j + 1] * height
            dy += sobel_y[i + 1][j + 1] * height

    # Compute the gradient magnitude
    return sqrt(dx*dx + dy*dy)

func populate_multimesh_from_heightmap(heightmap_image):
    # Make sure to load the clustermap image as well
    var clustermap_image = clustermap_texture.get_image()

    var width = heightmap_image.get_width()
    var height = heightmap_image.get_height()

    var scale_factor_x = terrain_size / width
    var scale_factor_y = terrain_size / height
    var half_terrain_size = terrain_size * 0.5

    while current_instance_count < max_instances:
        var x = randi() % width
        var y = randi() % height

        var cluster_value = clustermap_image.get_pixel(x, y).r
        # If cluster value is close to 0, skip this iteration
        if cluster_value < 0.3:
            continue

        var height_value = heightmap_image.get_pixel(x, y).r * heightmap_scale_factor
        var slope = get_slope(heightmap_image, x, y)

        # Adjust density based on cluster value
        if randf() < density * cluster_value and height_value > min_height and height_value < max_height and slope < max_slope:
            var world_pos_x = (x * scale_factor_x) - half_terrain_size
            var world_pos_y = (y * scale_factor_y) - half_terrain_size

            var transform = Transform3D.IDENTITY

            # Apply random rotation around Y-axis
            var rotation_angle = randf() * PI * 2.0  # A full circle in radians
            transform = transform.rotated(Vector3(0, 1, 0), rotation_angle)

            # Apply random scaling
            var scale_factor = Vector3(
                randf_range(min_scale.x * instance_scale_factor, max_scale.x * instance_scale_factor),
                randf_range(min_scale.y * instance_scale_factor, max_scale.y * instance_scale_factor),
                randf_range(min_scale.z * instance_scale_factor, max_scale.z * instance_scale_factor)
            )
            transform.basis = transform.basis.scaled(scale_factor)

            # Apply translation last
            transform.origin = Vector3(world_pos_x, height_value, world_pos_y)
            insert_into_spatial_hash(Vector3(world_pos_x, height_value, world_pos_y), current_instance_count)
            multimesh.set_instance_transform(current_instance_count, transform)
            current_instance_count += 1