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

View File

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

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

View File

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