From 51dddf3abfacee90b86584a4d5ca14b066f4c0ce Mon Sep 17 00:00:00 2001 From: Ivan-267 <61947090+Ivan-267@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:52:47 +0100 Subject: [PATCH 1/5] Adds Platform2D --- examples/Platform2D/.gitattributes | 2 + examples/Platform2D/.gitignore | 3 + examples/Platform2D/Platform2D.csproj | 11 + examples/Platform2D/Platform2D.sln | 19 + .../controller/ai_controller_2d.gd | 136 ++++ .../controller/ai_controller_3d.gd | 136 ++++ .../addons/godot_rl_agents/godot_rl_agents.gd | 16 + .../addons/godot_rl_agents/icon.png | Bin 0 -> 198 bytes .../onnx/csharp/ONNXInference.cs | 109 ++++ .../onnx/csharp/SessionConfigurator.cs | 131 ++++ .../onnx/csharp/docs/ONNXInference.xml | 31 + .../onnx/csharp/docs/SessionConfigurator.xml | 29 + .../onnx/wrapper/ONNX_wrapper.gd | 51 ++ .../addons/godot_rl_agents/plugin.cfg | 7 + .../sensors_2d/ExampleRaycastSensor2D.tscn | 48 ++ .../sensors/sensors_2d/GridSensor2D.gd | 235 +++++++ .../sensors/sensors_2d/ISensor2D.gd | 25 + .../sensors/sensors_2d/RaycastSensor2D.gd | 118 ++++ .../sensors/sensors_2d/RaycastSensor2D.tscn | 7 + .../sensors_3d/ExampleRaycastSensor3D.tscn | 6 + .../sensors/sensors_3d/GridSensor3D.gd | 258 ++++++++ .../sensors/sensors_3d/ISensor3D.gd | 25 + .../sensors/sensors_3d/RGBCameraSensor3D.gd | 63 ++ .../sensors/sensors_3d/RGBCameraSensor3D.tscn | 35 + .../sensors/sensors_3d/RaycastSensor3D.gd | 185 ++++++ .../sensors/sensors_3d/RaycastSensor3D.tscn | 27 + .../Platform2D/addons/godot_rl_agents/sync.gd | 598 ++++++++++++++++++ .../assets/player/jump/Player1Jump1.png | Bin 0 -> 3474 bytes .../assets/player/jump/Player1Jump2.png | Bin 0 -> 3232 bytes .../assets/player/jump/Player1Jump3.png | Bin 0 -> 3811 bytes .../assets/player/move/Player-1.png | Bin 0 -> 3729 bytes .../assets/player/move/Player-2.png | Bin 0 -> 3727 bytes .../assets/player/move/Player-3.png | Bin 0 -> 3763 bytes examples/Platform2D/assets/tilesheet.png | Bin 0 -> 35040 bytes examples/Platform2D/icon.svg | 1 + examples/Platform2D/license.md | 5 + examples/Platform2D/model.onnx | Bin 0 -> 42972 bytes examples/Platform2D/project.godot | 54 ++ examples/Platform2D/readme.md | 22 + .../scenes/game_scene/game_scene.tscn | 21 + .../scenes/player/extended_grid_sensor_2d.gd | 42 ++ examples/Platform2D/scenes/player/player.gd | 90 +++ examples/Platform2D/scenes/player/player.tscn | 126 ++++ .../scenes/player/player_ai_controller.gd | 82 +++ .../scenes/tilemap/tile_map_layer.gd | 145 +++++ .../Platform2D/scenes/tileset/tileset.tres | 31 + .../training_scene/inference_scene.tscn | 14 + .../scenes/training_scene/training_scene.tscn | 34 + 48 files changed, 2978 insertions(+) create mode 100644 examples/Platform2D/.gitattributes create mode 100644 examples/Platform2D/.gitignore create mode 100644 examples/Platform2D/Platform2D.csproj create mode 100644 examples/Platform2D/Platform2D.sln create mode 100644 examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_2d.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_3d.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/godot_rl_agents.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/icon.png create mode 100644 examples/Platform2D/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs create mode 100644 examples/Platform2D/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs create mode 100644 examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml create mode 100644 examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml create mode 100644 examples/Platform2D/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/plugin.cfg create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd create mode 100644 examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn create mode 100644 examples/Platform2D/addons/godot_rl_agents/sync.gd create mode 100644 examples/Platform2D/assets/player/jump/Player1Jump1.png create mode 100644 examples/Platform2D/assets/player/jump/Player1Jump2.png create mode 100644 examples/Platform2D/assets/player/jump/Player1Jump3.png create mode 100644 examples/Platform2D/assets/player/move/Player-1.png create mode 100644 examples/Platform2D/assets/player/move/Player-2.png create mode 100644 examples/Platform2D/assets/player/move/Player-3.png create mode 100644 examples/Platform2D/assets/tilesheet.png create mode 100644 examples/Platform2D/icon.svg create mode 100644 examples/Platform2D/license.md create mode 100644 examples/Platform2D/model.onnx create mode 100644 examples/Platform2D/project.godot create mode 100644 examples/Platform2D/readme.md create mode 100644 examples/Platform2D/scenes/game_scene/game_scene.tscn create mode 100644 examples/Platform2D/scenes/player/extended_grid_sensor_2d.gd create mode 100644 examples/Platform2D/scenes/player/player.gd create mode 100644 examples/Platform2D/scenes/player/player.tscn create mode 100644 examples/Platform2D/scenes/player/player_ai_controller.gd create mode 100644 examples/Platform2D/scenes/tilemap/tile_map_layer.gd create mode 100644 examples/Platform2D/scenes/tileset/tileset.tres create mode 100644 examples/Platform2D/scenes/training_scene/inference_scene.tscn create mode 100644 examples/Platform2D/scenes/training_scene/training_scene.tscn diff --git a/examples/Platform2D/.gitattributes b/examples/Platform2D/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/examples/Platform2D/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/examples/Platform2D/.gitignore b/examples/Platform2D/.gitignore new file mode 100644 index 0000000..7de8ea5 --- /dev/null +++ b/examples/Platform2D/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +android/ diff --git a/examples/Platform2D/Platform2D.csproj b/examples/Platform2D/Platform2D.csproj new file mode 100644 index 0000000..6fa3be0 --- /dev/null +++ b/examples/Platform2D/Platform2D.csproj @@ -0,0 +1,11 @@ + + + net6.0 + net7.0 + net8.0 + true + + + + + \ No newline at end of file diff --git a/examples/Platform2D/Platform2D.sln b/examples/Platform2D/Platform2D.sln new file mode 100644 index 0000000..5427097 --- /dev/null +++ b/examples/Platform2D/Platform2D.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Platform2D", "Platform2D.csproj", "{8552EC7B-EF81-42D4-828B-B6CD9D17C897}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {8552EC7B-EF81-42D4-828B-B6CD9D17C897}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_2d.gd b/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_2d.gd new file mode 100644 index 0000000..06d928b --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_2d.gd @@ -0,0 +1,136 @@ +extends Node2D +class_name AIController2D + +enum ControlModes { + INHERIT_FROM_SYNC, ## Inherit setting from sync node + HUMAN, ## Test the environment manually + TRAINING, ## Train a model + ONNX_INFERENCE, ## Load a pretrained model using an .onnx file + RECORD_EXPERT_DEMOS ## Record observations and actions for expert demonstrations +} +@export var control_mode: ControlModes = ControlModes.INHERIT_FROM_SYNC +## The path to a trained .onnx model file to use for inference (overrides the path set in sync node). +@export var onnx_model_path := "" +## Once the number of steps has passed, the flag 'needs_reset' will be set to 'true' for this instance. +@export var reset_after := 1000 + +@export_group("Record expert demos mode options") +## Path where the demos will be saved. The file can later be used for imitation learning. +@export var expert_demo_save_path: String +## The action that erases the last recorded episode from the currently recorded data. +@export var remove_last_episode_key: InputEvent +## Action will be repeated for n frames. Will introduce control lag if larger than 1. +## Can be used to ensure that action_repeat on inference and training matches +## the recorded demonstrations. +@export var action_repeat: int = 1 + +@export_group("Multi-policy mode options") +## Allows you to set certain agents to use different policies. +## Changing has no effect with default SB3 training. Works with Rllib example. +## Tutorial: https://github.com/edbeeching/godot_rl_agents/blob/main/docs/TRAINING_MULTIPLE_POLICIES.md +@export var policy_name: String = "shared_policy" + +var onnx_model: ONNXModel + +var heuristic := "human" +var done := false +var reward := 0.0 +var n_steps := 0 +var needs_reset := false + +var _player: Node2D + + +func _ready(): + add_to_group("AGENT") + + +func init(player: Node2D): + _player = player + + +#region Methods that need implementing using the "extend script" option in Godot +func get_obs() -> Dictionary: + assert(false, "the get_obs method is not implemented when extending from ai_controller") + return {"obs": []} + + +func get_reward() -> float: + assert(false, "the get_reward method is not implemented when extending from ai_controller") + return 0.0 + + +func get_action_space() -> Dictionary: + assert( + false, "the get_action_space method is not implemented when extending from ai_controller" + ) + return { + "example_actions_continous": {"size": 2, "action_type": "continuous"}, + "example_actions_discrete": {"size": 2, "action_type": "discrete"}, + } + + +func set_action(action) -> void: + assert(false, "the set_action method is not implemented when extending from ai_controller") + + +#endregion + + +#region Methods that sometimes need implementing using the "extend script" option in Godot +# Only needed if you are recording expert demos with this AIController +func get_action() -> Array: + assert( + false, + "the get_action method is not implemented in extended AIController but demo_recorder is used" + ) + return [] + + +# For providing additional info (e.g. `is_success` for SB3 training) +func get_info() -> Dictionary: + return {} + + +#endregion + + +func _physics_process(delta): + n_steps += 1 + if n_steps > reset_after: + needs_reset = true + + +func get_obs_space(): + # may need overriding if the obs space is complex + var obs = get_obs() + return { + "obs": {"size": [len(obs["obs"])], "space": "box"}, + } + + +func reset(): + n_steps = 0 + needs_reset = false + + +func reset_if_done(): + if done: + reset() + + +func set_heuristic(h): + # sets the heuristic from "human" or "model" nothing to change here + heuristic = h + + +func get_done(): + return done + + +func set_done_false(): + done = false + + +func zero_reward(): + reward = 0.0 diff --git a/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_3d.gd b/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_3d.gd new file mode 100644 index 0000000..61a0529 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/controller/ai_controller_3d.gd @@ -0,0 +1,136 @@ +extends Node3D +class_name AIController3D + +enum ControlModes { + INHERIT_FROM_SYNC, ## Inherit setting from sync node + HUMAN, ## Test the environment manually + TRAINING, ## Train a model + ONNX_INFERENCE, ## Load a pretrained model using an .onnx file + RECORD_EXPERT_DEMOS ## Record observations and actions for expert demonstrations +} +@export var control_mode: ControlModes = ControlModes.INHERIT_FROM_SYNC +## The path to a trained .onnx model file to use for inference (overrides the path set in sync node). +@export var onnx_model_path := "" +## Once the number of steps has passed, the flag 'needs_reset' will be set to 'true' for this instance. +@export var reset_after := 1000 + +@export_group("Record expert demos mode options") +## Path where the demos will be saved. The file can later be used for imitation learning. +@export var expert_demo_save_path: String +## The action that erases the last recorded episode from the currently recorded data. +@export var remove_last_episode_key: InputEvent +## Action will be repeated for n frames. Will introduce control lag if larger than 1. +## Can be used to ensure that action_repeat on inference and training matches +## the recorded demonstrations. +@export var action_repeat: int = 1 + +@export_group("Multi-policy mode options") +## Allows you to set certain agents to use different policies. +## Changing has no effect with default SB3 training. Works with Rllib example. +## Tutorial: https://github.com/edbeeching/godot_rl_agents/blob/main/docs/TRAINING_MULTIPLE_POLICIES.md +@export var policy_name: String = "shared_policy" + +var onnx_model: ONNXModel + +var heuristic := "human" +var done := false +var reward := 0.0 +var n_steps := 0 +var needs_reset := false + +var _player: Node3D + + +func _ready(): + add_to_group("AGENT") + + +func init(player: Node3D): + _player = player + + +#region Methods that need implementing using the "extend script" option in Godot +func get_obs() -> Dictionary: + assert(false, "the get_obs method is not implemented when extending from ai_controller") + return {"obs": []} + + +func get_reward() -> float: + assert(false, "the get_reward method is not implemented when extending from ai_controller") + return 0.0 + + +func get_action_space() -> Dictionary: + assert( + false, "the get_action_space method is not implemented when extending from ai_controller" + ) + return { + "example_actions_continous": {"size": 2, "action_type": "continuous"}, + "example_actions_discrete": {"size": 2, "action_type": "discrete"}, + } + + +func set_action(action) -> void: + assert(false, "the set_action method is not implemented when extending from ai_controller") + + +#endregion + + +#region Methods that sometimes need implementing using the "extend script" option in Godot +# Only needed if you are recording expert demos with this AIController +func get_action() -> Array: + assert( + false, + "the get_action method is not implemented in extended AIController but demo_recorder is used" + ) + return [] + + +# For providing additional info (e.g. `is_success` for SB3 training) +func get_info() -> Dictionary: + return {} + + +#endregion + + +func _physics_process(delta): + n_steps += 1 + if n_steps > reset_after: + needs_reset = true + + +func get_obs_space(): + # may need overriding if the obs space is complex + var obs = get_obs() + return { + "obs": {"size": [len(obs["obs"])], "space": "box"}, + } + + +func reset(): + n_steps = 0 + needs_reset = false + + +func reset_if_done(): + if done: + reset() + + +func set_heuristic(h): + # sets the heuristic from "human" or "model" nothing to change here + heuristic = h + + +func get_done(): + return done + + +func set_done_false(): + done = false + + +func zero_reward(): + reward = 0.0 diff --git a/examples/Platform2D/addons/godot_rl_agents/godot_rl_agents.gd b/examples/Platform2D/addons/godot_rl_agents/godot_rl_agents.gd new file mode 100644 index 0000000..e4fe136 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/godot_rl_agents.gd @@ -0,0 +1,16 @@ +@tool +extends EditorPlugin + + +func _enter_tree(): + # Initialization of the plugin goes here. + # Add the new type with a name, a parent type, a script and an icon. + add_custom_type("Sync", "Node", preload("sync.gd"), preload("icon.png")) + #add_custom_type("RaycastSensor2D2", "Node", preload("raycast_sensor_2d.gd"), preload("icon.png")) + + +func _exit_tree(): + # Clean-up of the plugin goes here. + # Always remember to remove it from the engine when deactivated. + remove_custom_type("Sync") + #remove_custom_type("RaycastSensor2D2") diff --git a/examples/Platform2D/addons/godot_rl_agents/icon.png b/examples/Platform2D/addons/godot_rl_agents/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd8190e710eafcd44b723917e69f8a028e697f4b GIT binary patch literal 198 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPHF3h)VW1=92H&!72h^YV{tmc3iE z?8EA%@76ATw`S>kbo66k0w+*4Z%L3}Farmdxo=y?%=15f{`ngim75Ecu=8|r45_%4 zoZ!H;NBn|B&bH~o2YBx9-M+p-GO7B#h~pETS&{_}Yj&4UZ<5S@e{U~m+MT`C-}QRf eHb3AgVPI&!A~QdCx-=WmBnD4cKbLh*2~7Y1GgDds literal 0 HcmV?d00001 diff --git a/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs new file mode 100644 index 0000000..6dcfa18 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/ONNXInference.cs @@ -0,0 +1,109 @@ +using Godot; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using System.Collections.Generic; +using System.Linq; + +namespace GodotONNX +{ + /// + public partial class ONNXInference : GodotObject + { + + private InferenceSession session; + /// + /// Path to the ONNX model. Use Initialize to change it. + /// + private string modelPath; + private int batchSize; + + private SessionOptions SessionOpt; + + /// + /// init function + /// + /// + /// + /// Returns the output size of the model + public int Initialize(string Path, int BatchSize) + { + modelPath = Path; + batchSize = BatchSize; + SessionOpt = SessionConfigurator.MakeConfiguredSessionOptions(); + session = LoadModel(modelPath); + return session.OutputMetadata["output"].Dimensions[1]; + } + + + /// + public Godot.Collections.Dictionary> RunInference(Godot.Collections.Array obs, int state_ins) + { + //Current model: Any (Godot Rl Agents) + //Expects a tensor of shape [batch_size, input_size] type float named obs and a tensor of shape [batch_size] type float named state_ins + + //Fill the input tensors + // create span from inputSize + var span = new float[obs.Count]; //There's probably a better way to do this + for (int i = 0; i < obs.Count; i++) + { + span[i] = obs[i]; + } + + IReadOnlyCollection inputs = new List + { + NamedOnnxValue.CreateFromTensor("obs", new DenseTensor(span, new int[] { batchSize, obs.Count })), + NamedOnnxValue.CreateFromTensor("state_ins", new DenseTensor(new float[] { state_ins }, new int[] { batchSize })) + }; + IReadOnlyCollection outputNames = new List { "output", "state_outs" }; //ONNX is sensible to these names, as well as the input names + + IDisposableReadOnlyCollection results; + //We do not use "using" here so we get a better exception explaination later + try + { + results = session.Run(inputs, outputNames); + } + catch (OnnxRuntimeException e) + { + //This error usually means that the model is not compatible with the input, beacause of the input shape (size) + GD.Print("Error at inference: ", e); + return null; + } + //Can't convert IEnumerable to Variant, so we have to convert it to an array or something + Godot.Collections.Dictionary> output = new Godot.Collections.Dictionary>(); + DisposableNamedOnnxValue output1 = results.First(); + DisposableNamedOnnxValue output2 = results.Last(); + Godot.Collections.Array output1Array = new Godot.Collections.Array(); + Godot.Collections.Array output2Array = new Godot.Collections.Array(); + + foreach (float f in output1.AsEnumerable()) + { + output1Array.Add(f); + } + + foreach (float f in output2.AsEnumerable()) + { + output2Array.Add(f); + } + + output.Add(output1.Name, output1Array); + output.Add(output2.Name, output2Array); + + //Output is a dictionary of arrays, ex: { "output" : [0.1, 0.2, 0.3, 0.4, ...], "state_outs" : [0.5, ...]} + results.Dispose(); + return output; + } + /// + public InferenceSession LoadModel(string Path) + { + using Godot.FileAccess file = FileAccess.Open(Path, Godot.FileAccess.ModeFlags.Read); + byte[] model = file.GetBuffer((int)file.GetLength()); + //file.Close(); file.Dispose(); //Close the file, then dispose the reference. + return new InferenceSession(model, SessionOpt); //Load the model + } + public void FreeDisposables() + { + session.Dispose(); + SessionOpt.Dispose(); + } + } +} diff --git a/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs new file mode 100644 index 0000000..ad7a41c --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/SessionConfigurator.cs @@ -0,0 +1,131 @@ +using Godot; +using Microsoft.ML.OnnxRuntime; + +namespace GodotONNX +{ + /// + + public static class SessionConfigurator + { + public enum ComputeName + { + CUDA, + ROCm, + DirectML, + CoreML, + CPU + } + + /// + public static SessionOptions MakeConfiguredSessionOptions() + { + SessionOptions sessionOptions = new(); + SetOptions(sessionOptions); + return sessionOptions; + } + + private static void SetOptions(SessionOptions sessionOptions) + { + sessionOptions.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING; + ApplySystemSpecificOptions(sessionOptions); + } + + /// + static public void ApplySystemSpecificOptions(SessionOptions sessionOptions) + { + //Most code for this function is verbose only, the only reason it exists is to track + //implementation progress of the different compute APIs. + + //December 2022: CUDA is not working. + + string OSName = OS.GetName(); //Get OS Name + + //ComputeName ComputeAPI = ComputeCheck(); //Get Compute API + // //TODO: Get CPU architecture + + //Linux can use OpenVINO (C#) on x64 and ROCm on x86 (GDNative/C++) + //Windows can use OpenVINO (C#) on x64 + //TODO: try TensorRT instead of CUDA + //TODO: Use OpenVINO for Intel Graphics + + // Temporarily using CPU on all platforms to avoid errors detected with DML + ComputeName ComputeAPI = ComputeName.CPU; + + //match OS and Compute API + GD.Print($"OS: {OSName} Compute API: {ComputeAPI}"); + + // CPU is set by default without appending necessary + // sessionOptions.AppendExecutionProvider_CPU(0); + + /* + switch (OSName) + { + case "Windows": //Can use CUDA, DirectML + if (ComputeAPI is ComputeName.CUDA) + { + //CUDA + //sessionOptions.AppendExecutionProvider_CUDA(0); + //sessionOptions.AppendExecutionProvider_DML(0); + } + else if (ComputeAPI is ComputeName.DirectML) + { + //DirectML + //sessionOptions.AppendExecutionProvider_DML(0); + } + break; + case "X11": //Can use CUDA, ROCm + if (ComputeAPI is ComputeName.CUDA) + { + //CUDA + //sessionOptions.AppendExecutionProvider_CUDA(0); + } + if (ComputeAPI is ComputeName.ROCm) + { + //ROCm, only works on x86 + //Research indicates that this has to be compiled as a GDNative plugin + //GD.Print("ROCm not supported yet, using CPU."); + //sessionOptions.AppendExecutionProvider_CPU(0); + } + break; + case "macOS": //Can use CoreML + if (ComputeAPI is ComputeName.CoreML) + { //CoreML + //TODO: Needs testing + //sessionOptions.AppendExecutionProvider_CoreML(0); + //CoreML on ARM64, out of the box, on x64 needs .tar file from GitHub + } + break; + default: + GD.Print("OS not Supported."); + break; + } + */ + } + + + /// + public static ComputeName ComputeCheck() + { + string adapterName = Godot.RenderingServer.GetVideoAdapterName(); + //string adapterVendor = Godot.RenderingServer.GetVideoAdapterVendor(); + adapterName = adapterName.ToUpper(new System.Globalization.CultureInfo("")); + //TODO: GPU vendors for MacOS, what do they even use these days? + + if (adapterName.Contains("INTEL")) + { + return ComputeName.DirectML; + } + if (adapterName.Contains("AMD") || adapterName.Contains("RADEON")) + { + return ComputeName.DirectML; + } + if (adapterName.Contains("NVIDIA")) + { + return ComputeName.CUDA; + } + + GD.Print("Graphics Card not recognized."); //Should use CPU + return ComputeName.CPU; + } + } +} diff --git a/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml new file mode 100644 index 0000000..91b07d6 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/ONNXInference.xml @@ -0,0 +1,31 @@ + + + + + The main ONNXInference Class that handles the inference process. + + + + + Starts the inference process. + + Path to the ONNX model, expects a path inside resources. + How many observations will the model recieve. + + + + Runs the given input through the model and returns the output. + + Dictionary containing all observations. + How many different agents are creating these observations. + A Dictionary of arrays, containing instructions based on the observations. + + + + Loads the given model into the inference process, using the best Execution provider available. + + Path to the ONNX model, expects a path inside resources. + InferenceSession ready to run. + + + \ No newline at end of file diff --git a/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml new file mode 100644 index 0000000..f160c02 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/onnx/csharp/docs/SessionConfigurator.xml @@ -0,0 +1,29 @@ + + + + + The main SessionConfigurator Class that handles the execution options and providers for the inference process. + + + + + Creates a SessionOptions with all available execution providers. + + SessionOptions with all available execution providers. + + + + Appends any execution provider available in the current system. + + + This function is mainly verbose for tracking implementation progress of different compute APIs. + + + + + Checks for available GPUs. + + An integer identifier for each compute platform. + + + \ No newline at end of file diff --git a/examples/Platform2D/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd b/examples/Platform2D/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd new file mode 100644 index 0000000..e27f2c3 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/onnx/wrapper/ONNX_wrapper.gd @@ -0,0 +1,51 @@ +extends Resource +class_name ONNXModel +var inferencer_script = load("res://addons/godot_rl_agents/onnx/csharp/ONNXInference.cs") + +var inferencer = null + +## How many action values the model outputs +var action_output_size: int + +## Used to differentiate models +## that only output continuous action mean (e.g. sb3, cleanrl export) +## versus models that output mean and logstd (e.g. rllib export) +var action_means_only: bool + +## Whether action_means_value has been set already for this model +var action_means_only_set: bool + +# Must provide the path to the model and the batch size +func _init(model_path, batch_size): + inferencer = inferencer_script.new() + action_output_size = inferencer.Initialize(model_path, batch_size) + +# This function is the one that will be called from the game, +# requires the observation as an array and the state_ins as an int +# returns an Array containing the action the model takes. +func run_inference(obs: Array, state_ins: int) -> Dictionary: + if inferencer == null: + printerr("Inferencer not initialized") + return {} + return inferencer.RunInference(obs, state_ins) + + +func _notification(what): + if what == NOTIFICATION_PREDELETE: + inferencer.FreeDisposables() + inferencer.free() + +# Check whether agent uses a continuous actions model with only action means or not +func set_action_means_only(agent_action_space): + action_means_only_set = true + var continuous_only: bool = true + var continuous_actions: int + for action in agent_action_space: + if not agent_action_space[action]["action_type"] == "continuous": + continuous_only = false + break + else: + continuous_actions += agent_action_space[action]["size"] + if continuous_only: + if continuous_actions == action_output_size: + action_means_only = true diff --git a/examples/Platform2D/addons/godot_rl_agents/plugin.cfg b/examples/Platform2D/addons/godot_rl_agents/plugin.cfg new file mode 100644 index 0000000..b1bc988 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="GodotRLAgents" +description="Custom nodes for the godot rl agents toolkit " +author="Edward Beeching" +version="0.1" +script="godot_rl_agents.gd" diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn new file mode 100644 index 0000000..5edb6c7 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ExampleRaycastSensor2D.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=5 format=3 uid="uid://ddeq7mn1ealyc"] + +[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="1"] + +[sub_resource type="GDScript" id="2"] +script/source = "extends Node2D + + + +func _physics_process(delta: float) -> void: + print(\"step start\") + +" + +[sub_resource type="GDScript" id="1"] +script/source = "extends RayCast2D + +var steps = 1 + +func _physics_process(delta: float) -> void: + print(\"processing raycast\") + steps += 1 + if steps % 2: + force_raycast_update() + + print(is_colliding()) +" + +[sub_resource type="CircleShape2D" id="3"] + +[node name="ExampleRaycastSensor2D" type="Node2D"] +script = SubResource("2") + +[node name="ExampleAgent" type="Node2D" parent="."] +position = Vector2(573, 314) +rotation = 0.286234 + +[node name="RaycastSensor2D" type="Node2D" parent="ExampleAgent"] +script = ExtResource("1") + +[node name="TestRayCast2D" type="RayCast2D" parent="."] +script = SubResource("1") + +[node name="StaticBody2D" type="StaticBody2D" parent="."] +position = Vector2(1, 52) + +[node name="CollisionShape2D" type="CollisionShape2D" parent="StaticBody2D"] +shape = SubResource("3") diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd new file mode 100644 index 0000000..48b132e --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/GridSensor2D.gd @@ -0,0 +1,235 @@ +@tool +extends ISensor2D +class_name GridSensor2D + +@export var debug_view := false: + get: + return debug_view + set(value): + debug_view = value + _update() + +@export_flags_2d_physics var detection_mask := 0: + get: + return detection_mask + set(value): + detection_mask = value + _update() + +@export var collide_with_areas := false: + get: + return collide_with_areas + set(value): + collide_with_areas = value + _update() + +@export var collide_with_bodies := true: + get: + return collide_with_bodies + set(value): + collide_with_bodies = value + _update() + +@export_range(1, 200, 0.1) var cell_width := 20.0: + get: + return cell_width + set(value): + cell_width = value + _update() + +@export_range(1, 200, 0.1) var cell_height := 20.0: + get: + return cell_height + set(value): + cell_height = value + _update() + +@export_range(1, 21, 2, "or_greater") var grid_size_x := 3: + get: + return grid_size_x + set(value): + grid_size_x = value + _update() + +@export_range(1, 21, 2, "or_greater") var grid_size_y := 3: + get: + return grid_size_y + set(value): + grid_size_y = value + _update() + +var _obs_buffer: PackedFloat64Array +var _rectangle_shape: RectangleShape2D +var _collision_mapping: Dictionary +var _n_layers_per_cell: int + +var _highlighted_cell_color: Color +var _standard_cell_color: Color + + +func get_observation(): + return _obs_buffer + + +func _update(): + if Engine.is_editor_hint(): + if is_node_ready(): + _spawn_nodes() + + +func _ready() -> void: + _set_colors() + + if Engine.is_editor_hint(): + if get_child_count() == 0: + _spawn_nodes() + else: + _spawn_nodes() + + +func _set_colors() -> void: + _standard_cell_color = Color(100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0) + _highlighted_cell_color = Color(255.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0) + + +func _get_collision_mapping() -> Dictionary: + # defines which layer is mapped to which cell obs index + var total_bits = 0 + var collision_mapping = {} + for i in 32: + var bit_mask = 2 ** i + if (detection_mask & bit_mask) > 0: + collision_mapping[i] = total_bits + total_bits += 1 + + return collision_mapping + + +func _spawn_nodes(): + for cell in get_children(): + cell.name = "_%s" % cell.name # Otherwise naming below will fail + cell.queue_free() + + _collision_mapping = _get_collision_mapping() + #prints("collision_mapping", _collision_mapping, len(_collision_mapping)) + # allocate memory for the observations + _n_layers_per_cell = len(_collision_mapping) + _obs_buffer = PackedFloat64Array() + _obs_buffer.resize(grid_size_x * grid_size_y * _n_layers_per_cell) + _obs_buffer.fill(0) + #prints(len(_obs_buffer), _obs_buffer ) + + _rectangle_shape = RectangleShape2D.new() + _rectangle_shape.set_size(Vector2(cell_width, cell_height)) + + var shift := Vector2( + -(grid_size_x / 2) * cell_width, + -(grid_size_y / 2) * cell_height, + ) + + for i in grid_size_x: + for j in grid_size_y: + var cell_position = Vector2(i * cell_width, j * cell_height) + shift + _create_cell(i, j, cell_position) + + +func _create_cell(i: int, j: int, position: Vector2): + var cell := Area2D.new() + cell.position = position + cell.name = "GridCell %s %s" % [i, j] + cell.modulate = _standard_cell_color + + if collide_with_areas: + cell.area_entered.connect(_on_cell_area_entered.bind(i, j)) + cell.area_exited.connect(_on_cell_area_exited.bind(i, j)) + + if collide_with_bodies: + cell.body_entered.connect(_on_cell_body_entered.bind(i, j)) + cell.body_exited.connect(_on_cell_body_exited.bind(i, j)) + + cell.collision_layer = 0 + cell.collision_mask = detection_mask + cell.monitorable = true + add_child(cell) + cell.set_owner(get_tree().edited_scene_root) + + var col_shape := CollisionShape2D.new() + col_shape.shape = _rectangle_shape + col_shape.name = "CollisionShape2D" + cell.add_child(col_shape) + col_shape.set_owner(get_tree().edited_scene_root) + + if debug_view: + var quad = MeshInstance2D.new() + quad.name = "MeshInstance2D" + var quad_mesh = QuadMesh.new() + + quad_mesh.set_size(Vector2(cell_width, cell_height)) + + quad.mesh = quad_mesh + cell.add_child(quad) + quad.set_owner(get_tree().edited_scene_root) + + +func _update_obs(cell_i: int, cell_j: int, collision_layer: int, entered: bool): + for key in _collision_mapping: + var bit_mask = 2 ** key + if (collision_layer & bit_mask) > 0: + var collison_map_index = _collision_mapping[key] + + var obs_index = ( + (cell_i * grid_size_y * _n_layers_per_cell) + + (cell_j * _n_layers_per_cell) + + collison_map_index + ) + #prints(obs_index, cell_i, cell_j) + if entered: + _obs_buffer[obs_index] += 1 + else: + _obs_buffer[obs_index] -= 1 + + +func _toggle_cell(cell_i: int, cell_j: int): + var cell = get_node_or_null("GridCell %s %s" % [cell_i, cell_j]) + + if cell == null: + print("cell not found, returning") + + var n_hits = 0 + var start_index = (cell_i * grid_size_y * _n_layers_per_cell) + (cell_j * _n_layers_per_cell) + for i in _n_layers_per_cell: + n_hits += _obs_buffer[start_index + i] + + if n_hits > 0: + cell.modulate = _highlighted_cell_color + else: + cell.modulate = _standard_cell_color + + +func _on_cell_area_entered(area: Area2D, cell_i: int, cell_j: int): + #prints("_on_cell_area_entered", cell_i, cell_j) + _update_obs(cell_i, cell_j, area.collision_layer, true) + if debug_view: + _toggle_cell(cell_i, cell_j) + #print(_obs_buffer) + + +func _on_cell_area_exited(area: Area2D, cell_i: int, cell_j: int): + #prints("_on_cell_area_exited", cell_i, cell_j) + _update_obs(cell_i, cell_j, area.collision_layer, false) + if debug_view: + _toggle_cell(cell_i, cell_j) + + +func _on_cell_body_entered(body: Node2D, cell_i: int, cell_j: int): + #prints("_on_cell_body_entered", cell_i, cell_j) + _update_obs(cell_i, cell_j, body.collision_layer, true) + if debug_view: + _toggle_cell(cell_i, cell_j) + + +func _on_cell_body_exited(body: Node2D, cell_i: int, cell_j: int): + #prints("_on_cell_body_exited", cell_i, cell_j) + _update_obs(cell_i, cell_j, body.collision_layer, false) + if debug_view: + _toggle_cell(cell_i, cell_j) diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd new file mode 100644 index 0000000..67669a1 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/ISensor2D.gd @@ -0,0 +1,25 @@ +extends Node2D +class_name ISensor2D + +var _obs: Array = [] +var _active := false + + +func get_observation(): + pass + + +func activate(): + _active = true + + +func deactivate(): + _active = false + + +func _update_observation(): + pass + + +func reset(): + pass diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd new file mode 100644 index 0000000..9bb54ed --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd @@ -0,0 +1,118 @@ +@tool +extends ISensor2D +class_name RaycastSensor2D + +@export_flags_2d_physics var collision_mask := 1: + get: + return collision_mask + set(value): + collision_mask = value + _update() + +@export var collide_with_areas := false: + get: + return collide_with_areas + set(value): + collide_with_areas = value + _update() + +@export var collide_with_bodies := true: + get: + return collide_with_bodies + set(value): + collide_with_bodies = value + _update() + +@export var n_rays := 16.0: + get: + return n_rays + set(value): + n_rays = value + _update() + +@export_range(5, 3000, 5.0) var ray_length := 200: + get: + return ray_length + set(value): + ray_length = value + _update() +@export_range(5, 360, 5.0) var cone_width := 360.0: + get: + return cone_width + set(value): + cone_width = value + _update() + +@export var debug_draw := true: + get: + return debug_draw + set(value): + debug_draw = value + _update() + +var _angles = [] +var rays := [] + + +func _update(): + if Engine.is_editor_hint(): + if debug_draw: + _spawn_nodes() + else: + for ray in get_children(): + if ray is RayCast2D: + remove_child(ray) + + +func _ready() -> void: + _spawn_nodes() + + +func _spawn_nodes(): + for ray in rays: + ray.queue_free() + rays = [] + + _angles = [] + var step = cone_width / (n_rays) + var start = step / 2 - cone_width / 2 + + for i in n_rays: + var angle = start + i * step + var ray = RayCast2D.new() + ray.set_target_position( + Vector2(ray_length * cos(deg_to_rad(angle)), ray_length * sin(deg_to_rad(angle))) + ) + ray.set_name("node_" + str(i)) + ray.enabled = false + ray.collide_with_areas = collide_with_areas + ray.collide_with_bodies = collide_with_bodies + ray.collision_mask = collision_mask + add_child(ray) + rays.append(ray) + + _angles.append(start + i * step) + + +func get_observation() -> Array: + return self.calculate_raycasts() + + +func calculate_raycasts() -> Array: + var result = [] + for ray in rays: + ray.enabled = true + ray.force_raycast_update() + var distance = _get_raycast_distance(ray) + result.append(distance) + ray.enabled = false + return result + + +func _get_raycast_distance(ray: RayCast2D) -> float: + if !ray.is_colliding(): + return 0.0 + + var distance = (global_position - ray.get_collision_point()).length() + distance = clamp(distance, 0.0, ray_length) + return (ray_length - distance) / ray_length diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn new file mode 100644 index 0000000..5ca402c --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://drvfihk5esgmv"] + +[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="1"] + +[node name="RaycastSensor2D" type="Node2D"] +script = ExtResource("1") +n_rays = 17.0 diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn new file mode 100644 index 0000000..a8057c7 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ExampleRaycastSensor3D.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://biu787qh4woik"] + +[node name="ExampleRaycastSensor3D" type="Node3D"] + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.804183, 0, 2.70146) diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd new file mode 100644 index 0000000..24de9a4 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/GridSensor3D.gd @@ -0,0 +1,258 @@ +@tool +extends ISensor3D +class_name GridSensor3D + +@export var debug_view := false: + get: + return debug_view + set(value): + debug_view = value + _update() + +@export_flags_3d_physics var detection_mask := 0: + get: + return detection_mask + set(value): + detection_mask = value + _update() + +@export var collide_with_areas := false: + get: + return collide_with_areas + set(value): + collide_with_areas = value + _update() + +@export var collide_with_bodies := false: + # NOTE! The sensor will not detect StaticBody3D, add an area to static bodies to detect them + get: + return collide_with_bodies + set(value): + collide_with_bodies = value + _update() + +@export_range(0.1, 2, 0.1) var cell_width := 1.0: + get: + return cell_width + set(value): + cell_width = value + _update() + +@export_range(0.1, 2, 0.1) var cell_height := 1.0: + get: + return cell_height + set(value): + cell_height = value + _update() + +@export_range(1, 21, 1, "or_greater") var grid_size_x := 3: + get: + return grid_size_x + set(value): + grid_size_x = value + _update() + +@export_range(1, 21, 1, "or_greater") var grid_size_z := 3: + get: + return grid_size_z + set(value): + grid_size_z = value + _update() + +var _obs_buffer: PackedFloat64Array +var _box_shape: BoxShape3D +var _collision_mapping: Dictionary +var _n_layers_per_cell: int + +var _highlighted_box_material: StandardMaterial3D +var _standard_box_material: StandardMaterial3D + + +func get_observation(): + return _obs_buffer + + +func reset(): + _obs_buffer.fill(0) + + +func _update(): + if Engine.is_editor_hint(): + if is_node_ready(): + _spawn_nodes() + + +func _ready() -> void: + _make_materials() + + if Engine.is_editor_hint(): + if get_child_count() == 0: + _spawn_nodes() + else: + _spawn_nodes() + + +func _make_materials() -> void: + if _highlighted_box_material != null and _standard_box_material != null: + return + + _standard_box_material = StandardMaterial3D.new() + _standard_box_material.set_transparency(1) # ALPHA + _standard_box_material.albedo_color = Color( + 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0 + ) + + _highlighted_box_material = StandardMaterial3D.new() + _highlighted_box_material.set_transparency(1) # ALPHA + _highlighted_box_material.albedo_color = Color( + 255.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0, 100.0 / 255.0 + ) + + +func _get_collision_mapping() -> Dictionary: + # defines which layer is mapped to which cell obs index + var total_bits = 0 + var collision_mapping = {} + for i in 32: + var bit_mask = 2 ** i + if (detection_mask & bit_mask) > 0: + collision_mapping[i] = total_bits + total_bits += 1 + + return collision_mapping + + +func _spawn_nodes(): + for cell in get_children(): + cell.name = "_%s" % cell.name # Otherwise naming below will fail + cell.queue_free() + + _collision_mapping = _get_collision_mapping() + #prints("collision_mapping", _collision_mapping, len(_collision_mapping)) + # allocate memory for the observations + _n_layers_per_cell = len(_collision_mapping) + _obs_buffer = PackedFloat64Array() + _obs_buffer.resize(grid_size_x * grid_size_z * _n_layers_per_cell) + _obs_buffer.fill(0) + #prints(len(_obs_buffer), _obs_buffer ) + + _box_shape = BoxShape3D.new() + _box_shape.set_size(Vector3(cell_width, cell_height, cell_width)) + + var shift := Vector3( + -(grid_size_x / 2) * cell_width, + 0, + -(grid_size_z / 2) * cell_width, + ) + + for i in grid_size_x: + for j in grid_size_z: + var cell_position = Vector3(i * cell_width, 0.0, j * cell_width) + shift + _create_cell(i, j, cell_position) + + +func _create_cell(i: int, j: int, position: Vector3): + var cell := Area3D.new() + cell.position = position + cell.name = "GridCell %s %s" % [i, j] + + if collide_with_areas: + cell.area_entered.connect(_on_cell_area_entered.bind(i, j)) + cell.area_exited.connect(_on_cell_area_exited.bind(i, j)) + + if collide_with_bodies: + cell.body_entered.connect(_on_cell_body_entered.bind(i, j)) + cell.body_exited.connect(_on_cell_body_exited.bind(i, j)) + +# cell.body_shape_entered.connect(_on_cell_body_shape_entered.bind(i, j)) +# cell.body_shape_exited.connect(_on_cell_body_shape_exited.bind(i, j)) + + cell.collision_layer = 0 + cell.collision_mask = detection_mask + cell.monitorable = true + cell.input_ray_pickable = false + add_child(cell) + cell.set_owner(get_tree().edited_scene_root) + + var col_shape := CollisionShape3D.new() + col_shape.shape = _box_shape + col_shape.name = "CollisionShape3D" + cell.add_child(col_shape) + col_shape.set_owner(get_tree().edited_scene_root) + + if debug_view: + var box = MeshInstance3D.new() + box.name = "MeshInstance3D" + var box_mesh = BoxMesh.new() + + box_mesh.set_size(Vector3(cell_width, cell_height, cell_width)) + box_mesh.material = _standard_box_material + + box.mesh = box_mesh + cell.add_child(box) + box.set_owner(get_tree().edited_scene_root) + + +func _update_obs(cell_i: int, cell_j: int, collision_layer: int, entered: bool): + for key in _collision_mapping: + var bit_mask = 2 ** key + if (collision_layer & bit_mask) > 0: + var collison_map_index = _collision_mapping[key] + + var obs_index = ( + (cell_i * grid_size_z * _n_layers_per_cell) + + (cell_j * _n_layers_per_cell) + + collison_map_index + ) + #prints(obs_index, cell_i, cell_j) + if entered: + _obs_buffer[obs_index] += 1 + else: + _obs_buffer[obs_index] -= 1 + + +func _toggle_cell(cell_i: int, cell_j: int): + var cell = get_node_or_null("GridCell %s %s" % [cell_i, cell_j]) + + if cell == null: + print("cell not found, returning") + + var n_hits = 0 + var start_index = (cell_i * grid_size_z * _n_layers_per_cell) + (cell_j * _n_layers_per_cell) + for i in _n_layers_per_cell: + n_hits += _obs_buffer[start_index + i] + + var cell_mesh = cell.get_node_or_null("MeshInstance3D") + if n_hits > 0: + cell_mesh.mesh.material = _highlighted_box_material + else: + cell_mesh.mesh.material = _standard_box_material + + +func _on_cell_area_entered(area: Area3D, cell_i: int, cell_j: int): + #prints("_on_cell_area_entered", cell_i, cell_j) + _update_obs(cell_i, cell_j, area.collision_layer, true) + if debug_view: + _toggle_cell(cell_i, cell_j) + #print(_obs_buffer) + + +func _on_cell_area_exited(area: Area3D, cell_i: int, cell_j: int): + #prints("_on_cell_area_exited", cell_i, cell_j) + _update_obs(cell_i, cell_j, area.collision_layer, false) + if debug_view: + _toggle_cell(cell_i, cell_j) + + +func _on_cell_body_entered(body: Node3D, cell_i: int, cell_j: int): + #prints("_on_cell_body_entered", cell_i, cell_j) + _update_obs(cell_i, cell_j, body.collision_layer, true) + if debug_view: + _toggle_cell(cell_i, cell_j) + + +func _on_cell_body_exited(body: Node3D, cell_i: int, cell_j: int): + #prints("_on_cell_body_exited", cell_i, cell_j) + _update_obs(cell_i, cell_j, body.collision_layer, false) + if debug_view: + _toggle_cell(cell_i, cell_j) diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd new file mode 100644 index 0000000..aca3c2d --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/ISensor3D.gd @@ -0,0 +1,25 @@ +extends Node3D +class_name ISensor3D + +var _obs: Array = [] +var _active := false + + +func get_observation(): + pass + + +func activate(): + _active = true + + +func deactivate(): + _active = false + + +func _update_observation(): + pass + + +func reset(): + pass diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd new file mode 100644 index 0000000..96dfb6a --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd @@ -0,0 +1,63 @@ +extends Node3D +class_name RGBCameraSensor3D +var camera_pixels = null + +@onready var camera_texture := $Control/CameraTexture as Sprite2D +@onready var processed_texture := $Control/ProcessedTexture as Sprite2D +@onready var sub_viewport := $SubViewport as SubViewport +@onready var displayed_image: ImageTexture + +@export var render_image_resolution := Vector2(36, 36) +## Display size does not affect rendered or sent image resolution. +## Scale is relative to either render image or downscale image resolution +## depending on which mode is set. +@export var displayed_image_scale_factor := Vector2(8, 8) + +@export_group("Downscale image options") +## Enable to downscale the rendered image before sending the obs. +@export var downscale_image: bool = false +## If downscale_image is true, will display the downscaled image instead of rendered image. +@export var display_downscaled_image: bool = true +## This is the resolution of the image that will be sent after downscaling +@export var resized_image_resolution := Vector2(36, 36) + + +func _ready(): + sub_viewport.size = render_image_resolution + camera_texture.scale = displayed_image_scale_factor + + if downscale_image and display_downscaled_image: + camera_texture.visible = false + processed_texture.scale = displayed_image_scale_factor + else: + processed_texture.visible = false + + +func get_camera_pixel_encoding(): + var image := camera_texture.get_texture().get_image() as Image + + if downscale_image: + image.resize( + resized_image_resolution.x, resized_image_resolution.y, Image.INTERPOLATE_NEAREST + ) + if display_downscaled_image: + if not processed_texture.texture: + displayed_image = ImageTexture.create_from_image(image) + processed_texture.texture = displayed_image + else: + displayed_image.update(image) + + return image.get_data().hex_encode() + + +func get_camera_shape() -> Array: + var size = resized_image_resolution if downscale_image else render_image_resolution + + assert( + size.x >= 36 and size.y >= 36, + "Camera sensor sent image resolution must be 36x36 or larger." + ) + if sub_viewport.transparent_bg: + return [4, size.y, size.x] + else: + return [3, size.y, size.x] diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn new file mode 100644 index 0000000..d58649c --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.tscn @@ -0,0 +1,35 @@ +[gd_scene load_steps=3 format=3 uid="uid://baaywi3arsl2m"] + +[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_3d/RGBCameraSensor3D.gd" id="1"] + +[sub_resource type="ViewportTexture" id="ViewportTexture_y72s3"] +viewport_path = NodePath("SubViewport") + +[node name="RGBCameraSensor3D" type="Node3D"] +script = ExtResource("1") + +[node name="RemoteTransform" type="RemoteTransform3D" parent="."] +remote_path = NodePath("../SubViewport/Camera") + +[node name="SubViewport" type="SubViewport" parent="."] +size = Vector2i(36, 36) +render_target_update_mode = 3 + +[node name="Camera" type="Camera3D" parent="SubViewport"] +near = 0.5 + +[node name="Control" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +metadata/_edit_use_anchors_ = true + +[node name="CameraTexture" type="Sprite2D" parent="Control"] +texture = SubResource("ViewportTexture_y72s3") +centered = false + +[node name="ProcessedTexture" type="Sprite2D" parent="Control"] +centered = false diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd new file mode 100644 index 0000000..1357529 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd @@ -0,0 +1,185 @@ +@tool +extends ISensor3D +class_name RayCastSensor3D +@export_flags_3d_physics var collision_mask = 1: + get: + return collision_mask + set(value): + collision_mask = value + _update() +@export_flags_3d_physics var boolean_class_mask = 1: + get: + return boolean_class_mask + set(value): + boolean_class_mask = value + _update() + +@export var n_rays_width := 6.0: + get: + return n_rays_width + set(value): + n_rays_width = value + _update() + +@export var n_rays_height := 6.0: + get: + return n_rays_height + set(value): + n_rays_height = value + _update() + +@export var ray_length := 10.0: + get: + return ray_length + set(value): + ray_length = value + _update() + +@export var cone_width := 60.0: + get: + return cone_width + set(value): + cone_width = value + _update() + +@export var cone_height := 60.0: + get: + return cone_height + set(value): + cone_height = value + _update() + +@export var collide_with_areas := false: + get: + return collide_with_areas + set(value): + collide_with_areas = value + _update() + +@export var collide_with_bodies := true: + get: + return collide_with_bodies + set(value): + collide_with_bodies = value + _update() + +@export var class_sensor := false + +var rays := [] +var geo = null + + +func _update(): + if Engine.is_editor_hint(): + if is_node_ready(): + _spawn_nodes() + + +func _ready() -> void: + if Engine.is_editor_hint(): + if get_child_count() == 0: + _spawn_nodes() + else: + _spawn_nodes() + + +func _spawn_nodes(): + print("spawning nodes") + for ray in get_children(): + ray.queue_free() + if geo: + geo.clear() + #$Lines.remove_points() + rays = [] + + var horizontal_step = cone_width / (n_rays_width) + var vertical_step = cone_height / (n_rays_height) + + var horizontal_start = horizontal_step / 2 - cone_width / 2 + var vertical_start = vertical_step / 2 - cone_height / 2 + + var points = [] + + for i in n_rays_width: + for j in n_rays_height: + var angle_w = horizontal_start + i * horizontal_step + var angle_h = vertical_start + j * vertical_step + #angle_h = 0.0 + var ray = RayCast3D.new() + var cast_to = to_spherical_coords(ray_length, angle_w, angle_h) + ray.set_target_position(cast_to) + + points.append(cast_to) + + ray.set_name("node_" + str(i) + " " + str(j)) + ray.enabled = true + ray.collide_with_bodies = collide_with_bodies + ray.collide_with_areas = collide_with_areas + ray.collision_mask = collision_mask + add_child(ray) + ray.set_owner(get_tree().edited_scene_root) + rays.append(ray) + ray.force_raycast_update() + + +# if Engine.editor_hint: +# _create_debug_lines(points) + + +func _create_debug_lines(points): + if not geo: + geo = ImmediateMesh.new() + add_child(geo) + + geo.clear() + geo.begin(Mesh.PRIMITIVE_LINES) + for point in points: + geo.set_color(Color.AQUA) + geo.add_vertex(Vector3.ZERO) + geo.add_vertex(point) + geo.end() + + +func display(): + if geo: + geo.display() + + +func to_spherical_coords(r, inc, azimuth) -> Vector3: + return Vector3( + r * sin(deg_to_rad(inc)) * cos(deg_to_rad(azimuth)), + r * sin(deg_to_rad(azimuth)), + r * cos(deg_to_rad(inc)) * cos(deg_to_rad(azimuth)) + ) + + +func get_observation() -> Array: + return self.calculate_raycasts() + + +func calculate_raycasts() -> Array: + var result = [] + for ray in rays: + ray.set_enabled(true) + ray.force_raycast_update() + var distance = _get_raycast_distance(ray) + + result.append(distance) + if class_sensor: + var hit_class: float = 0 + if ray.get_collider(): + var hit_collision_layer = ray.get_collider().collision_layer + hit_collision_layer = hit_collision_layer & collision_mask + hit_class = (hit_collision_layer & boolean_class_mask) > 0 + result.append(float(hit_class)) + ray.set_enabled(false) + return result + + +func _get_raycast_distance(ray: RayCast3D) -> float: + if !ray.is_colliding(): + return 0.0 + + var distance = (global_transform.origin - ray.get_collision_point()).length() + distance = clamp(distance, 0.0, ray_length) + return (ray_length - distance) / ray_length diff --git a/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn new file mode 100644 index 0000000..35f9796 --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.tscn @@ -0,0 +1,27 @@ +[gd_scene load_steps=2 format=3 uid="uid://b803cbh1fmy66"] + +[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_3d/RaycastSensor3D.gd" id="1"] + +[node name="RaycastSensor3D" type="Node3D"] +script = ExtResource("1") +n_rays_width = 4.0 +n_rays_height = 2.0 +ray_length = 11.0 + +[node name="node_1 0" type="RayCast3D" parent="."] +target_position = Vector3(-1.38686, -2.84701, 10.5343) + +[node name="node_1 1" type="RayCast3D" parent="."] +target_position = Vector3(-1.38686, 2.84701, 10.5343) + +[node name="node_2 0" type="RayCast3D" parent="."] +target_position = Vector3(1.38686, -2.84701, 10.5343) + +[node name="node_2 1" type="RayCast3D" parent="."] +target_position = Vector3(1.38686, 2.84701, 10.5343) + +[node name="node_3 0" type="RayCast3D" parent="."] +target_position = Vector3(4.06608, -2.84701, 9.81639) + +[node name="node_3 1" type="RayCast3D" parent="."] +target_position = Vector3(4.06608, 2.84701, 9.81639) diff --git a/examples/Platform2D/addons/godot_rl_agents/sync.gd b/examples/Platform2D/addons/godot_rl_agents/sync.gd new file mode 100644 index 0000000..f47decb --- /dev/null +++ b/examples/Platform2D/addons/godot_rl_agents/sync.gd @@ -0,0 +1,598 @@ +extends Node +class_name Sync + +# --fixed-fps 2000 --disable-render-loop + +enum ControlModes { + HUMAN, ## Test the environment manually + TRAINING, ## Train a model + ONNX_INFERENCE ## Load a pretrained model using an .onnx file +} +@export var control_mode: ControlModes = ControlModes.TRAINING +## Action will be repeated for n frames (Godot physics steps). +@export_range(1, 10, 1, "or_greater") var action_repeat := 8 +## Speeds up the physics in the environment to enable faster training. +@export_range(0, 10, 0.1, "or_greater") var speed_up := 1.0 +## The path to a trained .onnx model file to use for inference (only needed for the 'Onnx Inference' control mode). +@export var onnx_model_path := "" + +# Onnx model stored for each requested path +var onnx_models: Dictionary + +@onready var start_time = Time.get_ticks_msec() + +const MAJOR_VERSION := "0" +const MINOR_VERSION := "7" +const DEFAULT_PORT := "11008" +const DEFAULT_SEED := "1" +var stream: StreamPeerTCP = null +var connected = false +var message_center +var should_connect = true + +var all_agents: Array +var agents_training: Array +## Policy name of each agent, for use with multi-policy multi-agent RL cases +var agents_training_policy_names: Array[String] = ["shared_policy"] +var agents_inference: Array +var agents_heuristic: Array + +## For recording expert demos +var agent_demo_record: Node +## File path for writing recorded trajectories +var expert_demo_save_path: String +## Stores recorded trajectories +var demo_trajectories: Array +## A trajectory includes obs: Array, acts: Array, terminal (set in Python env instead) +var current_demo_trajectory: Array + +var need_to_send_obs = false +var args = null +var initialized = false +var just_reset = false +var onnx_model = null +var n_action_steps = 0 + +var _action_space_training: Array[Dictionary] = [] +var _action_space_inference: Array[Dictionary] = [] +var _obs_space_training: Array[Dictionary] = [] + + +# Called when the node enters the scene tree for the first time. +func _ready(): + await get_parent().ready + get_tree().set_pause(true) + _initialize() + await get_tree().create_timer(1.0).timeout + get_tree().set_pause(false) + + +func _initialize(): + _get_agents() + args = _get_args() + Engine.physics_ticks_per_second = _get_speedup() * 60 # Replace with function body. + Engine.time_scale = _get_speedup() * 1.0 + prints( + "physics ticks", + Engine.physics_ticks_per_second, + Engine.time_scale, + _get_speedup(), + speed_up + ) + + _set_heuristic("human", all_agents) + + _initialize_training_agents() + _initialize_inference_agents() + _initialize_demo_recording() + + _set_seed() + _set_action_repeat() + initialized = true + + +func _initialize_training_agents(): + if agents_training.size() > 0: + _obs_space_training.resize(agents_training.size()) + _action_space_training.resize(agents_training.size()) + for agent_idx in range(0, agents_training.size()): + _obs_space_training[agent_idx] = agents_training[agent_idx].get_obs_space() + _action_space_training[agent_idx] = agents_training[agent_idx].get_action_space() + connected = connect_to_server() + if connected: + _set_heuristic("model", agents_training) + _handshake() + _send_env_info() + else: + push_warning( + "Couldn't connect to Python server, using human controls instead. ", + "Did you start the training server using e.g. `gdrl` from the console?" + ) + + +func _initialize_inference_agents(): + if agents_inference.size() > 0: + if control_mode == ControlModes.ONNX_INFERENCE: + assert( + FileAccess.file_exists(onnx_model_path), + "Onnx Model Path set on Sync node does not exist: %s" % onnx_model_path + ) + onnx_models[onnx_model_path] = ONNXModel.new(onnx_model_path, 1) + + for agent in agents_inference: + var action_space = agent.get_action_space() + _action_space_inference.append(action_space) + + var agent_onnx_model: ONNXModel + if agent.onnx_model_path.is_empty(): + assert( + onnx_models.has(onnx_model_path), + ( + "Node %s has no onnx model path set " % agent.get_path() + + "and sync node's control mode is not set to OnnxInference. " + + "Either add the path to the AIController, " + + "or if you want to use the path set on sync node instead, " + + "set control mode to OnnxInference." + ) + ) + prints( + "Info: AIController %s" % agent.get_path(), + "has no onnx model path set.", + "Using path set on the sync node instead." + ) + agent_onnx_model = onnx_models[onnx_model_path] + else: + if not onnx_models.has(agent.onnx_model_path): + assert( + FileAccess.file_exists(agent.onnx_model_path), + ( + "Onnx Model Path set on %s node does not exist: %s" + % [agent.get_path(), agent.onnx_model_path] + ) + ) + onnx_models[agent.onnx_model_path] = ONNXModel.new(agent.onnx_model_path, 1) + agent_onnx_model = onnx_models[agent.onnx_model_path] + + agent.onnx_model = agent_onnx_model + if not agent_onnx_model.action_means_only_set: + agent_onnx_model.set_action_means_only(action_space) + + _set_heuristic("model", agents_inference) + + +func _initialize_demo_recording(): + if agent_demo_record: + expert_demo_save_path = agent_demo_record.expert_demo_save_path + assert( + not expert_demo_save_path.is_empty(), + "Expert demo save path set in %s is empty." % agent_demo_record.get_path() + ) + + InputMap.add_action("RemoveLastDemoEpisode") + InputMap.action_add_event( + "RemoveLastDemoEpisode", agent_demo_record.remove_last_episode_key + ) + current_demo_trajectory.resize(2) + current_demo_trajectory[0] = [] + current_demo_trajectory[1] = [] + agent_demo_record.heuristic = "demo_record" + + +func _physics_process(_delta): + # two modes, human control, agent control + # pause tree, send obs, get actions, set actions, unpause tree + + _demo_record_process() + + if n_action_steps % action_repeat != 0: + n_action_steps += 1 + return + + n_action_steps += 1 + + _training_process() + _inference_process() + _heuristic_process() + + +func _training_process(): + if connected: + get_tree().set_pause(true) + + var obs = _get_obs_from_agents(agents_training) + var info = _get_info_from_agents(agents_training) + + if just_reset: + just_reset = false + + var reply = {"type": "reset", "obs": obs, "info": info} + _send_dict_as_json_message(reply) + # this should go straight to getting the action and setting it checked the agent, no need to perform one phyics tick + get_tree().set_pause(false) + return + + if need_to_send_obs: + need_to_send_obs = false + var reward = _get_reward_from_agents() + var done = _get_done_from_agents() + #_reset_agents_if_done() # this ensures the new observation is from the next env instance : NEEDS REFACTOR + + var reply = {"type": "step", "obs": obs, "reward": reward, "done": done, "info": info} + _send_dict_as_json_message(reply) + + var handled = handle_message() + + +func _inference_process(): + if agents_inference.size() > 0: + var obs: Array = _get_obs_from_agents(agents_inference) + var actions = [] + + for agent_id in range(0, agents_inference.size()): + var model: ONNXModel = agents_inference[agent_id].onnx_model + var action = model.run_inference(obs[agent_id]["obs"], 1.0) + var action_dict = _extract_action_dict( + action["output"], _action_space_inference[agent_id], model.action_means_only + ) + actions.append(action_dict) + + _set_agent_actions(actions, agents_inference) + _reset_agents_if_done(agents_inference) + get_tree().set_pause(false) + + +func _demo_record_process(): + if not agent_demo_record: + return + + if Input.is_action_just_pressed("RemoveLastDemoEpisode"): + print("[Sync script][Demo recorder] Removing last recorded episode.") + demo_trajectories.remove_at(demo_trajectories.size() - 1) + print("Remaining episode count: %d" % demo_trajectories.size()) + + if n_action_steps % agent_demo_record.action_repeat != 0: + return + + var obs_dict: Dictionary = agent_demo_record.get_obs() + + # Get the current obs from the agent + assert( + obs_dict.has("obs"), + "Demo recorder needs an 'obs' key in get_obs() returned dictionary to record obs from." + ) + current_demo_trajectory[0].append(obs_dict.obs) + + # Get the action applied for the current obs from the agent + agent_demo_record.set_action() + var acts = agent_demo_record.get_action() + + var terminal = agent_demo_record.get_done() + # Record actions only for non-terminal states + if terminal: + agent_demo_record.set_done_false() + else: + current_demo_trajectory[1].append(acts) + + if terminal: + #current_demo_trajectory[2].append(true) + demo_trajectories.append(current_demo_trajectory.duplicate(true)) + print("[Sync script][Demo recorder] Recorded episode count: %d" % demo_trajectories.size()) + current_demo_trajectory[0].clear() + current_demo_trajectory[1].clear() + + +func _heuristic_process(): + for agent in agents_heuristic: + _reset_agents_if_done(agents_heuristic) + + +func _extract_action_dict(action_array: Array, action_space: Dictionary, action_means_only: bool): + var index = 0 + var result = {} + for key in action_space.keys(): + var size = action_space[key]["size"] + var action_type = action_space[key]["action_type"] + if action_type == "discrete": + var largest_logit: float # Value of the largest logit for this action in the actions array + var largest_logit_idx: int # Index of the largest logit for this action in the actions array + for logit_idx in range(0, size): + var logit_value = action_array[index + logit_idx] + if logit_value > largest_logit: + largest_logit = logit_value + largest_logit_idx = logit_idx + result[key] = largest_logit_idx # Index of the largest logit is the discrete action value + index += size + elif action_type == "continuous": + # For continous actions, we only take the action mean values + result[key] = clamp_array(action_array.slice(index, index + size), -1.0, 1.0) + if action_means_only: + index += size # model only outputs action means, so we move index by size + else: + index += size * 2 # model outputs logstd after action mean, we skip the logstd part + + else: + assert( + false, + ( + 'Only "discrete" and "continuous" action types supported. Found: %s action type set.' + % action_type + ) + ) + + return result + + +## For AIControllers that inherit mode from sync, sets the correct mode. +func _set_agent_mode(agent: Node): + var agent_inherits_mode: bool = agent.control_mode == agent.ControlModes.INHERIT_FROM_SYNC + + if agent_inherits_mode: + match control_mode: + ControlModes.HUMAN: + agent.control_mode = agent.ControlModes.HUMAN + ControlModes.TRAINING: + agent.control_mode = agent.ControlModes.TRAINING + ControlModes.ONNX_INFERENCE: + agent.control_mode = agent.ControlModes.ONNX_INFERENCE + + +func _get_agents(): + all_agents = get_tree().get_nodes_in_group("AGENT") + for agent in all_agents: + _set_agent_mode(agent) + + if agent.control_mode == agent.ControlModes.TRAINING: + agents_training.append(agent) + elif agent.control_mode == agent.ControlModes.ONNX_INFERENCE: + agents_inference.append(agent) + elif agent.control_mode == agent.ControlModes.HUMAN: + agents_heuristic.append(agent) + elif agent.control_mode == agent.ControlModes.RECORD_EXPERT_DEMOS: + assert( + not agent_demo_record, + "Currently only a single AIController can be used for recording expert demos." + ) + agent_demo_record = agent + + var training_agent_count = agents_training.size() + agents_training_policy_names.resize(training_agent_count) + for i in range(0, training_agent_count): + agents_training_policy_names[i] = agents_training[i].policy_name + + +func _set_heuristic(heuristic, agents: Array): + for agent in agents: + agent.set_heuristic(heuristic) + + +func _handshake(): + print("performing handshake") + + var json_dict = _get_dict_json_message() + assert(json_dict["type"] == "handshake") + var major_version = json_dict["major_version"] + var minor_version = json_dict["minor_version"] + if major_version != MAJOR_VERSION: + print("WARNING: major verison mismatch ", major_version, " ", MAJOR_VERSION) + if minor_version != MINOR_VERSION: + print("WARNING: minor verison mismatch ", minor_version, " ", MINOR_VERSION) + + print("handshake complete") + + +func _get_dict_json_message(): + # returns a dictionary from of the most recent message + # this is not waiting + while stream.get_available_bytes() == 0: + stream.poll() + if stream.get_status() != 2: + print("server disconnected status, closing") + get_tree().quit() + return null + + OS.delay_usec(10) + + var message = stream.get_string() + var json_data = JSON.parse_string(message) + + return json_data + + +func _send_dict_as_json_message(dict): + stream.put_string(JSON.stringify(dict, "", false)) + + +func _send_env_info(): + var json_dict = _get_dict_json_message() + assert(json_dict["type"] == "env_info") + + var message = { + "type": "env_info", + "observation_space": _obs_space_training, + "action_space": _action_space_training, + "n_agents": len(agents_training), + "agent_policy_names": agents_training_policy_names + } + _send_dict_as_json_message(message) + + +func connect_to_server(): + print("Waiting for one second to allow server to start") + OS.delay_msec(1000) + print("trying to connect to server") + stream = StreamPeerTCP.new() + + # "localhost" was not working on windows VM, had to use the IP + var ip = "127.0.0.1" + var port = _get_port() + var connect = stream.connect_to_host(ip, port) + stream.set_no_delay(true) # TODO check if this improves performance or not + stream.poll() + # Fetch the status until it is either connected (2) or failed to connect (3) + while stream.get_status() < 2: + stream.poll() + return stream.get_status() == 2 + + +func _get_args(): + print("getting command line arguments") + var arguments = {} + for argument in OS.get_cmdline_args(): + print(argument) + if argument.find("=") > -1: + var key_value = argument.split("=") + arguments[key_value[0].lstrip("--")] = key_value[1] + else: + # Options without an argument will be present in the dictionary, + # with the value set to an empty string. + arguments[argument.lstrip("--")] = "" + + return arguments + + +func _get_speedup(): + print(args) + return args.get("speedup", str(speed_up)).to_float() + + +func _get_port(): + return args.get("port", DEFAULT_PORT).to_int() + + +func _set_seed(): + var _seed = args.get("env_seed", DEFAULT_SEED).to_int() + seed(_seed) + + +func _set_action_repeat(): + action_repeat = args.get("action_repeat", str(action_repeat)).to_int() + + +func disconnect_from_server(): + stream.disconnect_from_host() + + +func handle_message() -> bool: + # get json message: reset, step, close + var message = _get_dict_json_message() + if message["type"] == "close": + print("received close message, closing game") + get_tree().quit() + get_tree().set_pause(false) + return true + + if message["type"] == "reset": + print("resetting all agents") + _reset_agents() + just_reset = true + get_tree().set_pause(false) + #print("resetting forcing draw") +# RenderingServer.force_draw() +# var obs = _get_obs_from_agents() +# print("obs ", obs) +# var reply = { +# "type": "reset", +# "obs": obs +# } +# _send_dict_as_json_message(reply) + return true + + if message["type"] == "call": + var method = message["method"] + var returns = _call_method_on_agents(method) + var reply = {"type": "call", "returns": returns} + print("calling method from Python") + _send_dict_as_json_message(reply) + return handle_message() + + if message["type"] == "action": + var action = message["action"] + _set_agent_actions(action, agents_training) + need_to_send_obs = true + get_tree().set_pause(false) + return true + + print("message was not handled") + return false + + +func _call_method_on_agents(method): + var returns = [] + for agent in all_agents: + returns.append(agent.call(method)) + + return returns + + +func _reset_agents_if_done(agents = all_agents): + for agent in agents: + if agent.get_done(): + agent.set_done_false() + + +func _reset_agents(agents = all_agents): + for agent in agents: + agent.needs_reset = true + #agent.reset() + + +func _get_obs_from_agents(agents: Array = all_agents): + var obs = [] + for agent in agents: + obs.append(agent.get_obs()) + return obs + + +func _get_reward_from_agents(agents: Array = agents_training): + var rewards = [] + for agent in agents: + rewards.append(agent.get_reward()) + agent.zero_reward() + return rewards + + +func _get_info_from_agents(agents: Array = all_agents): + var info = [] + for agent in agents: + info.append(agent.get_info()) + return info + + +func _get_done_from_agents(agents: Array = agents_training): + var dones = [] + for agent in agents: + var done = agent.get_done() + if done: + agent.set_done_false() + dones.append(done) + return dones + + +func _set_agent_actions(actions, agents: Array = all_agents): + for i in range(len(actions)): + agents[i].set_action(actions[i]) + + +func clamp_array(arr: Array, min: float, max: float): + var output: Array = [] + for a in arr: + output.append(clamp(a, min, max)) + return output + + +## Save recorded export demos on window exit (Close game window instead of "Stop" button in Godot Editor) +func _notification(what): + if demo_trajectories.size() == 0 or expert_demo_save_path.is_empty(): + return + + if what == NOTIFICATION_PREDELETE: + var json_string = JSON.stringify(demo_trajectories, "", false) + var file = FileAccess.open(expert_demo_save_path, FileAccess.WRITE) + + if not file: + var error: Error = FileAccess.get_open_error() + assert(not error, "There was an error opening the file: %d" % error) + + file.store_line(json_string) + var error = file.get_error() + assert(not error, "There was an error after trying to write to the file: %d" % error) diff --git a/examples/Platform2D/assets/player/jump/Player1Jump1.png b/examples/Platform2D/assets/player/jump/Player1Jump1.png new file mode 100644 index 0000000000000000000000000000000000000000..9a1719b97398c805aadf5ca28975642b1c3b1a4e GIT binary patch literal 3474 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 pQtpzOcZl;QFdov#bU*5fyiSxoe^?vmrxm( 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 Cn=v5( literal 0 HcmV?d00001 diff --git a/examples/Platform2D/assets/player/jump/Player1Jump3.png b/examples/Platform2D/assets/player/jump/Player1Jump3.png new file mode 100644 index 0000000000000000000000000000000000000000..79cecf17fea04e2b2ce81979be9b78afd55bb73b GIT binary patch literal 3811 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 literal 0 HcmV?d00001 diff --git a/examples/Platform2D/assets/player/move/Player-2.png b/examples/Platform2D/assets/player/move/Player-2.png new file mode 100644 index 0000000000000000000000000000000000000000..e750568ae37c15c16fd1b9af5bd03ba2a7fee326 GIT binary patch literal 3727 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 V72qkczda@au(pv_sfKOX{{TNGE4csw literal 0 HcmV?d00001 diff --git a/examples/Platform2D/assets/player/move/Player-3.png b/examples/Platform2D/assets/player/move/Player-3.png new file mode 100644 index 0000000000000000000000000000000000000000..f9b894bb642937d92d02d61be5be1af200c00100 GIT binary patch literal 3763 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 LG=NoU+C}~cS}P*Z literal 0 HcmV?d00001 diff --git a/examples/Platform2D/assets/tilesheet.png b/examples/Platform2D/assets/tilesheet.png new file mode 100644 index 0000000000000000000000000000000000000000..ee1352df4114dfd3eba1c5a0ed0836752cd8861c GIT binary patch literal 35040 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 diff --git a/examples/Platform2D/license.md b/examples/Platform2D/license.md new file mode 100644 index 0000000..fbcf1fa --- /dev/null +++ b/examples/Platform2D/license.md @@ -0,0 +1,5 @@ +Platform2D Environment made by Ivan Dodic (https://github.com/Ivan-267) + +The following license is only for the graphical assets in the folder "assets", specifically .png files: +Author: Ivan Dodic (https://github.com/Ivan-267), +License: https://creativecommons.org/licenses/by/4.0/ \ No newline at end of file diff --git a/examples/Platform2D/model.onnx b/examples/Platform2D/model.onnx new file mode 100644 index 0000000000000000000000000000000000000000..d5b25f73d12b93d6c3104baff64c8884b5890930 GIT binary patch literal 42972 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

5V*}9$lVFS74B$b0uJ}ZtJ5!DR4ZA%LLH*iH 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 literal 0 HcmV?d00001 diff --git a/examples/Platform2D/project.godot b/examples/Platform2D/project.godot new file mode 100644 index 0000000..3137106 --- /dev/null +++ b/examples/Platform2D/project.godot @@ -0,0 +1,54 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Platform2D" +run/main_scene="res://scenes/training_scene/training_scene.tscn" +config/features=PackedStringArray("4.3", "C#", "Forward Plus") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="Platform2D" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg") + +[input] + +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +jump={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + +[physics] + +common/max_physics_steps_per_frame=64 +2d/solver/solver_iterations=2 + +[rendering] + +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/examples/Platform2D/readme.md b/examples/Platform2D/readme.md new file mode 100644 index 0000000..64bd049 --- /dev/null +++ b/examples/Platform2D/readme.md @@ -0,0 +1,22 @@ +# Platform2D environment + +## Goal: + +## Observations: + +## Actions: + +## Running inference: + +If you’d just like to test the env using the pre-trained onnx model, + +## Training: + +There’s an included onnx file that was trained with https://github.com/edbeeching/godot_rl_agents/blob/main/examples/stable_baselines3_example.py + +CL arguments used (also onnx export and model saving was used, enable as needed): + +```python +--speedup=8 +--n_parallel=8 +``` \ No newline at end of file diff --git a/examples/Platform2D/scenes/game_scene/game_scene.tscn b/examples/Platform2D/scenes/game_scene/game_scene.tscn new file mode 100644 index 0000000..e7a2fac --- /dev/null +++ b/examples/Platform2D/scenes/game_scene/game_scene.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=4 format=3 uid="uid://danlnf1x033rf"] + +[ext_resource type="TileSet" uid="uid://sdmrwh4xd6qj" path="res://scenes/tileset/tileset.tres" id="1_rsvlm"] +[ext_resource type="Script" path="res://scenes/tilemap/tile_map_layer.gd" id="2_0030j"] +[ext_resource type="PackedScene" uid="uid://d2qsl7semlkyv" path="res://scenes/player/player.tscn" id="3_5v4jr"] + +[node name="GameScene" type="Node2D"] + +[node name="TileMapLayer" type="TileMapLayer" parent="."] +tile_set = ExtResource("1_rsvlm") +navigation_enabled = false +script = ExtResource("2_0030j") +total_rows = 12 +max_coins_per_row = 3 + +[node name="Player" parent="." node_paths=PackedStringArray("map_manager") instance=ExtResource("3_5v4jr")] +position = Vector2(50, -99.46) +map_manager = NodePath("../TileMapLayer") + +[node name="Camera2D" type="Camera2D" parent="Player"] +zoom = Vector2(0.785, 0.785) diff --git a/examples/Platform2D/scenes/player/extended_grid_sensor_2d.gd b/examples/Platform2D/scenes/player/extended_grid_sensor_2d.gd new file mode 100644 index 0000000..6d812e3 --- /dev/null +++ b/examples/Platform2D/scenes/player/extended_grid_sensor_2d.gd @@ -0,0 +1,42 @@ +extends GridSensor2D + +## Simple modification to enable detecting a single physics layer of tilemap +## without needing to check the collision layer of the specific tile +## it also clamps all observation values to 0-1 range, and overrides some of +## the export variable ranges. +## Note: Meant to be used with only a single `detection mask` layer per sensor + +@export_range(1, 10_000, 0.1) var cell_width_override := 20.0 +@export_range(1, 10_000, 0.1) var cell_height_override := 20.0 +@export_range(1, 21, 1, "or_greater") var grid_size_x_override := 2 +@export_range(1, 21, 1, "or_greater") var grid_size_y_override := 1 + + +func _ready() -> void: + cell_width = cell_width_override + cell_height = cell_height_override + grid_size_x = grid_size_x_override + grid_size_y = grid_size_y_override + super._ready() + +func get_observation(): + # There can be more than one object in a cell at a time + # to simplify the obs, we clamp the values to 0-1 range + var obs: Array[float] + obs.resize(_obs_buffer.size()) + for obs_idx in _obs_buffer: + obs[obs_idx] = clampf(_obs_buffer[obs_idx], 0, 1) + return obs + +func _on_cell_body_entered(_body: Node2D, cell_i: int, cell_j: int): + #prints("_on_cell_body_entered", cell_i, cell_j) + _update_obs(cell_i, cell_j, detection_mask, true) + if debug_view: + _toggle_cell(cell_i, cell_j) + + +func _on_cell_body_exited(_body: Node2D, cell_i: int, cell_j: int): + #prints("_on_cell_body_exited", cell_i, cell_j) + _update_obs(cell_i, cell_j, detection_mask, false) + if debug_view: + _toggle_cell(cell_i, cell_j) diff --git a/examples/Platform2D/scenes/player/player.gd b/examples/Platform2D/scenes/player/player.gd new file mode 100644 index 0000000..d5ae260 --- /dev/null +++ b/examples/Platform2D/scenes/player/player.gd @@ -0,0 +1,90 @@ +extends CharacterBody2D +class_name Player + +@export var speed := 700.0 +@export var jump_velocity := -1400.0 +@export var map_manager: MapManager +@export var ai_controller: PlayerAIController + +var requested_movement: float +var requested_jump: bool + +@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D +@onready var initial_transform := transform + + +func _physics_process(delta: float) -> void: + handle_movement(delta) + move_and_slide() + end_episode_on_fell_down() + + +func handle_movement(delta: float): + # Gravity + if not is_on_floor(): + velocity += Vector2.DOWN * 4000 * delta + + # Controls (human or AI controlled) + var direction: float + if ai_controller.control_mode == AIController2D.ControlModes.HUMAN: + direction = Input.get_axis("move_left", "move_right") + requested_jump = Input.is_action_just_pressed("jump") + else: + direction = requested_movement + + # Horizontal movement + velocity.x = direction * speed + if velocity.x: + animated_sprite.flip_h = velocity.x < 0 + if is_on_floor(): + animated_sprite.animation = "move" + animated_sprite.play() + + # Jump + if requested_jump and is_on_floor(): + velocity.y = jump_velocity + animated_sprite.animation = "jump" + animated_sprite.play() + + # Stop animation if not moving + if velocity.length_squared() < 0.01 and is_on_floor(): + animated_sprite.animation = "move" + animated_sprite.stop() + + +func end_episode_on_fell_down() -> void: + if (position.y - map_manager.total_rows * map_manager.tile_set.tile_size.y) > 0: + end_episode(-1.0) + + +func end_episode(final_reward := 0.0, success := false) -> void: + ai_controller.end_episode(final_reward, success) + transform = initial_transform + map_manager.reset() + + +func _on_area_2d_body_shape_entered( + body_rid: RID, body: Node2D, _body_shape_index: int, _local_shape_index: int +) -> void: + if body is MapManager: + var coords = body.get_coords_for_body_rid(body_rid) + if body.get_cell_atlas_coords(coords) == MapManager.Tiles.COIN: + body.remove_coin_from_position(coords) + player_picked_up_coin() + elif body.get_cell_atlas_coords(coords) == MapManager.Tiles.GOAL: + player_reached_goal() + elif body.get_cell_atlas_coords(coords) == MapManager.Tiles.SPIKES: + player_hit_spikes() + + +func player_picked_up_coin() -> void: + ai_controller.reward += 1 + + +func player_reached_goal() -> void: + if map_manager.remaining_coins == 0: + end_episode(+10, true) + + +func player_hit_spikes() -> void: + end_episode(-0.1) diff --git a/examples/Platform2D/scenes/player/player.tscn b/examples/Platform2D/scenes/player/player.tscn new file mode 100644 index 0000000..43d65de --- /dev/null +++ b/examples/Platform2D/scenes/player/player.tscn @@ -0,0 +1,126 @@ +[gd_scene load_steps=14 format=3 uid="uid://d2qsl7semlkyv"] + +[ext_resource type="Script" path="res://scenes/player/player.gd" id="1_uuo8p"] +[ext_resource type="Texture2D" uid="uid://djgf0w4s12f86" path="res://assets/player/jump/Player1Jump1.png" id="2_6wykd"] +[ext_resource type="Texture2D" uid="uid://dtrkm6ibrh22k" path="res://assets/player/move/Player-1.png" id="2_t2oqs"] +[ext_resource type="Texture2D" uid="uid://b5ty3hrl7jtj3" path="res://assets/player/jump/Player1Jump2.png" id="3_0ssda"] +[ext_resource type="Texture2D" uid="uid://mohyg2vfkunp" path="res://assets/player/move/Player-2.png" id="3_y4ujj"] +[ext_resource type="Texture2D" uid="uid://dlec4dcwqdi66" path="res://assets/player/move/Player-3.png" id="4_iy4ba"] +[ext_resource type="Texture2D" uid="uid://f7aey6fwsl0i" path="res://assets/player/jump/Player1Jump3.png" id="4_ngojv"] +[ext_resource type="Script" path="res://addons/godot_rl_agents/sensors/sensors_2d/RaycastSensor2D.gd" id="6_ybkrn"] +[ext_resource type="Script" path="res://scenes/player/extended_grid_sensor_2d.gd" id="7_jr4fg"] +[ext_resource type="Script" path="res://scenes/player/player_ai_controller.gd" id="12_dkdhh"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_5tff7"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("2_6wykd") +}, { +"duration": 1.0, +"texture": ExtResource("3_0ssda") +}, { +"duration": 1.0, +"texture": ExtResource("4_ngojv") +}], +"loop": true, +"name": &"jump", +"speed": 6.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": ExtResource("2_t2oqs") +}, { +"duration": 1.0, +"texture": ExtResource("3_y4ujj") +}, { +"duration": 1.0, +"texture": ExtResource("4_iy4ba") +}], +"loop": true, +"name": &"move", +"speed": 6.0 +}] + +[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_jl23j"] +radius = 32.0 +height = 80.0 + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_vqw8d"] +size = Vector2(68.14, 81.28) + +[node name="Player" type="CharacterBody2D" node_paths=PackedStringArray("ai_controller")] +collision_layer = 2 +script = ExtResource("1_uuo8p") +ai_controller = NodePath("AIController2D") + +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."] +scale = Vector2(0.64, 0.64) +sprite_frames = SubResource("SpriteFrames_5tff7") +animation = &"move" +autoplay = "move" + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("CapsuleShape2D_jl23j") + +[node name="Area2D" type="Area2D" parent="."] +collision_mask = 29 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] +position = Vector2(0, 0.5) +shape = SubResource("RectangleShape2D_vqw8d") + +[node name="AIController2D" type="Node2D" parent="." node_paths=PackedStringArray("player", "raycast_sensors")] +script = ExtResource("12_dkdhh") +player = NodePath("..") +raycast_sensors = [NodePath("RaycastGround"), NodePath("RaycastSpike"), NodePath("RaycastCoin"), NodePath("RaycastCoin2"), NodePath("GridSensor2DCoin")] +reset_after = 2500 + +[node name="RaycastGround" type="Node2D" parent="AIController2D"] +visible = false +rotation = 1.5708 +script = ExtResource("6_ybkrn") +n_rays = 32.0 +ray_length = 3000 +cone_width = 205.0 +debug_draw = false + +[node name="RaycastSpike" type="Node2D" parent="AIController2D"] +visible = false +rotation = 1.5708 +script = ExtResource("6_ybkrn") +collision_mask = 8 +n_rays = 32.0 +ray_length = 3000 +cone_width = 205.0 +debug_draw = false + +[node name="RaycastCoin" type="Node2D" parent="AIController2D"] +visible = false +script = ExtResource("6_ybkrn") +collision_mask = 4 +n_rays = 9.0 +ray_length = 1280 +cone_width = 100.0 +debug_draw = false + +[node name="RaycastCoin2" type="Node2D" parent="AIController2D"] +visible = false +rotation = 3.14159 +script = ExtResource("6_ybkrn") +collision_mask = 4 +n_rays = 9.0 +ray_length = 1280 +cone_width = 100.0 +debug_draw = false + +[node name="GridSensor2DCoin" type="Node2D" parent="AIController2D"] +visible = false +position = Vector2(1000, 0) +script = ExtResource("7_jr4fg") +cell_width_override = 2000.0 +cell_height_override = 480.0 +detection_mask = 4 +grid_size_y = 1 + +[connection signal="body_shape_entered" from="Area2D" to="." method="_on_area_2d_body_shape_entered"] diff --git a/examples/Platform2D/scenes/player/player_ai_controller.gd b/examples/Platform2D/scenes/player/player_ai_controller.gd new file mode 100644 index 0000000..dbe0374 --- /dev/null +++ b/examples/Platform2D/scenes/player/player_ai_controller.gd @@ -0,0 +1,82 @@ +extends AIController2D +class_name PlayerAIController + +@export var player: Player +@export var raycast_sensors: Array[Node2D] + +var is_success := false + + +func _physics_process(_delta): + n_steps += 1 + if needs_reset: + reset() + + if n_steps > reset_after: + player.end_episode(-0.1) + + # To help training, we reset the episode if there are any remaining coins + # in the row above the player + var previous_row_coins := player.map_manager.count_coins_in_grid_row( + player.map_manager.get_grid_pos(player.global_position).y - 1 + ) + if previous_row_coins > 0: + player.end_episode(-0.1) + + +func end_episode(final_reward := 0.0, success := false) -> void: + is_success = success + reward += final_reward + done = true + reset() + + + +func get_info() -> Dictionary: + if done: + return {"is_success": is_success} + return {} + + +func get_obs() -> Dictionary: + var obs: Array[float] + + for sensor in raycast_sensors: + obs.append_array(sensor.get_observation()) + + var player_velocity := player.get_real_velocity() + player_velocity /= Vector2(player.speed, player.jump_velocity) + + obs.append_array( + [ + clampf(player_velocity.x, -1.0, 1.0), + clampf(player_velocity.y, -1.0, 1.0), + float(player.is_on_floor()) + ] + ) + + var goal_pos_global := player.map_manager.goal_position + var player_to_goal := player.to_local(goal_pos_global) + var goal_direction := player_to_goal.normalized() + var goal_dist := clampf(player_to_goal.length() / 640.0, 0, 1.0) + + obs.append_array([goal_direction.x, goal_direction.y, goal_dist]) + return {"obs": obs} + + +func get_reward() -> float: + return reward + + +func get_action_space() -> Dictionary: + return { + "move": {"size": 3, "action_type": "discrete"}, + "jump": {"size": 2, "action_type": "discrete"}, + } + + +func set_action(action) -> void: + player.requested_movement = (action.move - 1) + player.requested_jump = (action.jump == 1) + + reward -= action.jump * 0.01 diff --git a/examples/Platform2D/scenes/tilemap/tile_map_layer.gd b/examples/Platform2D/scenes/tilemap/tile_map_layer.gd new file mode 100644 index 0000000..0d1cc09 --- /dev/null +++ b/examples/Platform2D/scenes/tilemap/tile_map_layer.gd @@ -0,0 +1,145 @@ +extends TileMapLayer +class_name MapManager + + +## Maps tile names to tileset atlas coordinates +class Tiles: + const PLATFORM_LEFT_EDGE = Vector2i(0, 0) + const PLATFORM_MIDDLE = Vector2i(1, 0) + const PLATFORM_RIGHT_EDGE = Vector2i(2, 0) + const GROUND = Vector2i(0, 1) # currently not used + const GROUND_2 = Vector2i(1, 1) # currently not used + const SPIKES = Vector2i(2, 1) + const COIN = Vector2i(0, 2) + const GOAL = Vector2i(1, 2) + + +@export var rows_between_walkable_platforms: int = 3 +## Must be a multiple of rows_between_walkable_platforms +@export var total_rows: int = 40 +@export var total_columns: int = 10 + +## Coin parameters +@export var max_coins := 200 +@export var max_coins_per_row := 5 + +## Remaining coin count +var remaining_coins := 0 + +## Goal position in global coordinates +var goal_position: Vector2 + + +func _ready() -> void: + update_map() + + +func update_map(): + clear_map() + build_map() + + +func clear_map(): + remaining_coins = 0 + clear() + + +func build_map(): + var coins_total = 0 + + for y in range(0, total_rows, rows_between_walkable_platforms): + var coins_in_row = 0 + var walkable_tiles_in_row = total_columns + # Place a walkable platform + for x in range(0, total_columns): + set_cell(Vector2i(x, y), 0, Tiles.PLATFORM_MIDDLE) + + # Carve passages on all but the last platform row + if y < total_rows - rows_between_walkable_platforms: + # Carve out up to 5 passages down at random coords + for i in range(total_columns - 1): + var rand_x = randi_range(1, total_columns - 2) + if y > 0: + # Carve only where there is no carved passage at the same column in the previous platform row + while ( + get_cell_atlas_coords(Vector2i(rand_x, y - rows_between_walkable_platforms)) + == Vector2i(-1, -1) + ): + rand_x = randi_range(1, total_columns - 2) + + if not ( + get_cell_atlas_coords(Vector2i(rand_x, y)) == Tiles.PLATFORM_MIDDLE + and get_cell_atlas_coords(Vector2i(rand_x - 1, y)) == Tiles.PLATFORM_MIDDLE + and get_cell_atlas_coords(Vector2i(rand_x + 1, y)) == Tiles.PLATFORM_MIDDLE + ): + continue + + erase_cell(Vector2i(rand_x, y)) + walkable_tiles_in_row -= 1 + set_cell(Vector2i(rand_x - 1, y), 0, Tiles.PLATFORM_RIGHT_EDGE) + set_cell(Vector2i(rand_x + 1, y), 0, Tiles.PLATFORM_LEFT_EDGE) + + # COINS: Add random coin only if there is no passage below the row + if y < total_rows - 1 and coins_total < max_coins: + while coins_in_row < max_coins_per_row and coins_in_row < walkable_tiles_in_row: + var rand_x = randi_range(0, total_columns - 1) + var current_cell_coord = get_cell_atlas_coords(Vector2i(rand_x, y)) + var above_cell_coord = get_cell_atlas_coords(Vector2i(rand_x, y - 1)) + + if ( + (current_cell_coord != Vector2i(-1, -1)) + and (above_cell_coord == Vector2i(-1, -1)) + ): + var coin_pos := Vector2i(rand_x, y - 1) + set_cell(coin_pos, 0, Tiles.COIN) + coins_total += 1 + remaining_coins += 1 + coins_in_row += 1 + + # TRAPS: Add traps (up to 1 per row, depending on coins placed) + if y > 0: + var rand_x = randi_range(0, total_columns - 1) + var current_cell_coord = get_cell_atlas_coords(Vector2i(rand_x, y)) + var previous_row_cell_coord = get_cell_atlas_coords( + Vector2i(rand_x, y - rows_between_walkable_platforms) + ) + var above_cell_coord = get_cell_atlas_coords(Vector2i(rand_x, y - 1)) + + if ( + (previous_row_cell_coord != Vector2i(-1, -1)) + and (above_cell_coord == Vector2i(-1, -1)) + and (current_cell_coord == Tiles.PLATFORM_MIDDLE) + ): + var spike_pos := Vector2i(rand_x, y - 1) + remove_coin_from_position(spike_pos, false) + set_cell(spike_pos, 0, Tiles.SPIKES) + + # GOAL: Add 1 goal on the last level + if y == total_rows - rows_between_walkable_platforms: + var rand_x = randi_range(0, total_columns - 1) + var goal_pos := Vector2i(rand_x, y - 1) + remove_coin_from_position(goal_pos, false) + set_cell(goal_pos, 0, Tiles.GOAL) + goal_position = to_global(map_to_local(goal_pos)) + + +func count_coins_in_grid_row(grid_y: int) -> int: + var coins := 0 + for x in total_columns: + if get_cell_atlas_coords(Vector2i(x, grid_y)) == Tiles.COIN: + coins += 1 + return coins + + +func remove_coin_from_position(grid_position: Vector2i, clear_cell := true): + if get_cell_atlas_coords(grid_position) == Tiles.COIN: + remaining_coins -= 1 + if clear_cell: set_cell(grid_position, -1) + + +func get_grid_pos(position_global: Vector2) -> Vector2i: + return local_to_map(to_local(position_global)) + + +func reset(): + call_deferred("update_map") diff --git a/examples/Platform2D/scenes/tileset/tileset.tres b/examples/Platform2D/scenes/tileset/tileset.tres new file mode 100644 index 0000000..bcaf652 --- /dev/null +++ b/examples/Platform2D/scenes/tileset/tileset.tres @@ -0,0 +1,31 @@ +[gd_resource type="TileSet" load_steps=3 format=3 uid="uid://sdmrwh4xd6qj"] + +[ext_resource type="Texture2D" uid="uid://mow8g6gd34j3" path="res://assets/tilesheet.png" id="1_xi302"] + +[sub_resource type="TileSetAtlasSource" id="TileSetAtlasSource_oky61"] +texture = ExtResource("1_xi302") +texture_region_size = Vector2i(160, 160) +0:0/0 = 0 +0:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80) +1:0/0 = 0 +1:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80) +2:0/0 = 0 +2:0/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80) +0:1/0 = 0 +0:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80) +1:1/0 = 0 +1:1/0/physics_layer_0/polygon_0/points = PackedVector2Array(-80, -80, 80, -80, 80, 80, -80, 80) +2:1/0 = 0 +2:1/0/physics_layer_2/polygon_0/points = PackedVector2Array(-80, 80, -50.9091, -7.27273, 50.9091, -7.27273, 80, 80) +0:2/0 = 0 +0:2/0/physics_layer_1/polygon_0/points = PackedVector2Array(-21.8182, -21.8182, 21.8182, -21.8182, 21.8182, 21.8182, -21.8182, 21.8182, -21.8182, 21.8182) +1:2/0 = 0 +1:2/0/physics_layer_3/polygon_0/points = PackedVector2Array(-50.9091, -50.9091, -7.27273, 80, 7.27273, 80, 50.9091, -50.9091) + +[resource] +tile_size = Vector2i(160, 160) +physics_layer_0/collision_layer = 1 +physics_layer_1/collision_layer = 4 +physics_layer_2/collision_layer = 8 +physics_layer_3/collision_layer = 16 +sources/0 = SubResource("TileSetAtlasSource_oky61") diff --git a/examples/Platform2D/scenes/training_scene/inference_scene.tscn b/examples/Platform2D/scenes/training_scene/inference_scene.tscn new file mode 100644 index 0000000..e29efcd --- /dev/null +++ b/examples/Platform2D/scenes/training_scene/inference_scene.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=3 format=3 uid="uid://c2baxuisewykf"] + +[ext_resource type="PackedScene" uid="uid://danlnf1x033rf" path="res://scenes/game_scene/game_scene.tscn" id="1_pwlfw"] +[ext_resource type="Script" path="res://addons/godot_rl_agents/sync.gd" id="3_bqjwy"] + +[node name="InferenceScene" type="Node2D"] + +[node name="GameScene" parent="." instance=ExtResource("1_pwlfw")] + +[node name="Sync" type="Node" parent="."] +script = ExtResource("3_bqjwy") +control_mode = 2 +action_repeat = 4 +onnx_model_path = "model.onnx" diff --git a/examples/Platform2D/scenes/training_scene/training_scene.tscn b/examples/Platform2D/scenes/training_scene/training_scene.tscn new file mode 100644 index 0000000..a3c6410 --- /dev/null +++ b/examples/Platform2D/scenes/training_scene/training_scene.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=3 format=3 uid="uid://fp0m16qnoe0r"] + +[ext_resource type="PackedScene" uid="uid://danlnf1x033rf" path="res://scenes/game_scene/game_scene.tscn" id="1_bccmi"] +[ext_resource type="Script" path="res://addons/godot_rl_agents/sync.gd" id="3_sbmsv"] + +[node name="TrainingScene" type="Node2D"] + +[node name="GameScene" parent="." instance=ExtResource("1_bccmi")] + +[node name="GameScene2" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(10000, 0) + +[node name="GameScene3" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(20000, 0) + +[node name="GameScene4" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(-10000, 0) + +[node name="GameScene5" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(0, 10048) + +[node name="GameScene6" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(10000, 10048) + +[node name="GameScene7" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(20000, 10048) + +[node name="GameScene8" parent="." instance=ExtResource("1_bccmi")] +position = Vector2(-10000, 10048) + +[node name="Sync" type="Node" parent="."] +script = ExtResource("3_sbmsv") +action_repeat = 4 +onnx_model_path = "C:\\Users\\Computer\\PycharmProjects\\godot_rl_agents\\examples\\model.onnx" From 71fe37eea8ecf37634d4b9adb4f38bfcbc7a479c Mon Sep 17 00:00:00 2001 From: Ivan-267 <61947090+Ivan-267@users.noreply.github.com> Date: Sat, 7 Dec 2024 16:05:24 +0100 Subject: [PATCH 2/5] Update readme.md --- examples/Platform2D/readme.md | 54 ++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/examples/Platform2D/readme.md b/examples/Platform2D/readme.md index 64bd049..4374c1b 100644 --- a/examples/Platform2D/readme.md +++ b/examples/Platform2D/readme.md @@ -1,22 +1,68 @@ # Platform2D environment ## Goal: +The player must pick up all of the coins and reach the goal, while avoiding traps and falling outside of the map. +It's not allowed to move to a lower level without first picking up all of the coins in the current one. ## Observations: +```gdscript +func get_obs() -> Dictionary: + var obs: Array[float] + + for sensor in raycast_sensors: + obs.append_array(sensor.get_observation()) + + var player_velocity := player.get_real_velocity() + player_velocity /= Vector2(player.speed, player.jump_velocity) + + obs.append_array( + [ + clampf(player_velocity.x, -1.0, 1.0), + clampf(player_velocity.y, -1.0, 1.0), + float(player.is_on_floor()) + ] + ) + + var goal_pos_global := player.map_manager.goal_position + var player_to_goal := player.to_local(goal_pos_global) + var goal_direction := player_to_goal.normalized() + var goal_dist := clampf(player_to_goal.length() / 640.0, 0, 1.0) + + obs.append_array([goal_direction.x, goal_direction.y, goal_dist]) + return {"obs": obs} +``` + +Observations include data from multiple raycast sensors (and a grid sensor with 2 cells to detect any remaining coins left or right from the player in the same row), +player velocity, whether jumping is allowed or not (`is_on_floor()`), as well as a direction vector and distance scalar toward the goal. + ## Actions: +```python +func get_action_space() -> Dictionary: + return { + "move": {"size": 3, "action_type": "discrete"}, + "jump": {"size": 2, "action_type": "discrete"}, + } +``` +The player can stand still, move left/right, and jump. ## Running inference: -If you’d just like to test the env using the pre-trained onnx model, +If you’d just like to test the env using the pre-trained onnx model, open `res://scenes/training_scene/inference_scene.tscn` in Godot, then press `F6`. ## Training: There’s an included onnx file that was trained with https://github.com/edbeeching/godot_rl_agents/blob/main/examples/stable_baselines3_example.py -CL arguments used (also onnx export and model saving was used, enable as needed): +CL arguments used (also onnx export and model saving was used, enable as needed, add `env_path` too to set the exported executable of the platform): ```python ---speedup=8 +--speedup=32 --n_parallel=8 -``` \ No newline at end of file +--timesteps=10_000_000 +--linear_lr_schedule +``` + +Stats from the training session (success rate only): + +![training_stats](https://github.com/user-attachments/assets/e799623b-c049-419d-b519-9fd9e9c6be16) From bbf0a02f1a9450c1c823a7cba0cb7138840ecdbb Mon Sep 17 00:00:00 2001 From: Ivan-267 <61947090+Ivan-267@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:42:19 +0100 Subject: [PATCH 3/5] Update readme.md --- examples/Platform2D/readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/Platform2D/readme.md b/examples/Platform2D/readme.md index 4374c1b..f306734 100644 --- a/examples/Platform2D/readme.md +++ b/examples/Platform2D/readme.md @@ -1,4 +1,5 @@ # Platform2D environment +https://github.com/user-attachments/assets/c2ca1ee6-5e67-4288-9a02-df133173dbcf ## Goal: The player must pick up all of the coins and reach the goal, while avoiding traps and falling outside of the map. From 4bca4a31016ba75771027f15ae38367c432a99ca Mon Sep 17 00:00:00 2001 From: Ivan-267 <61947090+Ivan-267@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:44:41 +0100 Subject: [PATCH 4/5] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 52234ba..7e17db2 100644 --- a/README.md +++ b/README.md @@ -38,4 +38,9 @@ https://github.com/user-attachments/assets/80c8b16b-df09-4607-bcc6-2b0e760f03c5 #### Robot FPS: https://github.com/user-attachments/assets/d44efbd1-59c2-4828-ae88-d8b374fb27e2 +#### Platform2D environment: +https://github.com/user-attachments/assets/468f3eb5-ea9f-4eb0-8ca1-6b8b67f37d02 + + + From 1cc694f765c77af30e57880b171af0ddfe28c379 Mon Sep 17 00:00:00 2001 From: Ivan-267 <61947090+Ivan-267@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:45:25 +0100 Subject: [PATCH 5/5] Update readme.md --- examples/Platform2D/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Platform2D/readme.md b/examples/Platform2D/readme.md index f306734..9d84afa 100644 --- a/examples/Platform2D/readme.md +++ b/examples/Platform2D/readme.md @@ -1,5 +1,5 @@ # Platform2D environment -https://github.com/user-attachments/assets/c2ca1ee6-5e67-4288-9a02-df133173dbcf +https://github.com/user-attachments/assets/9e61e70d-0968-4432-8952-44c38f7e971f ## Goal: The player must pick up all of the coins and reach the goal, while avoiding traps and falling outside of the map.