@tool extends Node3D signal shape_changed signal thread_completed signal build_completed # Includes const ProtonScatterDomain := preload("./common/domain.gd") const ProtonScatterItem := preload("./scatter_item.gd") const ProtonScatterModifierStack := preload("./stack/modifier_stack.gd") const ProtonScatterPhysicsHelper := preload("./common/physics_helper.gd") const ProtonScatterShape := preload("./scatter_shape.gd") const ProtonScatterTransformList := preload("./common/transform_list.gd") const ProtonScatterUtil := preload('./common/scatter_util.gd') @export_category("ProtonScatter") @export_group("General") @export var enabled := true: set(val): enabled = val if is_ready: rebuild() @export var global_seed := 0: set(val): global_seed = val rebuild() @export var show_output_in_tree := false: set(val): show_output_in_tree = val if output_root: ProtonScatterUtil.enforce_output_root_owner(self) @export_group("Performance") @export_enum("Use Instancing:0", "Create Copies:1", "Use Particles:2")\ var render_mode := 0: set(val): render_mode = val notify_property_list_changed() if is_ready: full_rebuild.call_deferred() var use_chunks : bool = true: set(val): use_chunks = val notify_property_list_changed() if is_ready: full_rebuild.call_deferred() var chunk_dimensions := Vector3.ONE * 15.0: set(val): chunk_dimensions.x = max(val.x, 1.0) chunk_dimensions.y = max(val.y, 1.0) chunk_dimensions.z = max(val.z, 1.0) if is_ready: rebuild.call_deferred() @export var keep_static_colliders := false @export var force_rebuild_on_load := true @export var enable_updates_in_game := false @export_group("Dependency") @export var scatter_parent: NodePath: set(val): if not is_inside_tree(): scatter_parent = val return scatter_parent = NodePath() if is_instance_valid(_dependency_parent): _dependency_parent.build_completed.disconnect(rebuild) _dependency_parent = null var node = get_node_or_null(val) if not node: return var type = node.get_script() var scatter_type = get_script() if type != scatter_type: push_warning("ProtonScatter warning: Please select a ProtonScatter node as a parent dependency.") return # TODO: Check for cyclic dependency scatter_parent = val _dependency_parent = node _dependency_parent.build_completed.connect(rebuild, CONNECT_DEFERRED) @export_group("Debug", "dbg_") @export var dbg_disable_thread := false var undo_redo # EditorUndoRedoManager - Can't type this, class not available outside the editor var modifier_stack: ProtonScatterModifierStack: set(val): if modifier_stack: if modifier_stack.value_changed.is_connected(rebuild): modifier_stack.value_changed.disconnect(rebuild) if modifier_stack.stack_changed.is_connected(rebuild): modifier_stack.stack_changed.disconnect(rebuild) if modifier_stack.transforms_ready.is_connected(_on_transforms_ready): modifier_stack.transforms_ready.disconnect(_on_transforms_ready) modifier_stack = val.get_copy() # Enfore uniqueness modifier_stack.value_changed.connect(rebuild, CONNECT_DEFERRED) modifier_stack.stack_changed.connect(rebuild, CONNECT_DEFERRED) modifier_stack.transforms_ready.connect(_on_transforms_ready, CONNECT_DEFERRED) var domain: ProtonScatterDomain: set(_val): domain = ProtonScatterDomain.new() # Enforce uniqueness var items: Array = [] var total_item_proportion: int var output_root: Marker3D var transforms: ProtonScatterTransformList var editor_plugin # Holds a reference to the EditorPlugin. Used by other parts. var is_ready := false var build_version := 0 # Internal variables var _thread: Thread var _rebuild_queued := false var _dependency_parent var _physics_helper: ProtonScatterPhysicsHelper var _body_rid: RID var _collision_shapes: Array[RID] var _ignore_transform_notification = false func _ready() -> void: if Engine.is_editor_hint() or enable_updates_in_game: set_notify_transform(true) child_exiting_tree.connect(_on_child_exiting_tree) _perform_sanity_check() _discover_items() update_configuration_warnings.call_deferred() is_ready = true if force_rebuild_on_load and not is_instance_valid(_dependency_parent): full_rebuild.call_deferred() func _exit_tree(): if is_thread_running(): modifier_stack.stop_update() _thread.wait_to_finish() _thread = null _clear_collision_data() func _get_property_list() -> Array: var list := [] list.push_back({ name = "modifier_stack", type = TYPE_OBJECT, hint_string = "ScatterModifierStack", }) var chunk_usage := PROPERTY_USAGE_NO_EDITOR var dimensions_usage := PROPERTY_USAGE_NO_EDITOR if render_mode == 0 or render_mode == 2: chunk_usage = PROPERTY_USAGE_DEFAULT if use_chunks: dimensions_usage = PROPERTY_USAGE_DEFAULT list.push_back({ name = "Performance/use_chunks", type = TYPE_BOOL, usage = chunk_usage }) list.push_back({ name = "Performance/chunk_dimensions", type = TYPE_VECTOR3, usage = dimensions_usage }) return list func _get_configuration_warnings() -> PackedStringArray: var warnings := PackedStringArray() if items.is_empty(): warnings.push_back("At least one ScatterItem node is required.") if modifier_stack and not modifier_stack.does_not_require_shapes(): if domain and domain.is_empty(): warnings.push_back("At least one ScatterShape node is required.") return warnings func _notification(what): if not is_ready: return match what: NOTIFICATION_TRANSFORM_CHANGED: if _ignore_transform_notification: _ignore_transform_notification = false return _perform_sanity_check() domain.compute_bounds() rebuild.call_deferred() NOTIFICATION_ENTER_WORLD: _ignore_transform_notification = true func _set(property, value): if not Engine.is_editor_hint(): return false # Workaround to detect when the node was duplicated from the editor. if property == "transform": _on_node_duplicated.call_deferred() elif property == "Performance/use_chunks": use_chunks = value elif property == "Performance/chunk_dimensions": chunk_dimensions = value # Backward compatibility. # Convert the value of previous property "use_instancing" into the proper render_mode. elif property == "use_instancing": render_mode = 0 if value else 1 return true return false func _get(property): if property == "Performance/use_chunks": return use_chunks elif property == "Performance/chunk_dimensions": return chunk_dimensions func is_thread_running() -> bool: return _thread != null and _thread.is_started() # Used by some modifiers to retrieve a physics helper node func get_physics_helper() -> ProtonScatterPhysicsHelper: return _physics_helper # Deletes what the Scatter node generated. func clear_output() -> void: if not output_root: output_root = get_node_or_null("ScatterOutput") if output_root: remove_child(output_root) output_root.queue_free() output_root = null ProtonScatterUtil.ensure_output_root_exists(self) _clear_collision_data() func _clear_collision_data() -> void: if _body_rid.is_valid(): PhysicsServer3D.free_rid(_body_rid) _body_rid = RID() for rid in _collision_shapes: if rid.is_valid(): PhysicsServer3D.free_rid(rid) _collision_shapes.clear() # Wrapper around the _rebuild function. Clears previous output and force # a clean rebuild. func full_rebuild(): update_gizmos() if not is_inside_tree(): return _rebuild_queued = false if is_thread_running(): await _thread.wait_to_finish() _thread = null clear_output() _rebuild(true) # A wrapper around the _rebuild function. Ensure it's not called more than once # per frame. (Happens when the Scatter node is moved, which triggers the # TRANSFORM_CHANGED notification in every children, which in turn notify the # parent Scatter node back about the changes). func rebuild(force_discover := false) -> void: update_gizmos() if not is_inside_tree() or not is_ready: return if is_thread_running(): _rebuild_queued = true return force_discover = true # TMP while we fix the other issues _rebuild(force_discover) # Re compute the desired output. # This is the main function, scattering the objects in the scene. # Scattered objects are stored under a Marker3D node called "ScatterOutput" # DON'T call this function directly outside of the 'rebuild()' function above. func _rebuild(force_discover) -> void: if not enabled: _clear_collision_data() clear_output() build_completed.emit() return _perform_sanity_check() if force_discover: _discover_items() domain.discover_shapes(self) if items.is_empty() or (domain.is_empty() and not modifier_stack.does_not_require_shapes()): clear_output() push_warning("ProtonScatter warning: No items or shapes, abort") return if render_mode == 1: clear_output() # TMP, prevents raycasts in modifier to self intersect with previous output if keep_static_colliders: _clear_collision_data() if dbg_disable_thread: modifier_stack.start_update(self, domain) return if is_thread_running(): await _thread.wait_to_finish() _thread = Thread.new() _thread.start(_rebuild_threaded, Thread.PRIORITY_NORMAL) func _rebuild_threaded() -> void: # Disable thread safety, but only after 4.1 beta 3 if _thread.has_method("set_thread_safety_checks_enabled"): # Calls static method on instance, otherwise it crashes in 4.0.x @warning_ignore("static_called_on_instance") _thread.set_thread_safety_checks_enabled(false) modifier_stack.start_update(self, domain.get_copy()) func _discover_items() -> void: items.clear() total_item_proportion = 0 for c in get_children(): if is_instance_of(c, ProtonScatterItem): items.push_back(c) total_item_proportion += c.proportion update_configuration_warnings() # Creates one MultimeshInstance3D for each ScatterItem node. func _update_multimeshes() -> void: if items.is_empty(): _discover_items() var offset := 0 var transforms_count: int = transforms.size() for item in items: var count = int(round(float(item.proportion) / total_item_proportion * transforms_count)) var mmi = ProtonScatterUtil.get_or_create_multimesh(item, count) if not mmi: continue var static_body := ProtonScatterUtil.get_collision_data(item) var t: Transform3D for i in count: # Extra check because of how 'count' is calculated if (offset + i) >= transforms_count: mmi.multimesh.instance_count = i - 1 continue t = item.process_transform(transforms.list[offset + i]) mmi.multimesh.set_instance_transform(i, t) _create_collision(static_body, t) static_body.queue_free() offset += count func _update_split_multimeshes() -> void: var size = domain.bounds_local.size var splits := Vector3i.ONE splits.x = max(1, ceil(size.x / chunk_dimensions.x)) splits.y = max(1, ceil(size.y / chunk_dimensions.y)) splits.z = max(1, ceil(size.z / chunk_dimensions.z)) if items.is_empty(): _discover_items() var offset := 0 # this many transforms have been used up var transforms_count: int = transforms.size() clear_output() for item in items: var root: Node3D = ProtonScatterUtil.get_or_create_item_root(item) if not is_instance_valid(root): continue # use count number of transforms for this item var count = int(round(float(item.proportion) / total_item_proportion * transforms_count)) # create 3d array with dimensions of split_size to store the chunks' transforms var transform_chunks : Array = [] for xi in splits.x: transform_chunks.append([]) for yi in splits.y: transform_chunks[xi].append([]) for zi in splits.z: transform_chunks[xi][yi].append([]) var t_list = transforms.list.slice(offset) var aabb = ProtonScatterUtil.get_aabb_from_transforms(t_list) aabb = aabb.grow(0.1) # avoid degenerate cases var static_body := ProtonScatterUtil.get_collision_data(item) for i in count: if (offset + i) >= transforms_count: continue # both aabb and t are in mmi's local coordinates var t = item.process_transform(transforms.list[offset + i]) var p_rel = (t.origin - aabb.position) / aabb.size # Chunk index var ci = (p_rel * Vector3(splits)).floor() # Store the transform to the appropriate array transform_chunks[ci.x][ci.y][ci.z].append(t) _create_collision(static_body, t) static_body.queue_free() # Cache the mesh instance to be used for the chunks var mesh_instance: MeshInstance3D = ProtonScatterUtil.get_merged_meshes_from(item) # The relevant transforms are now ordered in chunks for xi in splits.x: for yi in splits.y: for zi in splits.z: var chunk_elements = transform_chunks[xi][yi][zi].size() if chunk_elements == 0: continue var mmi = ProtonScatterUtil.get_or_create_multimesh_chunk( item, mesh_instance, Vector3i(xi, yi, zi), chunk_elements) if not mmi: continue # Use the eventual aabb as origin # The multimeshinstance needs to be centered where the transforms are # This matters because otherwise the visibility range fading is messed up var center = ProtonScatterUtil.get_aabb_from_transforms(transform_chunks[xi][yi][zi]).get_center() mmi.transform.origin = center var t: Transform3D for i in chunk_elements: t = transform_chunks[xi][yi][zi][i] t.origin -= center mmi.multimesh.set_instance_transform(i, t) mesh_instance.queue_free() offset += count func _update_duplicates() -> void: var offset := 0 var transforms_count: int = transforms.size() for item in items: var count := int(round(float(item.proportion) / total_item_proportion * transforms_count)) var root: Node3D = ProtonScatterUtil.get_or_create_item_root(item) var child_count := root.get_child_count() for i in count: if (offset + i) >= transforms_count: return var instance if i < child_count: # Grab an instance from the pool if there's one available instance = root.get_child(i) else: instance = _create_instance(item, root) if not instance: break var t: Transform3D = item.process_transform(transforms.list[offset + i]) instance.transform = t ProtonScatterUtil.set_visibility_layers(instance, item.visibility_layers) # Delete the unused instances left in the pool if any if count < child_count: for i in (child_count - count): root.get_child(-1).queue_free() offset += count func _update_particles_system() -> void: var offset := 0 var transforms_count: int = transforms.size() for item in items: var count := int(round(float(item.proportion) / total_item_proportion * transforms_count)) var particles = ProtonScatterUtil.get_or_create_particles(item) if not particles: continue particles.visibility_aabb = AABB(domain.bounds_local.min, domain.bounds_local.size) particles.amount = count var static_body := ProtonScatterUtil.get_collision_data(item) var t: Transform3D for i in count: if (offset + i) >= transforms_count: particles.amount = i - 1 return t = item.process_transform(transforms.list[offset + i]) particles.emit_particle( t, Vector3.ZERO, Color.WHITE, Color.BLACK, GPUParticles3D.EMIT_FLAG_POSITION | GPUParticles3D.EMIT_FLAG_ROTATION_SCALE) _create_collision(static_body, t) offset += count # Creates collision data with the Physics server directly. # This does not create new nodes in the scene tree. This also means you can't # see these colliders, even when enabling "Debug > Visible collision shapes". func _create_collision(body: StaticBody3D, t: Transform3D) -> void: if not keep_static_colliders or render_mode == 1: return # Create a static body if not _body_rid.is_valid(): _body_rid = PhysicsServer3D.body_create() PhysicsServer3D.body_set_mode(_body_rid, PhysicsServer3D.BODY_MODE_STATIC) PhysicsServer3D.body_set_state(_body_rid, PhysicsServer3D.BODY_STATE_TRANSFORM, global_transform) PhysicsServer3D.body_set_space(_body_rid, get_world_3d().space) for c in body.get_children(): if c is CollisionShape3D: var shape_rid: RID var data: Variant if c.shape is SphereShape3D: shape_rid = PhysicsServer3D.sphere_shape_create() data = c.shape.radius elif c.shape is BoxShape3D: shape_rid = PhysicsServer3D.box_shape_create() data = c.shape.size / 2.0 elif c.shape is CapsuleShape3D: shape_rid = PhysicsServer3D.capsule_shape_create() data = { "radius": c.shape.radius, "height": c.shape.height, } elif c.shape is CylinderShape3D: shape_rid = PhysicsServer3D.cylinder_shape_create() data = { "radius": c.shape.radius, "height": c.shape.height, } elif c.shape is ConcavePolygonShape3D: shape_rid = PhysicsServer3D.concave_polygon_shape_create() data = { "faces": c.shape.get_faces(), "backface_collision": c.shape.backface_collision, } elif c.shape is ConvexPolygonShape3D: shape_rid = PhysicsServer3D.convex_polygon_shape_create() data = c.shape.points elif c.shape is HeightMapShape3D: shape_rid = PhysicsServer3D.heightmap_shape_create() var min_height := 9999999.0 var max_height := -9999999.0 for v in c.shape.map_data: min_height = v if v < min_height else min_height max_height = v if v > max_height else max_height data = { "width": c.shape.map_width, "depth": c.shape.map_depth, "heights": c.shape.map_data, "min_height": min_height, "max_height": max_height, } elif c.shape is SeparationRayShape3D: shape_rid = PhysicsServer3D.separation_ray_shape_create() data = { "length": c.shape.length, "slide_on_slope": c.shape.slide_on_slope, } else: print_debug("Scatter - Unsupported collision shape: ", c.shape) continue PhysicsServer3D.shape_set_data(shape_rid, data) PhysicsServer3D.body_add_shape(_body_rid, shape_rid, t * c.transform) _collision_shapes.push_back(shape_rid) func _create_instance(item: ProtonScatterItem, root: Node3D): if not item: return null var instance = item.get_item() if not instance: return null instance.visible = true root.add_child.bind(instance, true).call_deferred() if show_output_in_tree: # We have to use a lambda here because ProtonScatterUtil isn't an # actual class_name, it's a const, which makes it impossible to reference # the callable, (but we can still call it) var defer_ownership := func(i, o): ProtonScatterUtil.set_owner_recursive(i, o) defer_ownership.bind(instance, get_tree().get_edited_scene_root()).call_deferred() return instance # Enforce the Scatter node has its required variables set. func _perform_sanity_check() -> void: if not modifier_stack: modifier_stack = ProtonScatterModifierStack.new() modifier_stack.just_created = true if not domain: domain = ProtonScatterDomain.new() domain.discover_shapes(self) if not is_instance_valid(_physics_helper): _physics_helper = ProtonScatterPhysicsHelper.new() _physics_helper.name = "PhysicsHelper" add_child(_physics_helper, true, INTERNAL_MODE_BACK) # Retrigger the parent setter, in case the parent node no longer exists or changed type. scatter_parent = scatter_parent # Remove output coming from the source node to avoid linked multimeshes or # other unwanted side effects func _on_node_duplicated() -> void: clear_output() func _on_child_exiting_tree(node: Node) -> void: if node is ProtonScatterShape or node is ProtonScatterItem: rebuild.bind(true).call_deferred() # Called when the modifier stack is done generating the full transform list func _on_transforms_ready(new_transforms: ProtonScatterTransformList) -> void: if is_thread_running(): await _thread.wait_to_finish() _thread = null _clear_collision_data() if _rebuild_queued: _rebuild_queued = false rebuild.call_deferred() return transforms = new_transforms if not transforms or transforms.is_empty(): clear_output() update_gizmos() return match render_mode: 0: if use_chunks: _update_split_multimeshes() else: _update_multimeshes() 1: _update_duplicates() 2: _update_particles_system() update_gizmos() build_version += 1 if is_inside_tree(): await get_tree().process_frame build_completed.emit()