built more assets and started playing with foliage painting

This commit is contained in:
derek
2024-12-04 17:02:46 -06:00
parent dd960cc00e
commit 478e2822d2
359 changed files with 34172 additions and 178 deletions

View File

@@ -0,0 +1,613 @@
@tool
extends Node3D
#-------------------------------------------------------------------------------
# Handles managing OctreeManager objects and changes applied when painting
# Instigates updates to OctreeManager MutliMeshInstance (MMI) objects
# To show the correct LOD variant when moving closer/further to plants
#-------------------------------------------------------------------------------
# It's worth considering to split this object into mutliple:
# Octree management
# Updating positions of individual plants (through painting)
# Threaded updates to LODs (if possible)
# However, these functions are very closely related, so maybe I'm overthinking this
const Logger = preload("../utility/logger.gd")
const Globals = preload("../utility/globals.gd")
const FunLib = preload("../utility/fun_lib.gd")
const Greenhouse_Plant = preload("../greenhouse/greenhouse_plant.gd")
const Toolshed_Brush = preload("../toolshed/toolshed_brush.gd")
const PaintingChanges = preload("painting_changes.gd")
const MMIOctreeManager = preload("mmi_octree/mmi_octree_manager.gd")
const UndoRedoInterface = preload("../utility/undo_redo_interface.gd")
const StrokeHandler = preload("stroke_handler/stroke_handler.gd")
const SH_Paint = preload("stroke_handler/sh_paint.gd")
const SH_Erase = preload("stroke_handler/sh_erase.gd")
const SH_Single = preload("stroke_handler/sh_single.gd")
const SH_Reapply = preload("stroke_handler/sh_reapply.gd")
const SH_Manual = preload("stroke_handler/sh_manual.gd")
var MMI_container:Node3D = null
var octree_managers:Array
var gardening_collision_mask:int = 0
# A manual override fot the camera (mainly used in Editor)
var active_camera_override:Camera3D = null
var active_stroke_handler:StrokeHandler = null
var active_painting_changes:PaintingChanges = null
# Threading LOD updates is not working for some reason. Gives error "Condition "!multimesh" is true." when closing a scene
# This might be related to https://github.com/godotengine/godot/pull/54650
# Possibly, there are some leftover references after closing a scene and idk how I'm supposed to clean them up
#var mutex_placement:Mutex = null
#var thread_instance_placement:Thread
#var semaphore_instance_placement:Semaphore
#var exit_instance_placement:bool
#var done_instance_placement:bool
var _undo_redo = null
var debug_redraw_requested_managers:Array = []
var logger = null
signal req_debug_redraw(octree_managers, requested_indexes)
signal member_count_updated(octree_index, new_count)
#-------------------------------------------------------------------------------
# Lifecycle and initialization
#-------------------------------------------------------------------------------
func _init():
set_meta("class", "Arborist")
func _ready():
# Workaround below fixes the problem of instanced nodes "sharing" exported arrays (and resources inside them)
# When instanced in the editor
# See https://github.com/godotengine/godot/issues/16478
# This fix is needed, so we can have multiple instances of same terrain with same plant placement
# But have LOD switch independently for each of these terrains
if octree_managers == null:
octree_managers = []
else:
var octree_managers_copy = octree_managers.duplicate()
octree_managers = []
for octree_manager in octree_managers_copy:
octree_managers.append(octree_manager.duplicate_tree())
logger = Logger.get_for(self, name)
owner = get_tree().get_edited_scene_root()
MMI_container = get_node_or_null("MMI_container")
if MMI_container && !is_instance_of(MMI_container, Node3D):
remove_child(MMI_container)
MMI_container.queue_free()
MMI_container = null
if !MMI_container:
FunLib.free_children(self)
MMI_container = Node3D.new()
MMI_container.name = "MMI_container"
add_child(MMI_container)
MMI_container.owner = owner
for octree_manager in octree_managers:
octree_manager.restore_after_load(MMI_container)
func _enter_tree():
pass
# thread_instance_placement = Thread.new()
# mutex_placement = Mutex.new()
# semaphore_instance_placement = Semaphore.new()
#
# exit_instance_placement = false
# done_instance_placement = true
#
# thread_instance_placement.start(Callable(self,"thread_update_LODs"))
func _notification(what):
match what:
NOTIFICATION_PREDELETE:
for octree_manager in octree_managers:
octree_manager.free_refs()
func _exit_tree():
pass
# This is... weird
# Apparently I need to free any Resources that are left after closing a scene
# I'm not exactly sure why
# And it *might* be destructive to do so in editor
#if Engine.is_editor_hint(): return
#for octree_manager in octree_managers:
#octree_manager.destroy()
# mutex_placement.lock()
# exit_instance_placement = true
# done_instance_placement = false
# mutex_placement.unlock()
#
# semaphore_instance_placement.post()
# thread_instance_placement.wait_to_finish()
#
# thread_instance_placement = null
# mutex_placement = null
# semaphore_instance_placement = null
# Expected to be called inside or after a parent's _ready()
func setup(plant_states):
verify_all_plants(plant_states)
# Restore all OctreeManager objects after load
# Create missing ones
func verify_all_plants(plant_states_to_verify:Array):
if !is_inside_tree(): return
debug_print_lifecycle("verifying for plant_states: " + str(plant_states_to_verify))
for plant_index in range(0, plant_states_to_verify.size()):
if octree_managers.size() - 1 >= plant_index:
octree_managers[plant_index].restore_after_load(MMI_container)
connect_octree_manager(octree_managers[plant_index])
else:
add_plant_octree_manager(plant_states_to_verify[plant_index], plant_index)
#-------------------------------------------------------------------------------
# Management of plant OctreeManager objects
#-------------------------------------------------------------------------------
# Instigate the OctreeManager adding process in response to an external signal
func on_plant_added(plant_state, plant_index:int):
debug_print_lifecycle("plant: %s added at plant_index %d" % [str(plant_state), plant_index])
add_plant_octree_manager(plant_state, plant_index)
request_debug_redraw_from_index(plant_index)
call_deferred("emit_member_count", plant_index)
# Instigate the OctreeManager removal process in response to an external signal
func on_plant_removed(plant_state, plant_index:int):
debug_print_lifecycle("plant: %s removed at plant_index %d" % [str(plant_state), plant_index])
remove_plant_octree_manager(plant_state, plant_index)
request_debug_redraw_from_index(plant_index)
# Up-to-date LOD variants of an OctreeManager
func on_LOD_variant_added(plant_index:int, mesh_index:int, LOD_variant):
debug_print_lifecycle("LOD Variant: %s added at plant_index %d and mesh_index %d" % [str(LOD_variant), plant_index, mesh_index])
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.insert_LOD_variant(LOD_variant, mesh_index)
octree_manager.set_LODs_to_active_index()
# Up-to-date LOD variants of an OctreeManager
func on_LOD_variant_removed(plant_index:int, mesh_index:int):
debug_print_lifecycle("LOD Variant: removed at plant_index %d and mesh_index %d" % [plant_index, mesh_index])
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.remove_LOD_variant(mesh_index)
octree_manager.set_LODs_to_active_index()
# Up-to-date LOD variants of an OctreeManager
func on_LOD_variant_set(plant_index:int, mesh_index:int, LOD_variant):
debug_print_lifecycle("LOD Variant: %s set at plant_index %d and mesh_index %d" % [str(LOD_variant), plant_index, mesh_index])
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.set_LOD_variant(LOD_variant, mesh_index)
octree_manager.set_LODs_to_active_index()
# Up-to-date LOD variants of an OctreeManager
func on_LOD_variant_prop_changed_spawned_spatial(plant_index:int, mesh_index:int, LOD_variant):
debug_print_lifecycle("LOD Variant: %s spawned spatial changed at plant_index %d and mesh_index %d" % [str(LOD_variant), plant_index, mesh_index])
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.set_LOD_variant_spawned_spatial(LOD_variant, mesh_index)
octree_manager.reset_member_spatials()
# Make sure LODs in OctreeNodes correspond to their active_LOD_index
# This is the preffered way to 'refresh' MMIs inside OctreeNodes
func set_LODs_to_active_index(plant_index:int):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.set_LODs_to_active_index()
# Initialize an OctreeManager for a given plant
func add_plant_octree_manager(plant_state, plant_index:int):
var octree_manager:MMIOctreeManager = MMIOctreeManager.new()
octree_manager.init_octree(
plant_state.plant.mesh_LOD_max_capacity, plant_state.plant.mesh_LOD_min_size,
Vector3.ZERO, MMI_container, plant_state.plant.mesh_LOD_min_size)
octree_manager.LOD_max_distance = plant_state.plant.mesh_LOD_max_distance
octree_manager.LOD_kill_distance = plant_state.plant.mesh_LOD_kill_distance
octree_managers.insert(plant_index, octree_manager)
for mesh_index in range (0, plant_state.plant.mesh_LOD_variants.size()):
var LOD_variant = plant_state.plant.mesh_LOD_variants[mesh_index]
octree_manager.insert_LOD_variant(LOD_variant, mesh_index)
connect_octree_manager(octree_manager)
# Remove an OctreeManager for a given plant
func remove_plant_octree_manager(plant_state, plant_index:int):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
disconnect_octree_manager(octree_manager)
octree_manager.prepare_for_removal()
octree_managers.remove_at(plant_index)
# A request to reconfigure an octree
func reconfigure_octree(plant_state, plant_index:int):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.rebuild_octree(plant_state.plant.mesh_LOD_max_capacity, plant_state.plant.mesh_LOD_min_size)
# A request to recenter an octree
func recenter_octree(plant_state, plant_index:int):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.recenter_octree()
# Connect all OctreeManager signals
func connect_octree_manager(octree_manager:MMIOctreeManager):
if !octree_manager.req_debug_redraw.is_connected(on_req_debug_redraw):
octree_manager.req_debug_redraw.connect(on_req_debug_redraw.bind(octree_manager))
# Disconnect all OctreeManager signals
func disconnect_octree_manager(octree_manager:MMIOctreeManager):
if octree_manager.req_debug_redraw.is_connected(on_req_debug_redraw):
octree_manager.req_debug_redraw.disconnect(on_req_debug_redraw)
#-------------------------------------------------------------------------------
# Setting/updating variables to outside signals
#-------------------------------------------------------------------------------
# To be called by a signal from Greenhouse_PlantState -> Gardener -> Arborist
func update_plant_LOD_max_distance(plant_index, val):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.LOD_max_distance = val
# To be called by a signal from Greenhouse_PlantState -> Gardener -> Arborist
func update_plant_LOD_kill_distance(plant_index, val):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
octree_manager.LOD_kill_distance = val
# To be called by a signal from Gardener -> Arborist
func set_gardening_collision_mask(_gardening_collision_mask):
gardening_collision_mask = _gardening_collision_mask
#-------------------------------------------------------------------------------
# Application of brushes and transform generation
#-------------------------------------------------------------------------------
# Create PaintingChanges and a StrokeHandler for this specific brush stroke
func on_stroke_started(brush:Toolshed_Brush, plant_states:Array):
var space_state := get_world_3d().direct_space_state
var camera = get_camera_3d()
active_painting_changes = PaintingChanges.new()
match brush.behavior_brush_type:
brush.BrushType.PAINT:
active_stroke_handler = SH_Paint.new(brush, plant_states, octree_managers, space_state, camera, gardening_collision_mask)
brush.BrushType.ERASE:
active_stroke_handler = SH_Erase.new(brush, plant_states, octree_managers, space_state, camera, gardening_collision_mask)
brush.BrushType.SINGLE:
active_stroke_handler = SH_Single.new(brush, plant_states, octree_managers, space_state, camera, gardening_collision_mask)
brush.BrushType.REAPPLY:
active_stroke_handler = SH_Reapply.new(brush, plant_states, octree_managers, space_state, camera, gardening_collision_mask)
_:
active_stroke_handler = StrokeHandler.new(brush, plant_states, octree_managers, space_state, camera, gardening_collision_mask)
debug_print_lifecycle("Stroke %s started" % [active_stroke_handler.get_meta("class")])
# Draw instances at the new brush position
# And collect them all into one PaintingChanges object
func on_stroke_updated(brush_data:Dictionary):
assert(active_stroke_handler)
assert(active_painting_changes)
debug_print_lifecycle("Stroke %s updating..." % [active_stroke_handler.get_meta("class")])
var msec_start = FunLib.get_msec()
# mutex_placement.lock()
var changes = active_stroke_handler.get_stroke_update_changes(brush_data, global_transform)
apply_stroke_update_changes(changes)
# mutex_placement.unlock()
active_painting_changes.append_changes(changes)
var msec_end = FunLib.get_msec()
debug_print_lifecycle("Total stroke %s update took: %s" % [active_stroke_handler.get_meta("class"), FunLib.msec_to_time(msec_end - msec_start)])
# Use collected PaintingChanges to add UndoRedo actions
func on_stroke_finished():
assert(active_stroke_handler)
assert(active_painting_changes)
UndoRedoInterface.create_action(_undo_redo, "Apply Arborist MMI changes", 0, false, self)
UndoRedoInterface.add_do_method(_undo_redo, _action_apply_changes.bind(active_painting_changes))
UndoRedoInterface.add_undo_method(_undo_redo, _action_apply_changes.bind(active_painting_changes.pop_opposite()))
# We toggle this flag to avoid reapplying already commited changes all over again
UndoRedoInterface.commit_action(_undo_redo, false)
debug_print_lifecycle("Stroke %s finished, total changes made: %d" % [active_stroke_handler.get_meta("class"), active_painting_changes.changes.size()])
active_stroke_handler = null
active_painting_changes = null
# A wrapper for applying changes to avoid reaplying UndoRedo actions on commit_action()
func _action_apply_changes(changes):
# mutex_placement.lock()
apply_stroke_update_changes(changes)
# mutex_placement.unlock()
#-------------------------------------------------------------------------------
# Updating OctreeManager objects
#-------------------------------------------------------------------------------
# Replace LOD_Variants inside of a shared array owned by this OctreeManager
func refresh_octree_shared_LOD_variants(plant_index:int, LOD_variants:Array):
if octree_managers.size() > plant_index:
octree_managers[plant_index].set_LOD_variants(LOD_variants)
# Add changes to corresponding OctreeManager queues
# Then process them all at once
func apply_stroke_update_changes(changes:PaintingChanges):
debug_print_lifecycle(" Applying %d stroke changes" % [changes.changes.size()])
var msec_start = FunLib.get_msec()
var affected_octree_managers := []
for change in changes.changes:
var octree_manager:MMIOctreeManager = octree_managers[change.at_index]
match change.change_type:
0:
octree_manager.queue_placeforms_add(change.new_val)
1:
octree_manager.queue_placeforms_remove(change.new_val)
2:
octree_manager.queue_placeforms_set(change.new_val)
if !affected_octree_managers.has(change.at_index):
affected_octree_managers.append(change.at_index)
for index in affected_octree_managers:
var octree_manager = octree_managers[index]
octree_manager.process_queues()
emit_member_count(index)
var msec_end = FunLib.get_msec()
debug_print_lifecycle(" Applying stroke changes took: %s" % [FunLib.msec_to_time(msec_end - msec_start)])
func emit_member_count(octree_index:int):
member_count_updated.emit(octree_index, octree_managers[octree_index].root_octree_node.get_nested_member_count())
func _process(delta):
# try_update_LODs()
if visible:
update_LODs()
request_debug_redraw()
# Trigger a threaded LOD update
#func try_update_LODs():
# var should_post = false
#
# mutex_placement.lock()
# if done_instance_placement:
# done_instance_placement = false
# should_post = true
# mutex_placement.unlock()
#
# if should_post:
# semaphore_instance_placement.post()
# A function that carries out threaded LOD updates
#func thread_update_LODs(arg = null):
# while true:
# semaphore_instance_placement.wait()
#
# var should_exit = false
# mutex_placement.lock()
# if exit_instance_placement:
# should_exit = true
# mutex_placement.unlock()
# if should_exit: break
#
# mutex_placement.lock()
# update_LODs()
# done_instance_placement = true
# mutex_placement.unlock()
# Instigate LOD updates in OctreeManager objects
func update_LODs():
var camera_to_use:Camera3D = get_camera_3d()
if camera_to_use:
var camera_pos := camera_to_use.global_transform.origin
for octree_manager in octree_managers:
octree_manager.update_LODs(camera_pos, global_transform)
# This exists to properly render instances in editor even if there is no forwarded_input()
else:
for octree_manager in octree_managers:
octree_manager.update_LODs_no_camera()
# Add instances as a batch (mostly, as a result of importing Greenhouse data)
func batch_add_instances(placeforms: Array, plant_idx: int):
active_painting_changes = PaintingChanges.new()
active_stroke_handler = SH_Manual.new()
for placeform in placeforms:
active_stroke_handler.add_instance_placeform(placeform, plant_idx, active_painting_changes)
apply_stroke_update_changes(active_painting_changes)
on_stroke_finished()
#-------------------------------------------------------------------------------
# Input
#-------------------------------------------------------------------------------
func _unhandled_input(event):
if is_instance_of(event, InputEventKey) && !event.pressed:
if event.keycode == debug_get_dump_tree_key():
for octree_manager in octree_managers:
logger.info(octree_manager.root_octree_node.debug_dump_tree())
#-------------------------------------------------------------------------------
# Utility
#-------------------------------------------------------------------------------
# A hack to get editor camera
# active_camera_override should be set by a Gardener
# In-game just gets an active viewport's camera
func get_camera_3d():
if is_instance_valid(active_camera_override):
return active_camera_override
else:
active_camera_override = null
return get_viewport().get_camera_3d()
#-------------------------------------------------------------------------------
# Property export
#-------------------------------------------------------------------------------
func _get(property):
match property:
"octree_managers":
return octree_managers
return null
func _set(property, val):
var return_val = true
match property:
"octree_managers":
octree_managers = val
_:
return_val = false
return return_val
func _get_property_list():
var props := [
{
"name": "octree_managers",
"type": TYPE_ARRAY,
"usage": PROPERTY_USAGE_DEFAULT,
"hint": PROPERTY_HINT_NONE
},
]
return props
func _get_configuration_warnings():
var MMI_container_check = get_node("MMI_container")
if MMI_container_check && is_instance_of(MMI_container_check, Node3D):
return ""
else:
return "Arborist is missing a valid MMI_container child\nSince it should be created automatically, try reloading a scene or recreating a Gardener"
func add_child(node:Node, legible_unique_name:bool = false, internal:InternalMode=0) -> void:
super.add_child(node, legible_unique_name)
update_configuration_warnings()
#-------------------------------------------------------------------------------
# Debug
#-------------------------------------------------------------------------------
# A wrapper to request debug redraw for a specific OctreeManager
func request_debug_redraw_from_index(plant_index):
for index in range(plant_index, octree_managers.size()):
on_req_debug_redraw(octree_managers[index])
# Add an OctreeManager to the debug redraw waiting list
func on_req_debug_redraw(octree_manager:MMIOctreeManager):
if debug_redraw_requested_managers.has(octree_manager): return
debug_redraw_requested_managers.append(octree_manager)
# Request a debug redraw for all OctreeManager objects in a waiting list using a signal
# We manually get all indexes here instead of when an OctreeManager is added to the waiting list
# Because we expect the order of managers might change and indexes will become inaccurate
# Typically called from _process()
func request_debug_redraw():
if debug_redraw_requested_managers.is_empty(): return
var requested_indexes := []
for octree_manager in debug_redraw_requested_managers:
requested_indexes.append(octree_managers.find(octree_manager))
if !requested_indexes.is_empty():
req_debug_redraw.emit(octree_managers)
debug_redraw_requested_managers = []
func debug_get_dump_tree_key():
var key = FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/dump_all_octrees_key", 0)
return Globals.index_to_enum(key, Globals.KeyboardKey)
func debug_print_lifecycle(string:String):
if !FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/arborist_log_lifecycle", false): return
logger.info(string)

View File

@@ -0,0 +1,405 @@
@tool
extends RefCounted
#-------------------------------------------------------------------------------
# A helper class that creates a placement area (grid)
# And makes sure all instances of a given plant are evenly spaced
#-------------------------------------------------------------------------------
# It uses a 2D grid-based algorithm to maintain both:
# close-to-desired instance density
# an even instance distribution
# This article was used as a main reference (grid uses a simple logic and gives good-enough results)
# https://www.gamedeveloper.com/disciplines/random-scattering-creating-realistic-landscapes
# However, I was not able to derive a solution that creates a grid that evenly projects on a surface of any angle
# When you approach 45 degrees you start seeing bigger spacing, which becomes unusable by 90 degrees
# So I thought of trying to project a 3D grid, but that raised a lot of questions on it's own
# This is still an open question really
# So I dug through some code online and decided on a system I discuss in BrushPlacementArea section
# Algorithm:
# Use the brush radius, position and the surface normal under it's center to describe a flat circle in 3D space
# This circle provides a plane and bounds for us to generate the transforms. Most other operations operate in 2D space aligned to said plane
# Generate a virtual 2D grid with distance between points representing the plant density (less density == bigger distance)
# Find all "overlapping" plants of the same type in the sphere's radius
# We only check if the placements (origin positions) of plants are within the sphere. No actual boundary tests
# Add a max instance check (how many instances total can fit in a circle) and pad the max distance from center slightly OUTSIDE the circle
# Last one is needed to prevent spawning at half-distance near the circle's edge
# Project the overlaps to our plane and snap them to the closest point on the grid
# These grid points become occupied and cannot spawn a new instance
# All the points that remain will spawn an instance
# Add a random jitter to each placement (it should be smaller than 0.5 to prevent occasional overlap)
# Get the raycast start and end positions
# They are aligned to the plane's normal and limit_length at the sphere's bounds
# Last one prevents out raycast from going outside the sphere
# Back in the StrokeHandler, use these positions to raycast to surface and determine actual placement positions
# The problem with this method is that the grid CAN and WILL be generated at uneven intervals as the brush moves
# An example, where "( )" - last grid, "[ ]" - current grid, "P" - point
# (P)(P)
# (P)(P) <- align
# [P][P] <- align
# [P][P]
# /\
# do not align
# That leads to some points spawning closer than they should
# While not ideal, this is somewhat mitigated with max instances check
# And snapping brush to the virtual grid
# Ideally I would like the grid to be independent from the brush position
# But I don't know how to accurately project 3D grid points on an angled surface while keeping them EVENLY SPACED no matter the angle
# 2D grids give me a close enough result
# TODO Write an article discussing this method that is actually readable by a normal human being
const Globals = preload("../utility/globals.gd")
const FunLib = preload("../utility/fun_lib.gd")
const Logger = preload("../utility/logger.gd")
const MMIOctreeManager = preload("mmi_octree/mmi_octree_manager.gd")
const MMIOctreeNode = preload("mmi_octree/mmi_octree_node.gd")
var sphere_pos:Vector3 = Vector3.ZERO
var sphere_radius:float = 0.0
var sphere_diameter:float = 0.0
var plane_axis_vectors:Array = []
var point_distance:float = 0.0
var grid_linear_size:int = 0
var grid_offset:float = 0.0
var placement_grid:Array = []
var diagonal_dilation:float = 1.0
var placement_overlaps:Array = []
var overlapped_member_data:Array = []
var overdense_member_data:Array = []
var raycast_positions:Array = []
var max_placements_allowed:int = 0
var jitter_fraction:float = 0.0
var logger = null
#-------------------------------------------------------------------------------
# Initialization
#-------------------------------------------------------------------------------
func _init(__sphere_pos:Vector3,__sphere_radius:float,__plane_normal:Vector3,__jitter_fraction:float = 0.6):
logger = Logger.get_for(self)
sphere_pos = __sphere_pos
sphere_radius = __sphere_radius
sphere_diameter = sphere_radius * 2.0
jitter_fraction = __jitter_fraction * 0.5
init_axis_vectors(__plane_normal)
# Find two perpedicular vectors, so all 3 describe a plane/flat circle
func init_axis_vectors(source:Vector3):
var nx: float = abs(source.x)
var ny: float = abs(source.y)
var nz: float = abs(source.z)
var axis1:Vector3
var axis2:Vector3
if nz > nx && nz > ny:
axis1 = Vector3(1,0,0)
else:
axis1 = Vector3(0,0,1)
axis1 = (axis1 - source * axis1.dot(source)).normalized()
axis2 = axis1.cross(source).normalized()
plane_axis_vectors = [source, axis1, axis2]
#-------------------------------------------------------------------------------
# Grid data
#-------------------------------------------------------------------------------
# Initialize vital grid data
# Grid is used to maintain plant spacing defined by density
# Grid data helps to work with that spacing inside a given brush sphere
func init_grid_data(plant_density:float, brush_strength:float):
point_distance = get_point_distance(plant_density, brush_strength)
# Find how many linear segments fit within a diameter
if point_distance > 0.0:
grid_linear_size = floor(sphere_diameter / point_distance)
# Then offset grid_linear_size by half a segment if it's an odd number
if grid_linear_size % 2 == 0:
grid_linear_size += 1
point_distance *= (sphere_diameter / point_distance * 1) / (sphere_diameter / point_distance)
# Subtract 1 because number of segments on a line is always 1 less than the number of points
# Get max length all points occupy and take half of it
grid_offset = (grid_linear_size - 1) * point_distance * -0.5
else:
grid_linear_size = 0
grid_offset = 0.0
init_placement_grid()
debug_visualize_placement_grid("Grid initialized:")
debug_log_grid("point_distance: %f, grid_linear_size: %d, grid_offset: %f" % [point_distance, grid_linear_size, grid_offset])
# Initialize the grid itself excluding points outside the sphere/plane circle
func init_placement_grid():
placement_grid = []
var radius_squared = pow(sphere_radius, 2)
for x in range(0, grid_linear_size):
placement_grid.append([])
placement_grid[x].resize(grid_linear_size)
for y in range(0, grid_linear_size):
var local_pos = grid_coord_to_local_pos(Vector2(x, y))
var dist = local_pos.length_squared()
if dist <= radius_squared:
placement_grid[x][y] = true
max_placements_allowed += 1
else:
placement_grid[x][y] = false
#-------------------------------------------------------------------------------
# Overlaps
#-------------------------------------------------------------------------------
# Initialize placements overlapping with the given sphere
# Resulting placements will be in range (-sphere_radius, sphere_radius)
# Edge extension is used to grad one more/one less loop of overlaps
# Typically used to grab members that were displaced with jitter_fraction
# And happen to be outside our sphere_radius, but still belong to overlapped grid cells
func init_placement_overlaps(octree_manager:MMIOctreeManager, edge_extension:int = 0):
placement_overlaps = []
overlapped_member_data = []
var max_dist = sphere_radius + point_distance * edge_extension
get_overlap_member_data(octree_manager.root_octree_node, max_dist)
# Recursively calculate placement overlaps in an octree
func get_overlap_member_data(octree_node:MMIOctreeNode, max_dist:float):
var max_bounds_to_center_dist = octree_node.max_bounds_to_center_dist
var dist_node: float = clamp((octree_node.center_pos - sphere_pos).length() - max_bounds_to_center_dist - sphere_radius, 0.0, INF)
if dist_node >= max_dist: return
if !octree_node.is_leaf:
for child_node in octree_node.child_nodes:
get_overlap_member_data(child_node, max_dist)
else:
var max_dist_squared = pow(max_dist, 2.0)
var node_address = octree_node.get_address()
for member_idx in range(0, octree_node.member_count()):
var placeform = octree_node.get_placeform(member_idx)
var placement = placeform[0] - sphere_pos
var dist_squared = placement.length_squared()
if dist_squared <= max_dist_squared:
placement_overlaps.append(placement)
overlapped_member_data.append({"node_address": node_address, "member_idx": member_idx})
# Get all overlaps that don't fit into the density grid
func get_placeforms_for_deletion():
if overdense_member_data.size() <= 0: return []
var placeforms_for_deletion := []
# Don't delete more than is exessive or actually overdense
var deletion_count: int = min(overlapped_member_data.size() - max_placements_allowed, overdense_member_data.size())
var deletion_increment := float(deletion_count) / float(overdense_member_data.size())
var deletion_progress := 0.0
if deletion_increment <= 0: return []
# # This part picks every [N]th member for deletion
# # [N] is defined by deletion_increment and can be fractional
# # E.g. we can delete an approximation of every 0.3th, 0.75th, etc. element
for index in range(0, overdense_member_data.size()):
deletion_progress += deletion_increment
if deletion_progress >= 1.0:
deletion_progress -= 1.0
placeforms_for_deletion.append(overdense_member_data[index])
return placeforms_for_deletion
# Mark grid coordinates as invalid if they are already occupied
# (if there is a plant origin near a grid point)
func invalidate_occupied_points():
for placement_index in range(0, placement_overlaps.size()):
var placement_overlap = placement_overlaps[placement_index]
# Project a local-space overlap to our plane
var projected_overlap = Vector2(plane_axis_vectors[1].dot(placement_overlap), plane_axis_vectors[2].dot(placement_overlap))
var grid_coord = local_pos_to_grid_coord(projected_overlap)
grid_coord = Vector2(clamp(grid_coord.x, 0.0, grid_linear_size - 1), clamp(grid_coord.y, 0.0, grid_linear_size - 1))
if placement_grid.size() > 0 && placement_grid[grid_coord.x][grid_coord.y]:
invalidate_self_or_neighbor(grid_coord)
else:
overdense_member_data.append(overlapped_member_data[placement_index])
func invalidate_self_or_neighbor(grid_coord:Vector2):
if placement_grid[grid_coord.x][grid_coord.y]:
placement_grid[grid_coord.x][grid_coord.y] = false
# Because our grid depends on brush position, sometimes we have cells that appear unoccupied
# (Due to placements being *slightly* outside the cell)
# So we nudge our overlaps one cell in whatever direction
# CURRENT VERSION DOESN'T SOLVE THE ISSUE
# THIS NEEDS TO APPROXIMATE THE BEST FITTING CELL BY COMPARING DISTANCES
# AND AT THIS POINT IT'S TOO MANY CALCULATIONS
# SO WE SUSPEND THIS FOR NOW
# else:
# var new_grid_coord
# var offset_lookup = [
# Vector2(-1,-1), Vector2(0,-1), Vector2(1,-1),
# Vector2(-1,0), Vector2(1,0),
# Vector2(-1,1), Vector2(0,1), Vector2(1,1)]
# for offset in offset_lookup:
# new_grid_coord = grid_coord + offset
# if placement_grid[new_grid_coord.x][new_grid_coord.y]:
# placement_grid[new_grid_coord.x][new_grid_coord.y] = false
# break
#-------------------------------------------------------------------------------
# Raycast setup
#-------------------------------------------------------------------------------
# Get raycast start and end positions that go through the whole sphere
# Rays will start and end at the opposite sphere borders
# And will have the same direction as the brush normal
func get_valid_raycast_positions() -> Array:
invalidate_occupied_points()
debug_visualize_placement_grid("Grid invalidated occupied points:")
generate_raycast_positions()
return raycast_positions
# Generate a randomized placement for each point on the grid
# And deproject it onto our brush sphere using the surface normal
func generate_raycast_positions():
raycast_positions = []
for x in range(0, grid_linear_size):
for y in range(0, grid_linear_size):
if !placement_grid[x][y]: continue
var grid_coord := Vector2(x, y)
var UV_jitter := Vector2(randf_range(-jitter_fraction, jitter_fraction), randf_range(-jitter_fraction, jitter_fraction))
grid_coord += UV_jitter
var centered_UV := grid_coord_to_centered_UV(grid_coord)
# Compensating a floating point error by padding the value a bit
if centered_UV.length_squared() > 0.999:
centered_UV = centered_UV.limit_length(0.999)
var UV_distance_to_surface:Vector3 = sqrt(1.0 - (pow(centered_UV.x, 2) + pow(centered_UV.y, 2))) * plane_axis_vectors[0]
var UV_point_on_plane:Vector3 = centered_UV.x * plane_axis_vectors[1] + centered_UV.y * plane_axis_vectors[2]
var raycast_start:Vector3 = sphere_pos + sphere_radius * (UV_point_on_plane + UV_distance_to_surface)
var raycast_end:Vector3 = sphere_pos + sphere_radius * (UV_point_on_plane - UV_distance_to_surface)
raycast_positions.append([raycast_start, raycast_end])
# This was made to make sure we don't go over a max instance limit
# I refactored placement logic to snap brush_pos to a density grid
# Yet it doesn't 100% work on angled surfaces
# We still might go over max placements, hence the limit check below
# The percieved visual density should be unaffected though, especially at high (>= 0.5) jitter
while raycast_positions.size() + placement_overlaps.size() > max_placements_allowed && !raycast_positions.is_empty():
raycast_positions.remove_at(randi() % raycast_positions.size())
#-------------------------------------------------------------------------------
# Utility
#-------------------------------------------------------------------------------
# Get a linear distance between two points
# Separated into a function because we need this in StrokeHandler as well
static func get_point_distance(plant_density, brush_strength) -> float:
# Convert square density to linear density
# Then density per PLANT_DENSITY_UNITS to density per 1 unit
# That is a distance between two points
if brush_strength <= 0.0:
return 0.0
return (Globals.PLANT_DENSITY_UNITS / sqrt(plant_density * brush_strength))
# Convert grid coordinates to local position around the sphere center
# Resulting vector will be in range (-sphere_radius, sphere_radius)
func grid_coord_to_local_pos(grid_coord:Vector2) -> Vector2:
return Vector2(
grid_coord.x * point_distance + grid_offset,
grid_coord.y * point_distance + grid_offset
)
# Convert local position to grid coordinates
# Resulting vector will try to fit in range (0, grid_linear_size)
# (indexes might be outside the grid if positions lie outside, so the result usually should be limit_length or rejected manually)
func local_pos_to_grid_coord(local_pos:Vector2) -> Vector2:
if point_distance <= 0.0:
return Vector2.ZERO
return Vector2(
round((local_pos.x - grid_offset) / point_distance),
round((local_pos.y - grid_offset) / point_distance)
)
# Convert grid coordinates to UV space in range (-1.0, 1.0)
func grid_coord_to_centered_UV(grid_coord:Vector2) -> Vector2:
var local_pos = grid_coord_to_local_pos(grid_coord)
return local_pos / sphere_radius
#-------------------------------------------------------------------------------
# Debug
#-------------------------------------------------------------------------------
# Print a nicely formatted placement grid to console
func debug_visualize_placement_grid(prefix:String = ""):
if !FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/brush_placement_area_log_grid", false): return
if prefix != "":
logger.info(prefix)
if placement_grid.size() > 0:
for y in range(0, placement_grid[0].size()):
var string = "|"
for x in range(0, placement_grid.size()):
if placement_grid[x][y]:
string += " "
else:
string += "X"
if x < placement_grid[x].size() - 1:
string += "|"
string += "|"
logger.info(string)
print("\n")
func debug_log_grid(string:String):
if !FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/brush_placement_area_log_grid", false): return
logger.info(string)

View File

@@ -0,0 +1,369 @@
@tool
extends Resource
#-------------------------------------------------------------------------------
# Handles higher-level management of OctreeNode objects
# Creation of new trees (octree roots), some of the growing/collapsing functionality
# Exposes lifecycle management to outside programs
# And passes changes made to members/plants to its OctreeNodes
#-------------------------------------------------------------------------------
const MMIOctreeNode = preload("mmi_octree_node.gd")
const FunLib = preload("../../utility/fun_lib.gd")
const DponDebugDraw = preload("../../utility/debug_draw.gd")
const GreenhouseLODVariant = preload("../../greenhouse/greenhouse_LOD_variant.gd")
@export var root_octree_node: Resource = null
@export var LOD_variants : Array[GreenhouseLODVariant] : set = set_LOD_variants
@export var LOD_max_distance:float
@export var LOD_kill_distance:float
var add_placeforms_queue:Array
var remove_placeforms_queue:Array
var set_placeforms_queue:Array
signal req_debug_redraw
#-------------------------------------------------------------------------------
# Lifecycle
#-------------------------------------------------------------------------------
func _init():
resource_local_to_scene = true
set_meta("class", "MMIOctreeManager")
resource_name = "MMIOctreeManager"
if LOD_variants == null || LOD_variants.is_empty():
LOD_variants = []
add_placeforms_queue = []
remove_placeforms_queue = []
set_placeforms_queue = []
func _notification(what):
if what == NOTIFICATION_PREDELETE:
if is_instance_valid(root_octree_node):
# Avoid memory leaks when OctreeNode leaks MMI nodes and spawned spatials
root_octree_node.prepare_for_removal()
# Duplictes the octree structure
func duplicate_tree():
var copy = duplicate(false)
copy.root_octree_node = copy.root_octree_node.duplicate_tree()
copy.connect_node(copy.root_octree_node)
LOD_variants = LOD_variants.duplicate()
return copy
func deep_copy():
var copy = duplicate(false)
copy.root_octree_node = copy.root_octree_node.deep_copy()
copy.connect_node(copy.root_octree_node)
LOD_variants = LOD_variants.duplicate()
return copy
# Restore any states that might be broken after loading OctreeNode objects
func restore_after_load(__MMI_container:Node3D):
if is_instance_valid(root_octree_node):
root_octree_node.restore_after_load(__MMI_container, LOD_variants)
connect_node(root_octree_node)
request_debug_redraw()
func init_octree(members_per_node:int, root_extent:float, center:Vector3 = Vector3.ZERO, MMI_container:Node3D = null, min_leaf_extent:float = 0.0):
root_octree_node = MMIOctreeNode.new(null, members_per_node, root_extent, center, -1, min_leaf_extent, MMI_container, LOD_variants)
connect_node(root_octree_node)
request_debug_redraw()
func connect_node(octree_node:MMIOctreeNode):
assert(octree_node)
FunLib.ensure_signal(octree_node.placeforms_rejected, grow_to_members)
FunLib.ensure_signal(octree_node.collapse_self_possible, collapse_root)
FunLib.ensure_signal(octree_node.req_debug_redraw, request_debug_redraw)
func disconnect_node(octree_node:MMIOctreeNode):
assert(octree_node)
octree_node.placeforms_rejected.disconnect(grow_to_members)
octree_node.collapse_self_possible.disconnect(collapse_root)
octree_node.req_debug_redraw.disconnect(request_debug_redraw)
func prepare_for_removal():
if root_octree_node:
root_octree_node.prepare_for_removal()
# Free anything that might incur a circular reference or a memory leak
# Anything that is @export'ed is NOT touched here
# We count on Godot's own systems to handle that in whatever way works best
# TODO: this is very similar to prepare_for_removal(), need to determine how best to combine the two
# will need to happen around v2.0.0, since it's a very risky change
func free_refs():
if !root_octree_node: return
root_octree_node.free_refs()
#-------------------------------------------------------------------------------
# Restructuring
#-------------------------------------------------------------------------------
# Rebuild the tree with new extent and member limitations
# The resulting octree node layout depends on the order of members in which they are added
# Hence the layout may difer if the members are the same, but belong to different nodes each time
# I.e. it can't be predicted with members_per_node and min_leaf_extent alone, for now it is (as far as it matters) non-deterministic
func rebuild_octree(members_per_node:int, min_leaf_extent:float):
assert(root_octree_node)
var all_placeforms:Array = []
root_octree_node.get_nested_placeforms(all_placeforms)
root_octree_node.prepare_for_removal()
init_octree(members_per_node, min_leaf_extent, Vector3.ZERO,
root_octree_node.MMI_container, min_leaf_extent)
if !all_placeforms.is_empty():
queue_placeforms_add_bulk(all_placeforms)
process_queues()
request_debug_redraw()
debug_manual_root_logger("rebuilt root")
# Recenter a tree and shrink to fit it's current members
func recenter_octree():
assert(root_octree_node)
var last_root:MMIOctreeNode = root_octree_node
var all_placeforms:Array = []
last_root.get_nested_placeforms(all_placeforms)
last_root.prepare_for_removal()
var new_center:Vector3 = Vector3.ZERO
var new_extent:float = last_root.min_leaf_extent
if all_placeforms.size() > 0:
for placeform in all_placeforms:
new_center += placeform[0]
new_center /= all_placeforms.size()
for placeform in all_placeforms:
var delta_pos = (placeform[0] - new_center).abs()
new_extent = max(new_extent, max(delta_pos.x, max(delta_pos.y, delta_pos.z)))
init_octree(last_root.max_members, new_extent, new_center,
root_octree_node.MMI_container, last_root.min_leaf_extent)
if !all_placeforms.is_empty():
queue_placeforms_add_bulk(all_placeforms)
process_queues()
request_debug_redraw()
debug_manual_root_logger("recentered root")
# Grow the tree to fit any members outside it's current bounds (by creating a whole new layer on top)
func grow_to_members(placeforms:Array):
assert(root_octree_node) # 'root_octree_node' is not initialized
assert(placeforms.size() > 0) # 'placeforms' is empty
var target_point = placeforms[0][0]
var last_root:MMIOctreeNode = root_octree_node
disconnect_node(last_root)
var last_octant = last_root._map_point_to_opposite_octant(target_point)
var new_center = last_root.center_pos - last_root._get_octant_center_offset(last_octant)
init_octree(last_root.max_members, last_root.extent * 2.0, new_center, last_root.MMI_container, last_root.min_leaf_extent)
debug_manual_root_logger("grew to members")
root_octree_node.adopt_child(last_root, last_octant)
var root_copy = root_octree_node
add_placeforms(placeforms)
root_copy.try_collapse_children(0)
# Make one of the root's children the new root
func collapse_root(new_root_octant):
assert(root_octree_node) # 'root_octree_node' is not initialized
var last_root:MMIOctreeNode = root_octree_node
disconnect_node(last_root)
root_octree_node = last_root.child_nodes[new_root_octant]
last_root.collapse_self(new_root_octant)
connect_node(root_octree_node)
root_octree_node.safe_init_root()
root_octree_node.try_collapse_self(0)
func get_all_placeforms(target_array: Array = []):
if root_octree_node:
return root_octree_node.get_nested_placeforms(target_array)
#-------------------------------------------------------------------------------
# Processing members
#-------------------------------------------------------------------------------
# Queue changes for bulk processing
func queue_placeforms_add(placeform):
add_placeforms_queue.append(placeform)
func queue_placeforms_add_bulk(placeforms: Array):
add_placeforms_queue.append_array(placeforms)
# Queue changes for bulk processing
func queue_placeforms_remove(placeform):
remove_placeforms_queue.append(placeform)
# Queue changes for bulk processing
func queue_placeforms_set(change):
set_placeforms_queue.append(change)
# Bulk process the queues
func process_queues():
assert(root_octree_node) # 'root_octree_node' is not initialized
var affected_addressed := []
if !add_placeforms_queue.is_empty():
add_placeforms(add_placeforms_queue)
if !remove_placeforms_queue.is_empty():
remove_placeforms(remove_placeforms_queue)
if !set_placeforms_queue.is_empty():
set_placeforms(set_placeforms_queue)
add_placeforms_queue = []
remove_placeforms_queue = []
set_placeforms_queue = []
# Make sure we update LODs even for nodes at max LOD index
# Since we changed their children most likely
set_LODs_to_active_index()
func add_placeforms(placeforms:Array):
assert(root_octree_node) # 'root_octree_node' is not initialized
assert(placeforms.size() > 0) # 'placeforms' is empty
root_octree_node.add_members(placeforms)
root_octree_node.MMI_refresh_instance_placements_recursive()
request_debug_redraw()
func remove_placeforms(placeforms:Array):
assert(root_octree_node) # 'root_octree_node' is not initialized
assert(placeforms.size() > 0) # 'placeforms' is empty
root_octree_node.remove_members(placeforms)
root_octree_node.process_collapse_children()
root_octree_node.process_collapse_self()
root_octree_node.MMI_refresh_instance_placements_recursive()
request_debug_redraw()
# if root_octree_node.child_nodes.size() <= 0 && root_octree_node.members.size() <= 0:
# reset_root_size()
func set_placeforms(changes:Array):
assert(root_octree_node) # 'root_octree_node' is not initialized
assert(changes.size() > 0) # 'changes' is empty
root_octree_node.set_members(changes)
#-------------------------------------------------------------------------------
# LOD management
#-------------------------------------------------------------------------------
func set_LOD_variants(val):
LOD_variants.resize(0)
for LOD_variant in val:
LOD_variants.append(LOD_variant)
# Up-to-date LOD variants of an OctreeNode
func insert_LOD_variant(variant, index:int):
LOD_variants.insert(index, variant)
# Up-to-date LOD variants of an OctreeNode
func remove_LOD_variant(index:int):
LOD_variants.remove_at(index)
# Up-to-date LOD variants of an OctreeNode
func set_LOD_variant(variant, index:int):
LOD_variants[index] = variant
# Up-to-date LOD variants of an OctreeNode
func set_LOD_variant_spawned_spatial(variant, index:int):
# No need to manually set spawned_spatial, it will be inherited from parent resource
# /\ I don't quite remember what this comment meant, but since LOD_Variants are shared
# It seems to imply that the line below in not neccessary
# So I commented it out for now
# LOD_variants[index].spawned_spatial = variant
pass
func reset_member_spatials():
root_octree_node.reset_member_spatials()
# Make sure LODs in OctreeNodes correspond to their active_LOD_index
# This is the preffered way to 'refresh' MMIs inside OctreeNodes
func set_LODs_to_active_index():
root_octree_node.set_LODs_to_active_index()
# Update LODs in OctreeNodes depending on their distance to camera
func update_LODs(camera_pos:Vector3, container_transform:Transform3D):
camera_pos = container_transform.affine_inverse() * camera_pos
root_octree_node.update_LODs(camera_pos, LOD_max_distance, LOD_kill_distance)
func update_LODs_no_camera():
root_octree_node.update_LODs(Vector3.ZERO, -1.0, -1.0)
#-------------------------------------------------------------------------------
# Debug
#-------------------------------------------------------------------------------
# A callback to request a debug redraw
func request_debug_redraw():
req_debug_redraw.emit()
# Manually trigger a Logger message when an OctreeNode doesn't know an important action happened
func debug_manual_root_logger(message:String):
root_octree_node.print_address(message)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
@tool
extends RefCounted
#-------------------------------------------------------------------------------
# A storage object for changes to the octree members
# To be passed to UndoRedo or executed on the spot
# Can also generate opposite actions (provided it's set up correctly)
#-------------------------------------------------------------------------------
enum ChangeType {APPEND, ERASE, SET}
var changes:Array = []
var _opposite_changes:Array = []
func _init(__changes:Array = []):
changes = __changes
# Add both the current and opposite changes
func add_change(change_type:int, at_index:int, new_val, old_val):
var change:Change = Change.new(change_type, at_index, new_val, old_val)
changes.append(change)
_opposite_changes.append(get_opposite_change(change))
# Append second PaintingChanges non-destructively to the second object
func append_changes(painting_changes):
for change in painting_changes.changes:
add_change(change.change_type, change.at_index, change.new_val, change.old_val)
# Generate an opposite action
# For now it really just swaps new_val and old_val
# But I'm keeping it as-is just in case I need something more complex
func get_opposite_change(change):
var opposite_change:Change = null
match change.change_type:
ChangeType.APPEND:
opposite_change = Change.new(ChangeType.ERASE, change.at_index, change.old_val, change.new_val)
ChangeType.ERASE:
opposite_change = Change.new(ChangeType.APPEND, change.at_index, change.old_val, change.new_val)
ChangeType.SET:
opposite_change = Change.new(ChangeType.SET, change.at_index, change.old_val, change.new_val)
return opposite_change
# Get all opposite changes as a new PaintingChanges object and remove them from the current one
func pop_opposite():
var opposite = get_script().new(_opposite_changes)
_opposite_changes = []
return opposite
func _to_string():
var string = "["
for change in changes:
string += str(change) + ","
string.trim_suffix(",")
string += "]"
return string
#-------------------------------------------------------------------------------
# A storage object for a specific octree member change
# To be generated and stored by PaintingChanges
#-------------------------------------------------------------------------------
class Change extends RefCounted:
var change_type:int = -1
var at_index:int = -1
var new_val = null
var old_val = null
func _init(_change_type:int = -1, _at_index:int = -1, _new_val = null, _old_val = null):
change_type = _change_type
at_index = _at_index
new_val = _new_val
old_val = _old_val
func _to_string():
return "[%d, %d, %s, %s]" % [change_type, at_index, str(new_val), str(old_val)]

View File

@@ -0,0 +1,51 @@
@tool
extends RefCounted
#-------------------------------------------------------------------------------
# PLACEment transFORM
# A pseudo-struct meant to store a placement (initial position), surface normal
# Final Transform3D and an occupied octree octant (what part of the 2x2x2 cube it's in)
#
# Originally was a resource, but after some quick tests, the overhead of having
# Thousands of Resources as simple containers became apparent
# It was decided to use an Array as a fastest and most compact built-in container
#
# This script provides an function library to more easily construct such arrays
# And provide access to methods that were formerly part of this Resource
#-------------------------------------------------------------------------------
# [0] - placement,
# [1] - surface_normal
# [2] - transform
# [3] - octree_octant
static func mk(placement:Vector3 = Vector3(), surface_normal:Vector3 = Vector3(), transform:Transform3D = Transform3D(), octree_octant:int = 0) -> Array:
return [
# A designated position for an instance
placement,
# A normal of the surface
surface_normal,
# An actual transform derived from placement including random offsets, rotations, scaling, etc.
transform,
# Occupied octant is mostly used to quick access the child node of an octree node
# E.g. when aplying PaintingChanges
octree_octant
]
static func to_str(placeform: Array) -> String:
return '[%s, %s, %s, %s, %d]' % [str(placeform[0]), str(placeform[1]), str(placeform[2].basis), str(placeform[2].origin), placeform[3]]
static func get_origin_offset(placeform: Array) -> float:
var difference = placeform[2].origin - placeform[0]
var offset = placeform[1].dot(difference.normalized()) * difference.length()
return offset
static func set_placement_from_origin_offset(placeform: Array, offset: float):
placeform[0] = placeform[2].origin - placeform[1] * offset
return placeform

View File

@@ -0,0 +1,49 @@
@tool
extends Resource
#-------------------------------------------------------------------------------
# A resource that stores a placement (initial position), surface normal
# Final Transform3D and an occupied octree octant (what part of the 2x2x2 cube it's in)
# Ideally this should be a struct to avoid the overhead of saving/loading thousands of small resources
#-------------------------------------------------------------------------------
# TODO find out if there *is* any overhead to this and if yes - make this a C++ struct or at least a GDScript dictionary
# A designated position for an instance
@export var placement:Vector3 = Vector3()
# A normal of the surface
@export var surface_normal:Vector3 = Vector3()
# An actual transform derived from placement including random offsets, rotations, scaling, etc.
@export var transform:Transform3D = Transform3D()
# Occupied octant is mostly used to quick access the child node of an octree node
# E.g. when aplying PaintingChanges
@export var octree_octant:int = 0
func _init(_placement:Vector3 = Vector3(), _surface_normal:Vector3 = Vector3(), _transform:Transform3D = Transform3D(), _octree_octant:int = 0):
set_meta("class", "PlacementTransform")
resource_name = "PlacementTransform"
placement = _placement
surface_normal = _surface_normal
transform = _transform
octree_octant = _octree_octant
func _to_string():
return '[%s, %s, %s, %s, %d]' % [str(placement), str(surface_normal), str(transform.origin), str(transform.basis), octree_octant]
func get_origin_offset() -> float:
var difference = transform.origin - placement
var offset = surface_normal.dot(difference.normalized()) * difference.length()
return offset
func set_placement_from_origin_offset(offset: float) -> Resource:
placement = transform.origin - surface_normal * offset
return self

View File

@@ -0,0 +1,40 @@
@tool
extends "stroke_handler.gd"
#-------------------------------------------------------------------------------
# Handle a regular erasing brush stroke
#-------------------------------------------------------------------------------
# Remove members from an octree according to the target density
func _init(_brush:Toolshed_Brush, _plant_states:Array, _octree_managers:Array, _space_state:PhysicsDirectSpaceState3D, _camera: Camera3D, _collision_mask:int):
super(_brush, _plant_states, _octree_managers, _space_state, _camera, _collision_mask)
set_meta("class", "SH_Erase")
func volume_get_stroke_update_changes(brush_data:Dictionary, plant:Greenhouse_Plant, plant_index:int, octree_manager:MMIOctreeManager,
brush_placement_area:BrushPlacementArea, container_transform:Transform3D, painting_changes:PaintingChanges):
# We create a grid and detect overlaps
brush_placement_area.init_grid_data(plant.density_per_units, 1.0 - brush.behavior_strength)
# brush_placement_area.max_placements_allowed *= 1.0 - brush.behavior_strength
brush_placement_area.init_placement_overlaps(octree_manager)
# We get overdense members - those that can't fit in a grid since their cells are already occupied
# Then erase all of them
brush_placement_area.invalidate_occupied_points()
var placeforms_data_for_deletion = brush_placement_area.get_placeforms_for_deletion()
for placeform_data_for_deletion in placeforms_data_for_deletion:
var octree_node = octree_manager.root_octree_node.find_child_by_address(placeform_data_for_deletion.node_address)
var placeform = octree_node.get_placeform(placeform_data_for_deletion.member_idx)
painting_changes.add_change(PaintingChanges.ChangeType.ERASE, plant_index, placeform, placeform)
# No brush strength - no member filtering needed
# Just make changes with ALL overlapped points
func proj_get_stroke_update_changes(placeforms_data_in_brush: Array, plant:Greenhouse_Plant, plant_index: int, octree_manager:MMIOctreeManager, painting_changes:PaintingChanges):
for placeform_data in placeforms_data_in_brush:
painting_changes.add_change(PaintingChanges.ChangeType.ERASE, plant_index, placeform_data.placeform, placeform_data.placeform)

View File

@@ -0,0 +1,28 @@
@tool
extends "stroke_handler.gd"
#-------------------------------------------------------------------------------
# Make painting changes by manually supplying data
#-------------------------------------------------------------------------------
# Meant to be used from code to add or remove instances
func _init():
super(null, [], [], null, null, -1)
set_meta("class", "SH_Manual")
func add_instance(placement: Vector3, surface_normal: Vector3, transform: Transform3D, plant_index: int, painting_changes: PaintingChanges):
var placeform:Array = Placeform.mk(placement, surface_normal, transform)
painting_changes.add_change(PaintingChanges.ChangeType.APPEND, plant_index, placeform, placeform)
func add_instance_placeform(placeform: Array, plant_index: int, painting_changes: PaintingChanges):
painting_changes.add_change(PaintingChanges.ChangeType.APPEND, plant_index, placeform, placeform)
func remove_instance(placeform: Array, plant_index: int, painting_changes: PaintingChanges):
painting_changes.add_change(PaintingChanges.ChangeType.ERASE, plant_index, placeform, placeform)

View File

@@ -0,0 +1,48 @@
@tool
extends "stroke_handler.gd"
#-------------------------------------------------------------------------------
# Handle a regular painting brush stroke
#-------------------------------------------------------------------------------
# Add members to an octree according to the target density
func _init(_brush:Toolshed_Brush, _plant_states:Array, _octree_managers:Array, _space_state:PhysicsDirectSpaceState3D, _camera: Camera3D, _collision_mask:int):
super(_brush, _plant_states, _octree_managers, _space_state, _camera, _collision_mask)
set_meta("class", "SH_Paint")
func should_abort_early(brush_data:Dictionary):
if brush.behavior_overlap_mode == Toolshed_Brush.OverlapMode.PROJECTION: return true
if brush.behavior_strength <= 0.0: return true
return false
func volume_get_stroke_update_changes(brush_data:Dictionary, plant:Greenhouse_Plant, plant_index:int, octree_manager:MMIOctreeManager,
brush_placement_area:BrushPlacementArea, container_transform:Transform3D, painting_changes:PaintingChanges):
# We create a grid, detect overlaps and get a list of raycast positions that aren't occupied
brush_placement_area.init_grid_data(plant.density_per_units, brush.behavior_strength)
# Previously we expanded the search area by 1 unit to eliminate placing instances right outside our area as it moves
# (since these would seem onoccupied to the placement logic)
# But I turned on placement amount limiter and it looks surprisingly fine
# And as a result doesn't cause a bug where small brushes with small density place plants *too* rarely (because of that search expansion)
brush_placement_area.init_placement_overlaps(octree_manager, 0)#1)
var raycast_positions = brush_placement_area.get_valid_raycast_positions()
for raycast_position in raycast_positions:
# We raycast along the surface normal using brush sphere as our bounds
raycast_position[0] = container_transform * raycast_position[0]
raycast_position[1] = container_transform * raycast_position[1]
var params = PhysicsRayQueryParameters3D.create(raycast_position[0], raycast_position[1])
var ray_result = space_state.intersect_ray(params)
if !ray_result.is_empty() && ray_result.collider.collision_layer & collision_mask:
if !TransformGenerator.is_plant_slope_allowed(ray_result.normal, plant): continue
# Generate transforms and add them to the array
var member_pos = container_transform.affine_inverse() * ray_result.position
var plant_transform:Transform3D = TransformGenerator.generate_plant_transform(member_pos, ray_result.normal, plant, randomizer)
var placeform: Array = Placeform.mk(member_pos, ray_result.normal, plant_transform)
painting_changes.add_change(PaintingChanges.ChangeType.APPEND, plant_index, placeform, placeform)

View File

@@ -0,0 +1,57 @@
@tool
extends "stroke_handler.gd"
#-------------------------------------------------------------------------------
# Handle a reapply transforms brush stroke
#-------------------------------------------------------------------------------
# Get overlapping placements and generate a new Transform3D for each of them
# We keep references to placements we already reapplied as to not continously regenerate them
var reapplied_octree_members:Array
func _init(_brush:Toolshed_Brush, _plant_states:Array, _octree_managers:Array, _space_state:PhysicsDirectSpaceState3D, _camera: Camera3D, _collision_mask:int):
super(_brush, _plant_states, _octree_managers, _space_state, _camera, _collision_mask)
set_meta("class", "SH_Reapply")
reapplied_octree_members = []
func volume_get_stroke_update_changes(brush_data:Dictionary, plant:Greenhouse_Plant, plant_index:int, octree_manager:MMIOctreeManager,
brush_placement_area:BrushPlacementArea, container_transform:Transform3D, painting_changes:PaintingChanges):
# We detect overlaps first
brush_placement_area.init_placement_overlaps(octree_manager)
# For each overlap we generate a new Transform3D and add it to the PaintingChange
create_painting_changes(brush_placement_area.overlapped_member_data, plant, plant_index, octree_manager, painting_changes)
func proj_get_stroke_update_changes(placeform_data_array: Array, plant:Greenhouse_Plant, plant_index: int, octree_manager:MMIOctreeManager, painting_changes:PaintingChanges):
create_painting_changes(placeform_data_array, plant, plant_index, octree_manager, painting_changes)
func create_painting_changes(placeform_data_array: Array, plant:Greenhouse_Plant, plant_index: int, octree_manager:MMIOctreeManager, painting_changes:PaintingChanges):
for placeform_data in placeform_data_array:
var octree_node = octree_manager.root_octree_node.find_child_by_address(placeform_data.node_address)
var placeform = octree_node.get_placeform(placeform_data.member_idx)
# We use Vector3.to_string() to generate our reference keys
# I assume it's fully deterministic at least in the scope of an individual OS
var octree_member_key = str(placeform[0])
if reapplied_octree_members.has(octree_member_key): continue
reapplied_octree_members.append(octree_member_key)
var plant_transform := TransformGenerator.generate_plant_transform(placeform[0], placeform[1], plant, randomizer)
var new_placeform := Placeform.mk(placeform[0], placeform[1], plant_transform, placeform[3])
# Painting changes here are non-standart: they actually have an octree node address and member index bundled
# We can't reliably use an address when adding/removing members since the octree might grow/collapse
# But here it's fine since we don't change the amount of members
painting_changes.add_change(PaintingChanges.ChangeType.SET, plant_index,
{"placeform": new_placeform, "index": placeform_data.member_idx, "address": placeform_data.node_address},
{"placeform": placeform, "index": placeform_data.member_idx, "address": placeform_data.node_address})

View File

@@ -0,0 +1,32 @@
@tool
extends "stroke_handler.gd"
#-------------------------------------------------------------------------------
# Handle a single placement brush stroke
#-------------------------------------------------------------------------------
# Meant to be used with single mouse clicks
# Dragging while pressing will place as many instances as the framerate allows
func _init(_brush:Toolshed_Brush, _plant_states:Array, _octree_managers:Array, _space_state:PhysicsDirectSpaceState3D, _camera: Camera3D, _collision_mask:int):
super(_brush, _plant_states, _octree_managers, _space_state, _camera, _collision_mask)
set_meta("class", "SH_Single")
func should_abort_early(brush_data:Dictionary):
if brush.behavior_overlap_mode == Toolshed_Brush.OverlapMode.PROJECTION: return true
return false
func volume_get_stroke_update_changes(brush_data:Dictionary, plant:Greenhouse_Plant, plant_index:int, octree_manager:MMIOctreeManager,
brush_placement_area:BrushPlacementArea, container_transform:Transform3D, painting_changes:PaintingChanges):
var member_pos = brush_data.brush_pos
var plant_transform: Transform3D = TransformGenerator.generate_plant_transform(member_pos, brush_data.brush_normal, plant, randomizer)
var placeform: Array = Placeform.mk(member_pos, brush_data.brush_normal, plant_transform)
painting_changes.add_change(PaintingChanges.ChangeType.APPEND, plant_index, placeform, placeform)

View File

@@ -0,0 +1,417 @@
@tool
extends RefCounted
#-------------------------------------------------------------------------------
# A base object that gathers plant positions/overlaps
# And generates neccessary PaintingChange depending on the stroke type it handles
#-------------------------------------------------------------------------------
const Logger = preload("../../utility/logger.gd")
const FunLib = preload("../../utility/fun_lib.gd")
const DponDebugDraw = preload("../../utility/debug_draw.gd")
const Greenhouse_Plant = preload("../../greenhouse/greenhouse_plant.gd")
const Placeform = preload("../placeform.gd")
const Toolshed_Brush = preload("../../toolshed/toolshed_brush.gd")
const BrushPlacementArea = preload("../brush_placement_area.gd")
const TransformGenerator = preload("../transform_generator.gd")
const PaintingChanges = preload("../painting_changes.gd")
const MMIOctreeManager = preload("../mmi_octree/mmi_octree_manager.gd")
const MMIOctreeNode = preload("../mmi_octree/mmi_octree_node.gd")
var randomizer:RandomNumberGenerator
var transform_generator:TransformGenerator
var brush:Toolshed_Brush
var plant_states:Array
var octree_managers:Array
var space_state:PhysicsDirectSpaceState3D
var camera: Camera3D
var collision_mask:int
var debug_draw_enabled: bool = false
var simplify_projection_frustum: bool = false
# Mouse position in screen space cached for one update cycle
var _cached_mouse_pos: Vector2 = Vector2()
var logger = null
#-------------------------------------------------------------------------------
# Initialization
#-------------------------------------------------------------------------------
func _init(_brush:Toolshed_Brush, _plant_states:Array, _octree_managers:Array, _space_state:PhysicsDirectSpaceState3D, _camera: Camera3D, _collision_mask:int):
set_meta("class", "StrokeHandler")
brush = _brush
plant_states = _plant_states
octree_managers = _octree_managers
space_state = _space_state
camera = _camera
collision_mask = _collision_mask
randomizer = RandomNumberGenerator.new()
randomizer.seed = Time.get_ticks_msec()
logger = Logger.get_for(self)
debug_draw_enabled = FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/stroke_handler_debug_draw", true)
simplify_projection_frustum = FunLib.get_setting_safe("dreadpons_spatial_gardener/painting/simplify_projection_frustum", true)
if debug_draw_enabled:
debug_mk_debug_draw()
#-------------------------------------------------------------------------------
# PaintingChange management
#-------------------------------------------------------------------------------
# A method for building PaintingChanges
# If we have 'brush' in _init(), we do we need 'brush_data' you might ask
# That's because 'brush' gives us brush settings (size, strength, etc.)
# While 'brush_data' gives up-to-date transformations and surface normal of a brush in world-space
func get_stroke_update_changes(brush_data:Dictionary, container_transform:Transform3D) -> PaintingChanges:
var painting_changes = PaintingChanges.new()
if should_abort_early(brush_data): return painting_changes
var msec_start = FunLib.get_msec()
brush_data = modify_brush_data_to_container(brush_data, container_transform)
for plant_index in range(0, plant_states.size()):
if !plant_states[plant_index].plant_brush_active: continue
var plant = plant_states[plant_index].plant
handle_plant_stroke(brush_data, container_transform, plant, plant_index, painting_changes)
var msec_end = FunLib.get_msec()
debug_print_lifecycle(" Stroke %s changes update took: %s" % [get_meta("class"), FunLib.msec_to_time(msec_end - msec_start)])
return painting_changes
# Are there any conditions that force us to abort before anything starts?
func should_abort_early(brush_data:Dictionary):
return false
func modify_brush_data_to_container(brush_data:Dictionary, container_transform:Transform3D) -> Dictionary:
match brush.behavior_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
return volume_modify_brush_data_to_container(brush_data, container_transform)
return brush_data
func handle_plant_stroke(brush_data:Dictionary, container_transform:Transform3D, plant:Greenhouse_Plant, plant_index:int, painting_changes:PaintingChanges):
var octree_manager:MMIOctreeManager = octree_managers[plant_index]
match brush.behavior_overlap_mode:
Toolshed_Brush.OverlapMode.VOLUME:
var plant_brush_data = volume_modify_brush_data_to_plant(brush_data, plant)
# BrushPlacementArea is the "brains" of my placement logic, so we initialize it here
var brush_placement_area := BrushPlacementArea.new(plant_brush_data.brush_pos, brush.shape_volume_size * 0.5, plant_brush_data.brush_normal, plant.offset_jitter_fraction)
volume_get_stroke_update_changes(plant_brush_data, plant, plant_index, octree_manager, brush_placement_area, container_transform, painting_changes)
Toolshed_Brush.OverlapMode.PROJECTION:
# This will store dictionaries so it shares the same data structure as brush_placement_area.overlapped_member_data
# But with member itself added into the mix. It'll look like this:
# [{"node_address": node_address, "member_idx": member_idx, "placeform": placeform}, ...]
var placeforms_in_brush := []
var frustum_planes := []
_cached_mouse_pos = camera.get_viewport().get_mouse_position()
proj_define_frustum(brush_data, frustum_planes)
proj_get_placeforms_data_in_frustum(frustum_planes, placeforms_in_brush, octree_manager.root_octree_node, container_transform)
proj_filter_placeforms_to_brush_circle(placeforms_in_brush, container_transform)
if !brush.behavior_passthrough:
proj_filter_obstructed_placeforms(placeforms_in_brush, container_transform)
proj_get_stroke_update_changes(placeforms_in_brush, plant, plant_index, octree_manager, painting_changes)
#-------------------------------------------------------------------------------
# Volume brush handling
#-------------------------------------------------------------------------------
func volume_modify_brush_data_to_container(brush_data:Dictionary, container_transform:Transform3D):
brush_data = brush_data.duplicate()
brush_data.brush_pos = container_transform.affine_inverse() * brush_data.brush_pos
return brush_data
# Modify the brush data according to the plant
func volume_modify_brush_data_to_plant(brush_data:Dictionary, plant) -> Dictionary:
# Here used to be code to snap the brush_pos to the virtual density grid of a plant
return brush_data.duplicate()
# Called when the Painter brush stroke is updated (moved)
# To be overridden
func volume_get_stroke_update_changes(brush_data:Dictionary, plant:Greenhouse_Plant, plant_index:int, octree_manager:MMIOctreeManager,
brush_placement_area:BrushPlacementArea, container_transform:Transform3D, painting_changes:PaintingChanges):
return null
#-------------------------------------------------------------------------------
# Projection brush handling
#-------------------------------------------------------------------------------
func proj_filter_placeforms_to_brush_circle(placeforms_data_in_frustum: Array, container_transform:Transform3D):
var brush_radius_squared: float = pow(brush.shape_projection_size * 0.5, 2.0)
var viewport_size: Vector2i = camera.get_viewport().size
for i in range(placeforms_data_in_frustum.size() -1, -1, -1):
var placeform_data = placeforms_data_in_frustum[i]
var placement = container_transform * placeform_data.placeform[0]
var screen_space_pos := camera.unproject_position(placement)
var dist_squared = (screen_space_pos - _cached_mouse_pos).length_squared()
# Remove those outside brush radius
if dist_squared > brush_radius_squared:
placeforms_data_in_frustum.remove_at(i)
# Remove those outside viewport
elif screen_space_pos.x < 0 || screen_space_pos.y < 0 || screen_space_pos.x > viewport_size.x || screen_space_pos.y > viewport_size.y:
placeforms_data_in_frustum.remove_at(i)
func proj_define_frustum(brush_data:Dictionary, frustum_planes: Array) -> Array:
var brush_half_size = brush.shape_projection_size * 0.5
var brush_rect := Rect2(-brush_half_size, -brush_half_size, brush.shape_projection_size, brush.shape_projection_size)
var frustum_points := []
frustum_points.resize(8)
frustum_planes.resize(6)
project_frustum_points(frustum_points, brush_rect)
define_frustum_plane_array(frustum_planes, frustum_points)
debug_draw_point_array(frustum_points)
debug_draw_plane_array(frustum_planes, [frustum_points[0], frustum_points[2], frustum_points[4], frustum_points[6], frustum_points[4], frustum_points[7]])
return frustum_planes
func proj_filter_obstructed_placeforms(placeforms_data_in_frustum: Array, container_transform:Transform3D):
# This is a margin to offset our cast due to miniscule errors in placement
# And built-in collision shape margin
# Not off-setting an endpoint will mark some visible instances as obstructed
var raycast_margin = FunLib.get_setting_safe("dreadpons_spatial_gardener/painting/projection_raycast_margin", 0.1)
for i in range(placeforms_data_in_frustum.size() -1, -1, -1):
var placeform_data = placeforms_data_in_frustum[i]
var ray_start: Vector3 = camera.global_transform.origin
var ray_vector = container_transform * placeform_data.placeform[0] - ray_start
var ray_end: Vector3 = ray_start + ray_vector.normalized() * (ray_vector.length() - raycast_margin)
var params = PhysicsRayQueryParameters3D.create(ray_start, ray_end, collision_mask)
var ray_result = space_state.intersect_ray(params)
if !ray_result.is_empty():# && ray_result.collider.collision_layer & collision_mask:
placeforms_data_in_frustum.remove_at(i)
# Called when the Painter brush stroke is updated (moved)
# To be overridden
func proj_get_stroke_update_changes(placeforms_in_brush: Array, plant:Greenhouse_Plant, plant_index: int, octree_manager:MMIOctreeManager, painting_changes:PaintingChanges):
return null
#-------------------------------------------------------------------------------
# Projection, intersection and other Projection-brush geometry
#-------------------------------------------------------------------------------
# Recursively iterate over octree nodes to find nodes and members within brush frustum
func proj_get_placeforms_data_in_frustum(frustum_planes: Array, placeforms_data_in_frustum: Array, octree_node: MMIOctreeNode, container_transform:Transform3D):
var octree_node_transform := Transform3D(container_transform.basis, container_transform * octree_node.center_pos)
var octree_node_extents := Vector3(octree_node.extent, octree_node.extent, octree_node.extent)
debug_draw_cube(octree_node_transform.origin, octree_node_extents * 2.0, octree_node_transform.basis.get_rotation_quaternion(), octree_node_transform.basis)
if is_box_intersecting_frustum(frustum_planes, octree_node_transform, octree_node_extents):
if octree_node.is_leaf:
var node_address = octree_node.get_address()
for member_idx in range(0, octree_node.member_count()):
var placeform = octree_node.get_placeform(member_idx)
placeforms_data_in_frustum.append({"node_address": node_address, "member_idx": member_idx, "placeform": placeform})
else:
for child_node in octree_node.child_nodes:
proj_get_placeforms_data_in_frustum(frustum_planes, placeforms_data_in_frustum, child_node, container_transform)
# This is an approximation (but THIS frustum check IS MEANT to be an approximation, so it's fine)
# This WILL fail on cube's screen-projected 'corners'
# Since technically it will intersect some planes of our frustum
# Which is fine because we perform distance checks to individual members later on
func is_box_intersecting_frustum(frustum_planes: Array, box_transform: Transform3D, box_extents: Vector3):
# Extents == half-size
var oriented_box_extents = [box_extents.x * box_transform.basis.x, box_extents.y * box_transform.basis.y, box_extents.z * box_transform.basis.z]
var box_points := [
box_transform.origin + oriented_box_extents[0] + oriented_box_extents[1] + oriented_box_extents[2],
box_transform.origin + oriented_box_extents[0] + oriented_box_extents[1] - oriented_box_extents[2],
box_transform.origin + oriented_box_extents[0] - oriented_box_extents[1] + oriented_box_extents[2],
box_transform.origin + oriented_box_extents[0] - oriented_box_extents[1] - oriented_box_extents[2],
box_transform.origin - oriented_box_extents[0] + oriented_box_extents[1] + oriented_box_extents[2],
box_transform.origin - oriented_box_extents[0] - oriented_box_extents[1] + oriented_box_extents[2],
box_transform.origin - oriented_box_extents[0] + oriented_box_extents[1] - oriented_box_extents[2],
box_transform.origin - oriented_box_extents[0] - oriented_box_extents[1] - oriented_box_extents[2],
]
debug_draw_point_array(box_points, Color.YELLOW)
for plane in frustum_planes:
var points_inside := 0
for point in box_points:
points_inside += 1 if plane.is_point_over(point) else 0
if points_inside == 0:
return false
return true
# Project frustum points from screen-space to world-space
func project_frustum_points(frustum_points: Array, brush_rect: Rect2):
# A simple version for frustum projection which assumes four corners of the screen in world-space
# To be roughly at camera's origin (camera.near is the default 0.05 and thus negligible)
if simplify_projection_frustum:
var origin_point = project_point(0.0)
# Upper left, near + far
frustum_points[0] = origin_point
frustum_points[1] = project_point(camera.far - 0.1, Vector2(brush_rect.position.x, brush_rect.position.y))
# Lower left, near + far
frustum_points[2] = origin_point
frustum_points[3] = project_point(camera.far - 0.1, Vector2(brush_rect.position.x, brush_rect.end.y))
# Lower right, near + far
frustum_points[4] = origin_point
frustum_points[5] = project_point(camera.far - 0.1, Vector2(brush_rect.end.x, brush_rect.end.y))
# Upper right, near + far
frustum_points[6] = origin_point
frustum_points[7] = project_point(camera.far - 0.1, Vector2(brush_rect.end.x, brush_rect.position.y))
# A complex version for frustum projection which uses camera.near for bigger accuracy
# Relevant when camera.near is greater than default of 0.05 (like 1.0 or 2.0)
else:
# Upper left, near + far
project_frustum_point_pair(frustum_points, 0, 1, Vector2(brush_rect.position.x, brush_rect.position.y))
# Lower left, near + far
project_frustum_point_pair(frustum_points, 2, 3, Vector2(brush_rect.position.x, brush_rect.end.y))
# Lower right, near + far
project_frustum_point_pair(frustum_points, 4, 5, Vector2(brush_rect.end.x, brush_rect.end.y))
# Upper right, near + far
project_frustum_point_pair(frustum_points, 6, 7, Vector2(brush_rect.end.x, brush_rect.position.y))
func project_frustum_point_pair(frustum_points: Array, idx_0: int, idx_1: int, offset: Vector2):
frustum_points[idx_0] = project_point(camera.near, offset)
frustum_points[idx_1] = project_point(camera.far - 0.1, offset)
# Define an array of 6 frustum planes
func define_frustum_plane_array(frustum_planes: Array, frustum_points: Array):
frustum_planes[0] = define_frustum_plane(frustum_points[0], frustum_points[3], frustum_points[1])
frustum_planes[1] = define_frustum_plane(frustum_points[2], frustum_points[5], frustum_points[3])
frustum_planes[2] = define_frustum_plane(frustum_points[4], frustum_points[7], frustum_points[5])
frustum_planes[3] = define_frustum_plane(frustum_points[6], frustum_points[1], frustum_points[7])
if simplify_projection_frustum:
# Since all points involved here will be the same point due to simplification (see 'project_frustum_points()')
# Approximate the points forming a plane by using camera's basis vectors
frustum_planes[4] = define_frustum_plane(frustum_points[4], frustum_points[2] - camera.global_transform.basis.x, frustum_points[0] + camera.global_transform.basis.y)
else:
# Here all our points are different, so use them as-is
frustum_planes[4] = define_frustum_plane(frustum_points[4], frustum_points[2], frustum_points[0])
frustum_planes[5] = define_frustum_plane(frustum_points[7], frustum_points[1], frustum_points[3])
# Plane equation is
# ax + by + xz + d = 0
# Or this with a common point on a plane
# a(x - x0) + b(y - y0) + c(z - z0) = 0
# Where (x0, y0, z0) is any point on the plane (lets use common_point)
#
# So plane equation becomes
# normal.x * ux - normal.x * common_point.x +
# normal.y * uy - normal.y * common_point.y +
# normal.z * uz - normal.z * common_point.z
# = 0
# Where ux, uy, uz are unkown variables that are substituted when solving a plane equations
#
# Now we combine our scalar values and move them to the right side
# normal.x * ux + normal.y * uy + normal.z * uz
# = normal.x * common_point.x + normal.y * common_point.y + normal.z * common_point.z
#
# That should be it, distance to origin is
# d = normal.x * common_point.x + normal.y * common_point.y + normal.z * common_point.z
# Which is esentially a dot product :)
func define_frustum_plane(common_point: Vector3, point_0: Vector3, point_1: Vector3):
var normal := (point_0 - common_point).cross(point_1 - common_point).normalized()
var dist_to_origin = normal.dot(common_point)
var plane = Plane(normal, dist_to_origin)
return plane
func project_point(distance: float, offset: Vector2 = Vector2.ZERO) -> Vector3:
return camera.project_position(_cached_mouse_pos + offset, distance)
#-------------------------------------------------------------------------------
# Debug
#-------------------------------------------------------------------------------
func debug_print_lifecycle(string:String):
if !FunLib.get_setting_safe("dreadpons_spatial_gardener/debug/arborist_log_lifecycle", false): return
logger.info(string)
func debug_mk_debug_draw():
var context = camera.get_tree().edited_scene_root#.find_child('Gardener').get_parent()
if !context.has_node('DponDebugDraw'):
var debug_draw := DponDebugDraw.new()
debug_draw.name = 'DponDebugDraw'
context.add_child(debug_draw)
func debug_draw_plane_array(planes: Array, origin_points: Array, color: Color = Color.RED):
if !debug_draw_enabled: return
for idx in range(0, planes.size()):
debug_draw_plane(origin_points[idx], planes[idx], color)
func debug_draw_point_array(points: Array, color: Color = Color.GREEN):
if !debug_draw_enabled: return
for point in points:
debug_draw_point(point, color)
func debug_draw_plane(draw_origin: Vector3, plane: Plane, color: Color = Color.RED):
if !debug_draw_enabled: return
var context = camera.get_tree().edited_scene_root.find_child('Gardener').get_parent()
context.get_node('DponDebugDraw').draw_plane(draw_origin, camera.far * 0.5, plane.normal, color, context, 2.0, camera.global_transform.basis.y, 10.0)
func debug_draw_point(draw_origin: Vector3, color: Color = Color.GREEN):
if !debug_draw_enabled: return
var context = camera.get_tree().edited_scene_root.find_child('Gardener').get_parent()
context.get_node('DponDebugDraw').draw_cube(draw_origin, Vector3.ONE, Quaternion(), color, context, 10.0)
func debug_draw_cube(draw_origin: Vector3, size: Vector3, rotation: Quaternion, basis: Basis = Basis(), color: Color = Color.BLUE):
if !debug_draw_enabled: return
var context = camera.get_tree().edited_scene_root.find_child('Gardener').get_parent()
size = Vector3(size.x * basis.x.length(), size.y * basis.y.length(), size.z * basis.z.length())
context.get_node('DponDebugDraw').draw_cube(draw_origin, size, rotation, color, context, 10.0)

View File

@@ -0,0 +1,116 @@
@tool
#-------------------------------------------------------------------------------
# A function library for generating individual plant tranforms
# Based on the given placement position and Greenhouse_Plant settings
#-------------------------------------------------------------------------------
const FunLib = preload("../utility/fun_lib.gd")
const Greenhouse_Plant = preload("../greenhouse/greenhouse_plant.gd")
# Randomize an instance transform according to its Greenhouse_Plant settings
static func generate_plant_transform(placement, normal, plant, randomizer) -> Transform3D:
var up_vector_primary:Vector3 = get_dir_vector(plant.up_vector_primary_type, plant.up_vector_primary, normal)
var up_vector_secondary:Vector3 = get_dir_vector(plant.up_vector_secondary_type, plant.up_vector_secondary, normal)
var plant_up_vector:Vector3 = lerp(up_vector_primary, up_vector_secondary, plant.up_vector_blending).normalized()
var fwd_vector_primary:Vector3 = get_dir_vector(plant.fwd_vector_primary_type, plant.fwd_vector_primary, normal)
var fwd_vector_secondary:Vector3 = get_dir_vector(plant.fwd_vector_secondary_type, plant.fwd_vector_secondary, normal)
var plant_fwd_vector:Vector3 = lerp(fwd_vector_primary, fwd_vector_secondary, plant.fwd_vector_blending).normalized()
var plant_scale:Vector3 = FunLib.vector_tri_lerp(
plant.scale_range[0],
plant.scale_range[1],
get_scaling_randomized_weight(plant.scale_scaling_type, randomizer)
)
var plant_y_offset = lerp(plant.offset_y_range[0], plant.offset_y_range[1], randomizer.randf_range(0.0, 1.0)) * plant_scale
var plant_rotation = Vector3(
deg_to_rad(lerp(-plant.rotation_random_x, plant.rotation_random_x, randomizer.randf_range(0.0, 1.0))),
deg_to_rad(lerp(-plant.rotation_random_y, plant.rotation_random_y, randomizer.randf_range(0.0, 1.0))),
deg_to_rad(lerp(-plant.rotation_random_z, plant.rotation_random_z, randomizer.randf_range(0.0, 1.0)))
)
var plant_basis:Basis = Basis()
plant_basis.y = plant_up_vector
# If one of the forward vectors is unused and contributes to a blend
if ((plant.fwd_vector_primary_type == Greenhouse_Plant.DirectionVectorType.UNUSED && plant.fwd_vector_blending != 1.0)
|| (plant.fwd_vector_secondary_type == Greenhouse_Plant.DirectionVectorType.UNUSED && plant.fwd_vector_blending != 0.0)):
# Use automatic forward vector
plant_basis.z = Vector3.FORWARD.rotated(plant_up_vector, plant_rotation.y)
else:
plant_basis.z = plant_fwd_vector.rotated(plant_up_vector, plant_rotation.y)
plant_basis.x = plant_basis.y.cross(plant_basis.z)
plant_basis.z = plant_basis.x.cross(plant_basis.y)
plant_basis = plant_basis.orthonormalized()
plant_basis = plant_basis.rotated(plant_basis.x, plant_rotation.x)
plant_basis = plant_basis.rotated(plant_basis.z, plant_rotation.z)
plant_basis.x *= plant_scale.x
plant_basis.y *= plant_scale.y
plant_basis.z *= plant_scale.z
var plant_origin = placement + plant_y_offset * plant_basis.y.normalized()
var plant_transform = Transform3D(plant_basis, plant_origin)
return plant_transform
# See slope_allowedRange in Greenhouse_Plant
static func is_plant_slope_allowed(normal, plant) -> bool:
var up_vector_primary:Vector3 = get_dir_vector(plant.up_vector_primary_type, plant.up_vector_primary, normal)
var slope_angle = abs(rad_to_deg(up_vector_primary.angle_to(normal)))
return slope_angle >= plant.slope_allowed_range[0] && slope_angle <= plant.slope_allowed_range[1]
# Choose the appropriate direction vector
static func get_dir_vector(dir_vector_type, custom_vector:Vector3, normal:Vector3) -> Vector3:
match dir_vector_type:
Greenhouse_Plant.DirectionVectorType.WORLD_X:
return Vector3.RIGHT
Greenhouse_Plant.DirectionVectorType.WORLD_Y:
return Vector3.UP
Greenhouse_Plant.DirectionVectorType.WORLD_Z:
return Vector3.FORWARD
Greenhouse_Plant.DirectionVectorType.NORMAL:
return normal
Greenhouse_Plant.DirectionVectorType.CUSTOM:
return custom_vector.normalized()
return Vector3.UP
# Enforce the scaling plane lock if present
# The scaling itself is already enforced by Greenhouse_Plant
# But we need to enforce the randomization as well
static func get_scaling_randomized_weight(scaling_type, randomizer) -> Vector3:
var scale_weight = Vector3()
match scaling_type:
Greenhouse_Plant.ScalingType.UNIFORM:
scale_weight.x = randomizer.randf_range(0.0, 1.0)
scale_weight.y = scale_weight.x
scale_weight.z = scale_weight.x
Greenhouse_Plant.ScalingType.FREE:
scale_weight.x = randomizer.randf_range(0.0, 1.0)
scale_weight.y = randomizer.randf_range(0.0, 1.0)
scale_weight.z = randomizer.randf_range(0.0, 1.0)
Greenhouse_Plant.ScalingType.LOCK_XY:
scale_weight.x = randomizer.randf_range(0.0, 1.0)
scale_weight.y = scale_weight.x
scale_weight.z = randomizer.randf_range(0.0, 1.0)
Greenhouse_Plant.ScalingType.LOCK_ZY:
scale_weight.x = randomizer.randf_range(0.0, 1.0)
scale_weight.y = randomizer.randf_range(0.0, 1.0)
scale_weight.z = scale_weight.y
Greenhouse_Plant.ScalingType.LOCK_XZ:
scale_weight.x = randomizer.randf_range(0.0, 1.0)
scale_weight.y = randomizer.randf_range(0.0, 1.0)
scale_weight.z = scale_weight.x
return scale_weight