Adding a Texture to a New Material During Import

Godot Version

4.6.1.rc1

Question

I am trying to automate the steps in this video by Kay Lousberg on importing their models: Using KayKit Characters in Godot (Detailed Version). I’ve created an import script to do so.

I am using the Barbarian model found here for free: KayKit - Character Pack : Adventurers by Kay Lousberg (To be clear, I am using this paid version: The Complete KayKit by Kay Lousberg)

My current issues are twofold:

  1. When I create an image (code below) by extracting the texture from the material and save it, I have to use EditorInterface.get_resource_filesystem().call_deferred("scan") for it to show up in the FileSystem windows without a reboot, but it throws errors. NOTE: No errors were thrown when I did this with just the material, once I added in the call_deferred().
  2. I cannot figure out how to assign the saved texture to the material. Currently I’m saving it, then loading it to assign it back to where I got it from.

The manual equivalent of this is dragging and dropping the texture into the material after saving the material externally.

Problem Code:

# Create Texture
var texture_path: String = GameConstants.TEXTURES_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + "_texture.png"
ensure_path_exists(texture_path)
var texture = material.albedo_texture
var image = texture.get_image()
var error= image.save_png(texture_path)
if error != OK:
	print("Saving texture failed.")
else:
	print("Image saved to %s" % [texture_path])

#Create material
material.albedo_texture = ResourceLoader.load(texture_path)
ensure_path_exists(material_path)
ResourceSaver.save(material, material_path)
print("Material saved to %s" % [material_path])
var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
mesh.surface_set_material(index, saved_material)

#Refresh FileSystem so saved texture(s) and material(s) appear immeditaely
EditorInterface.get_resource_filesystem().call_deferred("scan")

Full Import Script:

@tool
extends EditorScenePostImport

enum LoopMode { LOOP_NONE, LOOP_LINEAR, LOOP_PINGPONG }
const LOOPMODE = ["LOOP_NONE", "[b][color=green]LOOP_LINEAR[/color][/b]", "[b][color=orange]LOOP_PINGPONG[/color][/b]"]
var owner_scene
var slots: Dictionary = {
	"HeadSlot" : "head",
	"RightHandSlot": "handslot.r",
	"LeftHandSlot": "handslot.l",
	"BackSlot": "chest"
}


# Import script for rig to set up everything so little editing is needed once the character is
# instantiated.
func _post_import(scene):
	owner_scene = scene
	print_rich("\n[b][color=red]Begin Post-import[/color] -> [color=purple]%s[/color] as [color=green]%s[/color][/b]" % [scene.name + "Skin", scene.get_class()])
	print_rich("[b]Time/Date Stamp: %s[/b]\n" % [Time.get_datetime_string_from_system(false, true)])
	prepare(scene)
	enhance(scene)
	scene.name = scene.name + "Skin"
	print_rich("\n[b][color=red]NOTE:[/color] Ignore any [color=salmon]reimport[/color] or [color=salmon]editor/gui/progress_dialog[/color] errors that appear below this line. This happens when an import script creates new directories or files and we scan so they will show up in FileSystem.[/b]")
	return scene


func prepare(node: Node) -> void:
	# Rotating the model to face the other direction so it moves in the correct direction when animated.
	if node is Skeleton3D:
		node.rotate_y(deg_to_rad(-180.0))
		print_rich("Post-import: [b]Rotated [color=green]%s[/color] [color=yellow]-180 degrees[/color][/b] on the [b][color=green]y-axis[/color][/b]" % [node.get_class()])
	
	if node is MeshInstance3D:
		var mesh: Mesh = node.mesh
		for index in mesh.get_surface_count():
			var material: StandardMaterial3D = mesh.surface_get_material(index)
			var material_path: String = GameConstants.MATERIALS_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + ".material"
			if ResourceLoader.exists(material_path):
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				print("Assigning saved material")
				mesh.surface_set_material(index, saved_material)
			else:
				# Create Texture
				var texture_path: String = GameConstants.TEXTURES_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + "_texture.png"
				ensure_path_exists(texture_path)
				var texture = material.albedo_texture
				var image = texture.get_image()
				var error= image.save_png(texture_path)
				if error != OK:
					print("Saving texture failed.")
				else:
					print("Image saved to %s" % [texture_path])

				#Create material
				material.albedo_texture = ResourceLoader.load(texture_path)
				ensure_path_exists(material_path)
				ResourceSaver.save(material, material_path)
				print("Material saved to %s" % [material_path])
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				mesh.surface_set_material(index, saved_material)

				#Refresh FileSystem so saved texture(s) and material(s) appear immeditaely
				EditorInterface.get_resource_filesystem().call_deferred("scan")
				
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		prepare(subnode)


func enhance(node: Node) -> void:
	#Add slots for head, hands and chest (which is where the capes go).
	if node is Skeleton3D:
		for slot in slots:
			var bone_attachment_3d: BoneAttachment3D = BoneAttachment3D.new()
			node.add_child(bone_attachment_3d)
			bone_attachment_3d.owner = owner_scene
			bone_attachment_3d.name = slot
			bone_attachment_3d.bone_name = slots[slot]
			print_rich("Post-import: [b]Added [color=green]%s[/color] -> Slot: [color=yellow]%s[/color][/b]" % [slot, slots[slot]])
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		enhance(subnode)


#Checks to make sure the path exists, and if not, creates the requisite folders.
func ensure_path_exists(path: String) -> void:
	var directory: String = path.get_base_dir()
	if DirAccess.dir_exists_absolute(directory):
		return
	else:
		DirAccess.make_dir_recursive_absolute(directory)

GameConstants File:

class_name GameConstants

#Filepaths
const MATERIALS_PATH = "res://assets/materials/"
const TEXTURES_PATH = "res://assets/textures/"

Console output when I run the script:

Begin Post-import -> BarbarianSkin as Node3D
Time/Date Stamp: 2026-02-23 12:02:41

Post-import: Rotated Skeleton3D -180 degrees on the y-axis
Image saved to res://assets/textures/kay_kit_models/barbarian_texture.png
  ERROR: Failed loading resource: res://assets/textures/kay_kit_models/barbarian_texture.png.
  ERROR: Error loading resource: 'res://assets/textures/kay_kit_models/barbarian_texture.png'.
Material saved to res://assets/materials/kay_kit_models/barbarian.material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Post-import: Added HeadSlot -> Slot: head
Post-import: Added RightHandSlot -> Slot: handslot.r
Post-import: Added LeftHandSlot -> Slot: handslot.l
Post-import: Added BackSlot -> Slot: chest

NOTE: Ignore any reimport or editor/gui/progress_dialog errors that appear below this line. This happens when an import script creates new directories or files and we scan so they will show up in FileSystem.
  ERROR: Task 'reimport' already exists.
  ERROR: editor/gui/progress_dialog.cpp:230 - Condition "!tasks.has(p_task)" is true. Returning: canceled
  ERROR: editor/gui/progress_dialog.cpp:253 - Condition "!tasks.has(p_task)" is true.
1 Like

You’re trying to load before the resource was imported. Connect to file system’s resources_reimported signal and load there:

var fs: EditorFileSystem = EditorInterface.get_resource_filesystem()
image.save_png(texture_path)
fs.resources_reimported.connect(
		func(paths: PackedStringArray):
			if texture_path in paths:
				print(load(texture_path))
, CONNECT_ONE_SHOT)
fs.scan()

Btw why can’t you just assign the existing texture object to the material? Why create a copy?

1 Like

My understanding is because the texture is embedded in the material, and Kay indicated it was better to use an externally linked texture. However, I could just try linking the existing texture externally, then moving and renaming it.

I’ll try your initial suggestion, then re-using the existing one.

So I tried that, and it copied the file. I got a different error:

  ERROR: Attempted to call reimport_files() recursively, this is not allowed.

Full Output:

Begin Post-import -> BarbarianSkin as Node3D
Time/Date Stamp: 2026-02-23 14:51:25

Post-import: Rotated Skeleton3D -180 degrees on the y-axis
Material saved to res://assets/materials/kay_kit_models/barbarian.material
NOTE: Ensure you drag-and-drop res://assets/textures/kay_kit_models/barbarian_texture.png into the albedo_texture field of res://assets/materials/kay_kit_models/barbarian.material.
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Post-import: Added HeadSlot -> Slot: head
Post-import: Added RightHandSlot -> Slot: handslot.r
Post-import: Added LeftHandSlot -> Slot: handslot.l
Post-import: Added BackSlot -> Slot: chest

NOTE: Ignore any reimport or editor/gui/progress_dialog errors that appear below this line. This happens when an import script creates new directories or files and we scan so they will show up in FileSystem.
  ERROR: Attempted to call reimport_files() recursively, this is not allowed.

With a little more testing, it appears to me that resources_reimported gets emitted and that connection changes the error message. It never prints out the texture, but it does appear to get saved.

I then tried moving the existing file:

				##Copy external texure
				var texture_path: String = GameConstants.TEXTURES_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + "_texture.png"
				ensure_path_exists(texture_path)
				var file_path: String = get_source_file().get_base_dir()
				var dir := DirAccess.open(file_path)
				for file in dir.get_files():
					if file.contains(owner_scene.name) and file.ends_with(".png"):
						dir.rename(file, texture_path)
						dir.remove(file + ".import")
						EditorInterface.get_resource_filesystem().reimport_files([texture_path])
						break

This results in even more errors:

Begin Post-import -> BarbarianSkin as Node3D
Time/Date Stamp: 2026-02-23 15:35:21

Post-import: Rotated Skeleton3D -180 degrees on the y-axis
  ERROR: Attempted to call reimport_files() recursively, this is not allowed.
Material saved to res://assets/materials/kay_kit_models/barbarian.material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Post-import: Added HeadSlot -> Slot: head
Post-import: Added RightHandSlot -> Slot: handslot.r
Post-import: Added LeftHandSlot -> Slot: handslot.l
Post-import: Added BackSlot -> Slot: chest

NOTE: Ignore any reimport or editor/gui/progress_dialog errors that appear below this line. This happens when an import script creates new directories or files and we scan so they will show up in FileSystem.
  ERROR: Task 'reimport' already exists.
  ERROR: editor/gui/progress_dialog.cpp:230 - Condition "!tasks.has(p_task)" is true. Returning: canceled
  ERROR: editor/gui/progress_dialog.cpp:253 - Condition "!tasks.has(p_task)" is true.

And while it does move the file, the material is still linked to the old location.

Adding in this:

material.albedo_texture = ResourceLoader.load(texture_path)

Results in the albedo_texture being null (with both solutions).

At this point I think I’m just going to leave the texture where it is, as it now seems to link instead of being embedded.

Current Solution

I’d like something better, but I’ve also spent a whole day on this already.

Code:

@tool
extends EditorScenePostImport

enum LoopMode { LOOP_NONE, LOOP_LINEAR, LOOP_PINGPONG }
const LOOPMODE = ["LOOP_NONE", "[b][color=green]LOOP_LINEAR[/color][/b]", "[b][color=orange]LOOP_PINGPONG[/color][/b]"]
var owner_scene
var slots: Dictionary = {
	"HeadSlot" : "head",
	"RightHandSlot": "handslot.r",
	"LeftHandSlot": "handslot.l",
	"BackSlot": "chest"
}


# Import script for rig to set up everything so little editing is needed once the character is
# instantiated.
func _post_import(scene):
	owner_scene = scene
	print_rich("\n[b][color=red]Begin Post-import[/color] -> [color=purple]%s[/color] as [color=green]%s[/color][/b]" % [scene.name + "Skin", scene.get_class()])
	print_rich("[b]Time/Date Stamp: %s[/b]\n" % [Time.get_datetime_string_from_system(false, true)])
	prepare(scene)
	enhance(scene)
	scene.name = scene.name + "Skin"
	print_rich("\n[b][color=red]NOTE:[/color][/b] Ignore any [color=salmon]ERROR[/color] messages directly after this line. This happens due to the way we are linking the texture externally. [b]You can copy the texture to another folder and/or rename it as you please now that import is complete - as it is not possible to do so during the import process.[/b]")
	return scene


func prepare(node: Node) -> void:
	# Rotating the model to face the other direction so it moves in the correct direction when animated.
	if node is Skeleton3D:
		node.rotate_y(deg_to_rad(-180.0))
		print_rich("Post-import: [b]Rotated [color=green]%s[/color] [color=yellow]-180 degrees[/color][/b] on the [b][color=green]y-axis[/color][/b]" % [node.get_class()])
	
	if node is MeshInstance3D:
		var mesh: Mesh = node.mesh
		for index in mesh.get_surface_count():
			var material: StandardMaterial3D = mesh.surface_get_material(index)
			var material_path: String = GameConstants.MATERIALS_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + ".material"
			if ResourceLoader.exists(material_path):
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				print("Assigning saved material")
				mesh.surface_set_material(index, saved_material)
			else:
				var fs: EditorFileSystem = EditorInterface.get_resource_filesystem()
				fs.scan()
			
				#Create material
				ensure_path_exists(material_path)
				ResourceSaver.save(material, material_path)
				print("Material saved to %s" % [material_path])
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				mesh.surface_set_material(index, saved_material)
	
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		prepare(subnode)


func enhance(node: Node) -> void:
	#Add slots for head, hands and chest (which is where the capes go).
	if node is Skeleton3D:
		for slot in slots:
			var bone_attachment_3d: BoneAttachment3D = BoneAttachment3D.new()
			node.add_child(bone_attachment_3d)
			bone_attachment_3d.owner = owner_scene
			bone_attachment_3d.name = slot
			bone_attachment_3d.bone_name = slots[slot]
			print_rich("Post-import: [b]Added [color=green]%s[/color] -> Slot: [color=yellow]%s[/color][/b]" % [slot, slots[slot]])
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		enhance(subnode)


#Checks to make sure the path exists, and if not, creates the requisite folders.
func ensure_path_exists(path: String) -> void:
	var directory: String = path.get_base_dir()
	if DirAccess.dir_exists_absolute(directory):
		return
	else:
		DirAccess.make_dir_recursive_absolute(directory)

Output:
(Note this is from a single run, even though it looks like it ran twice.)

Begin Post-import -> BarbarianSkin as Node3D
Time/Date Stamp: 2026-02-23 16:11:36

Post-import: Rotated Skeleton3D -180 degrees on the y-axis
Material saved to res://assets/materials/kay_kit_models/barbarian.material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Post-import: Added HeadSlot -> Slot: head
Post-import: Added RightHandSlot -> Slot: handslot.r
Post-import: Added LeftHandSlot -> Slot: handslot.l
Post-import: Added BackSlot -> Slot: chest

NOTE: Ignore the Attempted to call reimport_files() recursively, this is not allowed. error on the next line. This happens due to the way we are linking the texture externally. You can copy the texture to another folder and/or rename it as you please now that import is complete - as it is not possible to do so during the import process.
  ERROR: Task 'reimport' already exists.

Begin Post-import -> BarbarianSkin as Node3D
Time/Date Stamp: 2026-02-23 16:11:36

Post-import: Rotated Skeleton3D -180 degrees on the y-axis
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Assigning saved material
Post-import: Added HeadSlot -> Slot: head
Post-import: Added RightHandSlot -> Slot: handslot.r
Post-import: Added LeftHandSlot -> Slot: handslot.l
Post-import: Added BackSlot -> Slot: chest

NOTE: Ignore the Attempted to call reimport_files() recursively, this is not allowed. error on the next line. This happens due to the way we are linking the texture externally. You can copy the texture to another folder and/or rename it as you please now that import is complete - as it is not possible to do so during the import process.
  ERROR: editor/gui/progress_dialog.cpp:230 - Condition "!tasks.has(p_task)" is true. Returning: canceled
  ERROR: editor/gui/progress_dialog.cpp:253 - Condition "!tasks.has(p_task)" is true.

1 Like

Not sure if is case here but usually in Python when doing similar operations first was check , create and then loop .

Do you try to automatically do imports via this tool for all assets ?
Do you setting lossless and bone remapping for animations ?

I suppose I could do that. I’m partitioning import scripts by node. So once the first node creates the texture, it gets re-used. Right now I’m not in refactor/cleanup mode, but proof of concept mode.

This is a script attached to certain assets - specifically the character models. Other assets have other scripts. I am not doing it automatically. I’m still trying to get it working.

I looked into setting losseless. but the code was going to be complex, and I can manually change that if necessary,

There are no bone remappings needed for the provided animations. Only if I want to use Mixamo animations, and I haven’t gotten that far yet.

Maybe import plugin be better ?
Could read from predefined folder let say kaykit_sources , have button for execute script instead of attaching to node .
Also in documentation is this type of plugin .

I re-read the docs on Import Plugins. The very last comment thread by DissonantVoid showed me how it would be possible. But based on their code, I don’t think it would solve my problem. Because my problems are that the texture doesn’t exist in the place that I want, and you’re not supposed to call a scan() of the FileSystem while an import is happening.

I’m not sure how that would work TBH. I didn’t see anything about picking import folders to make a button to execute a script appear. If that’s possible, it would potentially save a few clicks.

Pretty sure that’s the documentation I linked above.

I appreciate the idea, but I do not see how it solves my problems.

1 Like

Post the code that caused it. You did something wrong. You need to connect to a signal handler, then call scan() and then continue to do everything that depends on that resource file - inside the signal handler.

Will do tomorrow. I’ll have to recreate it. It’s after midnight and I’m going to bed now.

2 Likes


As just looking in assets itself I don’t see point of making texture as they exist ,
Flow that would make sense to me at least ;
Import source , make inherited scene , save .
Check for texture , import texture , if exist return
Check for material , create material , set albedo to texture , if exist return
Set material in mesh, …

I watched and followed tutorial , it’s a tedious if wanted to automate it in exact manner but one fact is Kay himself saying he is not programmer , from whole tutorial what makes deference is lossless setting for texture to remove deband, and skeleton remapping for adding more animations .

Definitely. It should just be reloaded and assigned.

So what I’m doing now is just using the embedded texture instead of copying over the external texture. The embedded texture is extracted from the .glb as soon as the .glb is imported. I then create the material and link it externally to the embedded texture. Then that texture can be moved, renamed, or deleted, and can either be re-used or replaced.

The whole thing I believe we are trying to solve at this point (based on the video) is not having to override the embedded mesh texture, but actually being able to use it. Up until this point, I’ve just been overriding it on KayKit models.

So I think you’re both right - it’s good enough as-is.

This is my current code:

@tool
extends EditorScenePostImport

enum LoopMode { LOOP_NONE, LOOP_LINEAR, LOOP_PINGPONG }
const LOOPMODE = ["LOOP_NONE", "[b][color=green]LOOP_LINEAR[/color][/b]", "[b][color=orange]LOOP_PINGPONG[/color][/b]"]
var owner_scene
var slots: Dictionary = {
	"HeadSlot" : "head",
	"RightHandSlot": "handslot.r",
	"LeftHandSlot": "handslot.l",
	"BackSlot": "chest"
}


# Import script for rig to set up everything so little editing is needed once the character is
# instantiated.
func _post_import(scene):
	owner_scene = scene
	print_rich("\n[b][color=red]Begin Post-import[/color] -> [color=purple]%s[/color] as [color=green]%s[/color][/b]" % [scene.name + "Skin", scene.get_class()])
	print_rich("[b]Time/Date Stamp: %s[/b]\n" % [Time.get_datetime_string_from_system(false, true)])
	prepare(scene)
	enhance(scene)
	scene.name = scene.name + "Skin"
	print_rich("\n[b][color=red]NOTE:[/color][/b] Ignore any [color=salmon]ERROR[/color] messages directly after this line. This happens due to the way we are linking the texture externally. [b]You can copy the texture to another folder and/or rename it as you please now that import is complete - as it is not possible to do so during the import process.[/b]")
	return scene


func prepare(node: Node) -> void:
	# Rotating the model to face the other direction so it moves in the correct direction when animated.
	if node is Skeleton3D:
		node.rotate_y(deg_to_rad(-180.0))
		print_rich("Post-import: [b]Rotated [color=green]%s[/color] [color=yellow]-180 degrees[/color][/b] on the [b][color=green]y-axis[/color][/b]" % [node.get_class()])
	
	if node is MeshInstance3D:
		var mesh: Mesh = node.mesh
		for index in mesh.get_surface_count():
			var material: StandardMaterial3D = mesh.surface_get_material(index)
			# We are using the MATERIALS_PATH and then a "kay_kit_models/" directory. Next we take
			# the scene name and convert it to a string, and then use rslpit() to get rid of any
			# underscore suffixes off for Barbarian or Skeleton models (so they use the same
			# textures.) Then we convert the whole thing to snake_case to avoid export errors down
			# the line, and add on the .material file extension.
			var material_path: String = GameConstants.MATERIALS_PATH + "kay_kit_models/" + str(owner_scene.name).rsplit("_", true, 1)[0].to_snake_case() + ".material"
			if ResourceLoader.exists(material_path):
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				print("Assigning saved material")
				mesh.surface_set_material(index, saved_material)
			else:
				# This code turns the embedded texture into an externally linked texture.
				var fs: EditorFileSystem = EditorInterface.get_resource_filesystem()
				fs.scan()
				#Creates a folder that can be used to copy the texture into manually.
				var texture_path: String = GameConstants.TEXTURES_PATH + "kay_kit_models/" + str(owner_scene.name).to_snake_case() + "_texture.png"
				ensure_path_exists(texture_path)
				
				#Create material
				ensure_path_exists(material_path)
				ResourceSaver.save(material, material_path)
				print("Material saved to %s" % [material_path])
				var saved_material: StandardMaterial3D = ResourceLoader.load(material_path)
				mesh.surface_set_material(index, saved_material)
	
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		prepare(subnode)


func enhance(node: Node) -> void:
	#Add slots for head, hands and chest (which is where the capes go).
	if node is Skeleton3D:
		for slot in slots:
			var bone_attachment_3d: BoneAttachment3D = BoneAttachment3D.new()
			node.add_child(bone_attachment_3d)
			bone_attachment_3d.owner = owner_scene
			bone_attachment_3d.name = slot
			bone_attachment_3d.bone_name = slots[slot]
			print_rich("Post-import: [b]Added [color=green]%s[/color] -> Slot: [color=yellow]%s[/color][/b]" % [slot, slots[slot]])
	# Recursively call this function on any subnodes that exist.
	for subnode in node.get_children():
		enhance(subnode)


#Checks to make sure the path exists, and if not, creates the requisite folders.
func ensure_path_exists(path: String) -> void:
	var directory: String = path.get_base_dir()
	if DirAccess.dir_exists_absolute(directory):
		return
	else:
		DirAccess.make_dir_recursive_absolute(directory)
1 Like