Refactor Material Creator Plugin Demo to use custom resource-based data (#1219)

This commit is contained in:
Šarūnas Ramonas
2025-10-08 02:02:21 +03:00
committed by GitHub
parent 217436bce6
commit 7d73b8c4cb
6 changed files with 228 additions and 68 deletions

View File

@@ -1,20 +1,25 @@
# Material Creator Plugin Demo # Material Creator Plugin Demo
This plugin demo contains a custom material creator This plugin demo contains a custom material creation dock
interface using a custom dock in the editor. inside the Godot editor.
Custom docks are made of Control nodes, they run in the Custom docks are made of Control nodes, they run in the
editor, and any behavior must be done through `tool` scripts. editor, and any behavior must be done through `tool` scripts.
For more information, see this documentation article: For more information, see this documentation article:
https://docs.godotengine.org/en/latest/tutorials/plugins/editor/making_plugins.html#a-custom-dock https://docs.godotengine.org/en/latest/tutorials/plugins/editor/making_plugins.html#a-custom-dock
This plugin allows you to specify color, metallic, and ## Features
roughness values, and then use it as a material. - 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 ## Implementation notes
nodes by selecting them and then clicking "Apply". - `.silly_mat` format is registered through `SillyMatFormatSaver` and
This shows how a plugin can interact closely with the `SillyMatFormatLoader` in the plugin.
editor, manipulating nodes the user selects. - Custm docks are built from `Control` nodes and run as `@tool` scripts.
Alternatively, you can also save the material to
a file, and then load it back into the plugin later.

View File

@@ -2,7 +2,7 @@
extends Panel extends Panel
# In this file, the word "silly" is used to make it obvious that the name is arbitrary. # 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 var editor_interface: EditorInterface
@@ -11,8 +11,26 @@ func _ready() -> void:
$VBoxContainer/ApplyButton.pressed.connect(apply_pressed) $VBoxContainer/ApplyButton.pressed.connect(apply_pressed)
$VBoxContainer/SaveButton.pressed.connect(save_pressed) $VBoxContainer/SaveButton.pressed.connect(save_pressed)
$VBoxContainer/LoadButton.pressed.connect(load_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) $LoadMaterialDialog.file_selected.connect(load_file_selected)
RenderingServer.canvas_item_set_clip(get_canvas_item(), true) RenderingServer.canvas_item_set_clip(get_canvas_item(), true)
@@ -24,12 +42,55 @@ func load_pressed() -> void:
$LoadMaterialDialog.popup_centered_ratio() $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: func apply_pressed() -> void:
# Using the passed in editor interface, get the selected nodes in the editor. # Using the passed in editor interface, get the selected nodes in the editor.
var editor_selection: EditorSelection = editor_interface.get_selection() var editor_selection: EditorSelection = editor_interface.get_selection()
var selected_nodes := editor_selection.get_selected_nodes() var selected_nodes := editor_selection.get_selected_nodes()
if selected_nodes.is_empty(): if selected_nodes.is_empty():
push_error("Material Creator: Can't apply the material, because there are no nodes selected!") 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() 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" # 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) 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: 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) if ext == "mtxt":
# and initialize it. # Load SOURCE by manual parse (works inside/outside res://)
var silly_resource: Variant = silly_material_resource.new() var loaded := _read_source_silly(path)
#silly_resource.init() if loaded == null:
push_error("Material Creator: Failed to parse source at %s" % path)
# If the file exists, then open it. return false
if FileAccess.file_exists(path): $VBoxContainer/AlbedoColorPicker.color = loaded.albedo_color
var file := FileAccess.open(path, FileAccess.READ) $VBoxContainer/MetallicSlider.value = loaded.metallic_strength
$VBoxContainer/RoughnessSlider.value = loaded.roughness_strength
# Get the JSON string and convert it into a silly material. return true
var json_dict_as_string := file.get_line() else:
if json_dict_as_string != null: # Load RESOURCE via ResourceLoader (silly_mat via your loader, tres via built-in)
silly_resource.from_json(json_dict_as_string) var silly_resource: Resource = ResourceLoader.load(path)
else: if silly_resource == null:
push_error("Material Creator: Failed to load resource at %s" % path)
return false return false
$VBoxContainer/AlbedoColorPicker.color = silly_resource.albedo_color $VBoxContainer/AlbedoColorPicker.color = silly_resource.albedo_color
$VBoxContainer/MetallicSlider.value = silly_resource.metallic_strength $VBoxContainer/MetallicSlider.value = silly_resource.metallic_strength
$VBoxContainer/RoughnessSlider.value = silly_resource.roughness_strength $VBoxContainer/RoughnessSlider.value = silly_resource.roughness_strength
# Return `true` to indicate success.
return true return true
# If the file does not exist, then return `false` to indicate failure.
return false
func _silly_resource_from_values() -> Resource:
func _silly_resource_from_values() -> Variant:
# Get the values from the sliders and color picker.
var color: Color = $VBoxContainer/AlbedoColorPicker.color var color: Color = $VBoxContainer/AlbedoColorPicker.color
var metallic: float = $VBoxContainer/MetallicSlider.value var metallic: float = $VBoxContainer/MetallicSlider.value
var roughness: float = $VBoxContainer/RoughnessSlider.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_res: Resource = silly_material_resource.new()
var silly_resource: Variant = silly_material_resource.new() silly_res.albedo_color = color
#silly_resource.init() silly_res.metallic_strength = metallic
silly_res.roughness_strength = roughness
return silly_res
# Assign the values. # ---------------------------------------------------------------
silly_resource.albedo_color = color # Source (.mtxt) helpers.
silly_resource.metallic_strength = metallic # ---------------------------------------------------------------
silly_resource.roughness_strength = roughness
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

View File

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

View File

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

View File

@@ -12,9 +12,16 @@
extends EditorPlugin extends EditorPlugin
var io_material_dialog: Panel var io_material_dialog: Panel
var _loader: SillyMatFormatLoader
var _saver: SillyMatFormatSaver
func _enter_tree() -> void: 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 = preload("res://addons/material_creator/material_dock.tscn").instantiate()
io_material_dialog.editor_interface = get_editor_interface() io_material_dialog.editor_interface = get_editor_interface()
add_control_to_dock(DOCK_SLOT_LEFT_UL, io_material_dialog) add_control_to_dock(DOCK_SLOT_LEFT_UL, io_material_dialog)
@@ -22,3 +29,10 @@ func _enter_tree() -> void:
func _exit_tree() -> void: func _exit_tree() -> void:
remove_control_from_docks(io_material_dialog) 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

View File

@@ -1,15 +1,14 @@
@tool @tool
extends Node extends Resource
# NOTE: In theory, this would extend from Resource, but until saving and loading resources class_name SillyMaterialResource
# 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.
var albedo_color := Color.BLACK # Use export to make properties visible and serializable in the inspector and for resource saving/loading.
var metallic_strength := 0.0 @export var albedo_color: Color = Color.BLACK
var roughness_strength := 0.0 @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 # Convert our data into an dictionary so we can convert it
# into the JSON format. # into the JSON format.
func make_json() -> String: func make_json() -> String:
@@ -41,10 +40,8 @@ func from_json(json_dict_as_string: String) -> void:
# Make a StandardMaterial3D using our variables. # Make a StandardMaterial3D using our variables.
func make_material() -> StandardMaterial3D: func make_material() -> StandardMaterial3D:
var material := StandardMaterial3D.new() var mat = StandardMaterial3D.new()
mat.albedo_color = albedo_color
material.albedo_color = albedo_color mat.metallic = metallic_strength
material.metallic = metallic_strength mat.roughness = roughness_strength
material.roughness = roughness_strength return mat
return material