From 7d73b8c4cb95b394bc69a572d2408ccd351e6d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0ar=C5=ABnas=20Ramonas?= Date: Wed, 8 Oct 2025 02:02:21 +0300 Subject: [PATCH] Refactor Material Creator Plugin Demo to use custom resource-based data (#1219) --- plugins/addons/material_creator/README.md | 27 ++-- .../material_creator/material_creator.gd | 153 +++++++++++++----- .../material_format_loader.gd | 40 +++++ .../material_creator/material_format_saver.gd | 35 ++++ .../material_creator/material_plugin.gd | 14 ++ .../material_creator/material_resource.gd | 27 ++-- 6 files changed, 228 insertions(+), 68 deletions(-) create mode 100644 plugins/addons/material_creator/material_format_loader.gd create mode 100644 plugins/addons/material_creator/material_format_saver.gd diff --git a/plugins/addons/material_creator/README.md b/plugins/addons/material_creator/README.md index 147fbca7..6518a71a 100644 --- a/plugins/addons/material_creator/README.md +++ b/plugins/addons/material_creator/README.md @@ -1,20 +1,25 @@ # Material Creator Plugin Demo -This plugin demo contains a custom material creator -interface using a custom dock in the editor. +This plugin demo contains a custom material creation dock +inside the Godot editor. Custom docks are made of Control nodes, they run in the editor, and any behavior must be done through `tool` scripts. For more information, see this documentation article: https://docs.godotengine.org/en/latest/tutorials/plugins/editor/making_plugins.html#a-custom-dock -This plugin allows you to specify color, metallic, and -roughness values, and then use it as a material. +## Features +- Adjust albedo color, metallic and rouphness values interactively. +- Apply the generated material to selected 3D nodes in the editor. +- Save and load materials in two ways: + - `.silly_mat`: Custom Godot Resource type, handled by custom saver/loader + included in the plygin. + - `.mtxt`: Plain-text format. Useful for external editing or as an + interchange format. + - `.tres`: Standard Godot resource format (works without the custom + loader). -You can apply this material directly to Spatial -nodes by selecting them and then clicking "Apply". -This shows how a plugin can interact closely with the -editor, manipulating nodes the user selects. - -Alternatively, you can also save the material to -a file, and then load it back into the plugin later. +## Implementation notes +- `.silly_mat` format is registered through `SillyMatFormatSaver` and +`SillyMatFormatLoader` in the plugin. +- Custm docks are built from `Control` nodes and run as `@tool` scripts. diff --git a/plugins/addons/material_creator/material_creator.gd b/plugins/addons/material_creator/material_creator.gd index efe117ee..43b7e4dd 100644 --- a/plugins/addons/material_creator/material_creator.gd +++ b/plugins/addons/material_creator/material_creator.gd @@ -2,7 +2,7 @@ extends Panel # In this file, the word "silly" is used to make it obvious that the name is arbitrary. -var silly_material_resource = preload("res://addons/material_creator/material_resource.gd") +var silly_material_resource := preload("res://addons/material_creator/material_resource.gd") var editor_interface: EditorInterface @@ -11,8 +11,26 @@ func _ready() -> void: $VBoxContainer/ApplyButton.pressed.connect(apply_pressed) $VBoxContainer/SaveButton.pressed.connect(save_pressed) $VBoxContainer/LoadButton.pressed.connect(load_pressed) - $SaveMaterialDialog.file_selected.connect(save_file_selected) + + $SaveMaterialDialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + $SaveMaterialDialog.access = FileDialog.ACCESS_RESOURCES + $SaveMaterialDialog.current_dir = "res://materials" + $SaveMaterialDialog.current_file = "new_material.silly_mat" + $SaveMaterialDialog.filters = PackedStringArray([ + "*.silly_mat ; Silly Material (resource)", + "*.tres ; Godot Resource (resource)", + "*.mtxt ; Silly Material (source)" + ]) + $SaveMaterialDialog.confirmed.connect(_on_save_confirmed) + + $LoadMaterialDialog.access = FileDialog.ACCESS_RESOURCES + $LoadMaterialDialog.filters = PackedStringArray([ + "*.silly_mat ; Silly Material (resource)", + "*.tres ; Godot Resource (resource)", + "*.mtxt ; Silly Material (source)" + ]) $LoadMaterialDialog.file_selected.connect(load_file_selected) + RenderingServer.canvas_item_set_clip(get_canvas_item(), true) @@ -24,12 +42,55 @@ func load_pressed() -> void: $LoadMaterialDialog.popup_centered_ratio() +func _on_save_confirmed() -> void: + var path = $SaveMaterialDialog.get_current_path() + if path.is_empty(): + push_error("Material Creator: No path chosen for saving.") + return + + # If user typed no extension, default to .silly_mat (resource path). + if not path.get_file().contains("."): + path += ".silly_mat" + + var ext = path.get_extension().to_lower() + + # Ensure directory exists under res:// when saving inside project. + var dir = path.get_base_dir() + if path.begins_with("res://") and not DirAccess.dir_exists_absolute(dir): + var mk := DirAccess.make_dir_recursive_absolute(dir) + if mk != OK: + push_error("Material Creator: Can't create folder: %s (%s)" % [dir, error_string(mk)]) + return + + var res: Resource = _silly_resource_from_values() + + match ext: + "mtxt": + # Write SOURCE file (no ResourceSaver, works anywhere). + var ok := _write_source_silly(path, res) + if not ok: + push_error("Material Creator: Failed to write source .mtxt at %s" % path) + else: + print("Material Creator: Wrote source to ", path) + "silly_mat", "tres": + # Save RESOURCE (requires your custom saver for .silly_mat). + res.resource_path = path + var err := ResourceSaver.save(res, path) + if err != OK: + push_error("Material Creator: Failed to save resource: %s (%s)" % [path, error_string(err)]) + else: + print("Material Creator: Saved resource to ", path) + _: + push_error("Material Creator: Unsupported extension: ." + ext) + + func apply_pressed() -> void: # Using the passed in editor interface, get the selected nodes in the editor. var editor_selection: EditorSelection = editor_interface.get_selection() var selected_nodes := editor_selection.get_selected_nodes() if selected_nodes.is_empty(): push_error("Material Creator: Can't apply the material, because there are no nodes selected!") + return var new_material: StandardMaterial3D = _silly_resource_from_values().make_material() # Go through the selected nodes and see if they have the "set_surface_override_material" @@ -40,57 +101,65 @@ func apply_pressed() -> void: node.set_surface_override_material(0, new_material) -func save_file_selected(path: String) -> bool: - var silly_resource: Variant = _silly_resource_from_values() - # Make a file, store the silly material as a JSON string. - var file := FileAccess.open(path, FileAccess.WRITE) - file.store_string(silly_resource.make_json()) - - return true - - func load_file_selected(path: String) -> bool: - var SpatialMaterial_Silly: StandardMaterial3D = null + var ext := path.get_extension().to_lower() - # Make a new silly resource (which in this case actually is a node) - # and initialize it. - var silly_resource: Variant = silly_material_resource.new() - #silly_resource.init() - - # If the file exists, then open it. - if FileAccess.file_exists(path): - var file := FileAccess.open(path, FileAccess.READ) - - # Get the JSON string and convert it into a silly material. - var json_dict_as_string := file.get_line() - if json_dict_as_string != null: - silly_resource.from_json(json_dict_as_string) - else: + if ext == "mtxt": + # Load SOURCE by manual parse (works inside/outside res://) + var loaded := _read_source_silly(path) + if loaded == null: + push_error("Material Creator: Failed to parse source at %s" % path) + return false + $VBoxContainer/AlbedoColorPicker.color = loaded.albedo_color + $VBoxContainer/MetallicSlider.value = loaded.metallic_strength + $VBoxContainer/RoughnessSlider.value = loaded.roughness_strength + return true + else: + # Load RESOURCE via ResourceLoader (silly_mat via your loader, tres via built-in) + var silly_resource: Resource = ResourceLoader.load(path) + if silly_resource == null: + push_error("Material Creator: Failed to load resource at %s" % path) return false - $VBoxContainer/AlbedoColorPicker.color = silly_resource.albedo_color $VBoxContainer/MetallicSlider.value = silly_resource.metallic_strength $VBoxContainer/RoughnessSlider.value = silly_resource.roughness_strength - - # Return `true` to indicate success. return true - # If the file does not exist, then return `false` to indicate failure. - return false - -func _silly_resource_from_values() -> Variant: - # Get the values from the sliders and color picker. +func _silly_resource_from_values() -> Resource: var color: Color = $VBoxContainer/AlbedoColorPicker.color var metallic: float = $VBoxContainer/MetallicSlider.value var roughness: float = $VBoxContainer/RoughnessSlider.value - # Make a new silly resource (which in this case actually is a node) and initialize it. - var silly_resource: Variant = silly_material_resource.new() - #silly_resource.init() + var silly_res: Resource = silly_material_resource.new() + silly_res.albedo_color = color + silly_res.metallic_strength = metallic + silly_res.roughness_strength = roughness + return silly_res - # Assign the values. - silly_resource.albedo_color = color - silly_resource.metallic_strength = metallic - silly_resource.roughness_strength = roughness +# --------------------------------------------------------------- +# Source (.mtxt) helpers. +# --------------------------------------------------------------- - return silly_resource +func _write_source_silly(path: String, res: Resource) -> bool: + var mat_file := FileAccess.open(path, FileAccess.WRITE) + if mat_file == null: + return false + mat_file.store_line("SILLY_MAT v1") + mat_file.store_line(res.albedo_color.to_html(true)) # RGBA hex + mat_file.store_line(str(res.metallic_strength)) + mat_file.store_line(str(res.roughness_strength)) + return true + + +func _read_source_silly(path: String) -> Resource: + var mat_file := FileAccess.open(path, FileAccess.READ) + if mat_file == null: + return null + var header := mat_file.get_line() + if not header.begins_with("SILLY_MAT"): + return null + var mat_res := silly_material_resource.new() + mat_res.albedo_color = Color(mat_file.get_line()) # from hex string + mat_res.metallic_strength = float(mat_file.get_line()) + mat_res.roughness_strength = float(mat_file.get_line()) + return mat_res diff --git a/plugins/addons/material_creator/material_format_loader.gd b/plugins/addons/material_creator/material_format_loader.gd new file mode 100644 index 00000000..9039258f --- /dev/null +++ b/plugins/addons/material_creator/material_format_loader.gd @@ -0,0 +1,40 @@ +@tool +extends ResourceFormatLoader +class_name SillyMatFormatLoader +## Custom loader for the .silly_mat file format. +## Allows Godot to recognize and load SillyMaterialResource files. +## Register this loader in the EditorPlugin to enable saving/loading resources. + + +## Returns the list of file extensions this loader supports. +func _get_recognized_extensions() -> PackedStringArray: + # Returns only ".silly_mat" + return PackedStringArray(["silly_mat"]) + + +## Returns what resource type this loader handles. +func _handles_type(typename: StringName) -> bool: + return typename == "SillyMaterialResource" + + +## Returns the resource type name based on file extension. +func _get_resource_type(path: String) -> String: + return "SillyMaterialResource" if path.get_extension() == "silly_mat" else "" + + +## Main load function. Reads .silly_mat and constructs a SillyMaterialResource. +func _load(path: String, original_path: String, use_sub_threads, cache_mode): + var mat_file = FileAccess.open(path, FileAccess.READ) + if mat_file == null: + return ERR_CANT_OPEN + + # Check header line to validate file format version. + if mat_file.get_line() != "SILLY_MAT v1": + return ERR_PARSE_ERROR + + # Create and Fill SillyMaterialResource + var mat_res: SillyMaterialResource = SillyMaterialResource.new() + mat_res.albedo_color = Color(mat_file.get_line()) + mat_res.metallic_strength = float(mat_file.get_line()) + mat_res.roughness_strength = float(mat_file.get_line()) + return mat_res diff --git a/plugins/addons/material_creator/material_format_saver.gd b/plugins/addons/material_creator/material_format_saver.gd new file mode 100644 index 00000000..71df9dbb --- /dev/null +++ b/plugins/addons/material_creator/material_format_saver.gd @@ -0,0 +1,35 @@ +@tool +extends ResourceFormatSaver +class_name SillyMatFormatSaver +## Custom saver for the .silly_mat file format. +## Works together with SillyMatFormatLoader to make SillyMaterialResource. + + +## This saver only supports SilluMaterialResource. +func _recognize(resource: Resource) -> bool: + return resource is SillyMaterialResource + + +## Return list of file extensions this saver will write. +func _get_recognized_extensions(resource: Resource) -> PackedStringArray: + return PackedStringArray(["silly_mat"]) + + +## Main save function. +## Serializes a SillyMaterialResource into .silly_mat format. +## +## It will write simple text-based format, one property per line. +func _save(resource: Resource, path: String, flags: int) -> int: + var mat_res: SillyMaterialResource = resource as SillyMaterialResource + if mat_res == null: + return ERR_INVALID_DATA + + var mat_file := FileAccess.open(path, FileAccess.WRITE) + if mat_file == null: + return ERR_CANT_OPEN + + mat_file.store_line("SILLY_MAT v1") + mat_file.store_line(mat_res.albedo_color.to_html(true)) # Stored in HTML hex. + mat_file.store_line(str(mat_res.metallic_strength)) + mat_file.store_line(str(mat_res.roughness_strength)) + return OK diff --git a/plugins/addons/material_creator/material_plugin.gd b/plugins/addons/material_creator/material_plugin.gd index d5135b37..b69e6e1c 100644 --- a/plugins/addons/material_creator/material_plugin.gd +++ b/plugins/addons/material_creator/material_plugin.gd @@ -12,9 +12,16 @@ extends EditorPlugin var io_material_dialog: Panel +var _loader: SillyMatFormatLoader +var _saver: SillyMatFormatSaver func _enter_tree() -> void: + _loader = SillyMatFormatLoader.new() + _saver = SillyMatFormatSaver.new() + ResourceLoader.add_resource_format_loader(_loader) + ResourceSaver.add_resource_format_saver(_saver) + io_material_dialog = preload("res://addons/material_creator/material_dock.tscn").instantiate() io_material_dialog.editor_interface = get_editor_interface() add_control_to_dock(DOCK_SLOT_LEFT_UL, io_material_dialog) @@ -22,3 +29,10 @@ func _enter_tree() -> void: func _exit_tree() -> void: remove_control_from_docks(io_material_dialog) + + if _loader: + ResourceLoader.remove_resource_format_loader(_loader) + _loader = null + if _saver: + ResourceSaver.remove_resource_format_saver(_saver) + _saver = null diff --git a/plugins/addons/material_creator/material_resource.gd b/plugins/addons/material_creator/material_resource.gd index aa86d38f..740d5dcc 100644 --- a/plugins/addons/material_creator/material_resource.gd +++ b/plugins/addons/material_creator/material_resource.gd @@ -1,15 +1,14 @@ @tool -extends Node -# NOTE: In theory, this would extend from Resource, but until saving and loading resources -# works in Godot, we'll stick with extending from Node and using JSON files to save/load data. -# -# See `material_import.gd` for more information. +extends Resource +class_name SillyMaterialResource -var albedo_color := Color.BLACK -var metallic_strength := 0.0 -var roughness_strength := 0.0 +# Use export to make properties visible and serializable in the inspector and for resource saving/loading. +@export var albedo_color: Color = Color.BLACK +@export var metallic_strength: float = 0.0 +@export var roughness_strength: float = 0.0 +# Create a StandardMaterial3D from the resource's properties. # Convert our data into an dictionary so we can convert it # into the JSON format. func make_json() -> String: @@ -41,10 +40,8 @@ func from_json(json_dict_as_string: String) -> void: # Make a StandardMaterial3D using our variables. func make_material() -> StandardMaterial3D: - var material := StandardMaterial3D.new() - - material.albedo_color = albedo_color - material.metallic = metallic_strength - material.roughness = roughness_strength - - return material + var mat = StandardMaterial3D.new() + mat.albedo_color = albedo_color + mat.metallic = metallic_strength + mat.roughness = roughness_strength + return mat