Files
fps_project_1/addons/proton_scatter/src/scatter.gd

735 lines
20 KiB
GDScript

@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()