Skip to content

Instantly share code, notes, and snippets.

@Wampa842
Last active March 21, 2022 22:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Wampa842/59aa27a13f71d6a3c78dc4ca71c83712 to your computer and use it in GitHub Desktop.
Save Wampa842/59aa27a13f71d6a3c78dc4ca71c83712 to your computer and use it in GitHub Desktop.
AmbientCG material import add-on for Blender. Developed for 3.1.0, but probably compatible with earlier versions too.
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# License text: https://www.gnu.org/licenses/old-licenses/gpl-2.0-standalone.html
# #################################
# This script can automatically import and set up materials using images downloaded from AmbientCG.com.
# It matches the file names against Regex patterns to identify what kind of data each file contains.
# The patterns are defined in the `filename_patterns` variable, and work with all AmbientCG materials
# I've downloaded thus far.
# All this script does is load the files, connect the nodes, and set the color space as needed.
# Further manual adjustments *will* be necessary.
# #################################
bl_info = {
"name": "AmbientCG Material Importer",
"author": "Wampa842",
"version": (1, 0),
"blender": (3, 1, 0),
"location": "Add > Image > Import AmbientCG Material",
"description": "Identifies texture files from AmbientCG in the provided directory and imports them as a material.",
"category": "Import-Export"
}
import bpy
import os.path
import re
image_extension_patterns = "\.(png|jpe?g|exr|tiff?|dds|tga|bmp)"
filename_patterns = {
"color": "_[Cc]olor" + image_extension_patterns,
"ao": "_[Aa]mbient[Oo]cclusion" + image_extension_patterns,
"displacement": "_[Dd]isplacement" + image_extension_patterns,
"metalness": "_[Mm]etalness" + image_extension_patterns,
"roughness": "_[Rr]oughness" + image_extension_patterns,
"emission": "_[Ee]missi(ve|on)" + image_extension_patterns,
"normal_dx": "_[Nn]ormal_?[Dd][Xx]" + image_extension_patterns,
"normal_gl": "_[Nn]ormal_?[Gg][Ll]" + image_extension_patterns
}
# conflict: 'NEW', 'REPLACE', 'CANCEL'
# actions: {'FAKE_USER', 'ASSET', 'ASSIGN'}
# height_mode: 'BUMP', 'DISPLACEMENT', 'BOTH', 'NONE'
# height_filter: 'Linear', 'Closest', 'Cubic', 'Smart'
# occlusion_mode: 'MULTIPLY', 'NONE'
def import_material(directory, *, filter_pattern=None, conflict = 'NEW', actions = {}, height_mode = {}, height_filter = 'Linear', occlusion_mode = 'MULTIPLY'):
# Before anything happens, check if the process needs to be aborted due to name conflict.
material_name = bpy.path.display_name_from_filepath(directory)
if (conflict == 'CANCEL') and (bpy.data.materials.find(material_name) > -1):
print("Cancelled because '" + material_name + "' already exists.")
return ({'CANCELLED'}, bpy.data.materials[material_name])
# Identify the image files
color_name = None
ao_name = None
displacement_name = None
metalness_name = None
normal_dx_name = None
normal_gl_name = None
roughness_name = None
emission_name = None
files = os.listdir(directory)
for f in files:
if (filter_pattern == None) or (re.search(filter_pattern, f)):
if re.search(filename_patterns["color"], f):
color_name = f
if re.search(filename_patterns["ao"], f):
ao_name = f
if re.search(filename_patterns["displacement"], f):
displacement_name = f
if re.search(filename_patterns["metalness"], f):
metalness_name = f
if re.search(filename_patterns["normal_dx"], f):
normal_dx_name = f
if re.search(filename_patterns["normal_gl"], f):
normal_gl_name = f
if re.search(filename_patterns["roughness"], f):
roughness_name = f
if re.search(filename_patterns["emission"], f):
emission_name = f
# Create the material
mat = None
if (conflict == 'REPLACE') and (bpy.data.materials.find(material_name) > -1):
print("Replacing existing materials is not yet implemented.")
return ({'CANCELLED'}, None)
else:
mat = bpy.data.materials.new(name=material_name)
material_name = mat.name
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
bsdf = nodes["Principled BSDF"]
mat.use_fake_user = 'FAKE_USER' in actions # NOTE: if an existing material is replaced and it already has a fake user, this CAN unset it.
node_row_top = 600 # Nodes are placed vertically from this point.
node_row_spacing = 280 # The vertical distance between nodes' origins.
node_row_count = 0 # Increment with each row of nodes. This helps with the vertical spacing.
# Base color map
if color_name != None:
tex = bpy.data.images.load(os.path.join(directory, color_name), check_existing=True)
node = nodes.new(type="ShaderNodeTexImage")
node.image = tex
node.location = (-500, node_row_top - node_row_spacing * node_row_count)
node_row_count += 1
if (occlusion_mode == 'MULTIPLY' or occlusion_mode == 'DISCONNECTED') and (ao_name != None):
tex_ao = bpy.data.images.load(os.path.join(directory, ao_name), check_existing=True)
node_ao = nodes.new(type="ShaderNodeTexImage")
node_ao.image = tex_ao
node_ao.location = (-500, node_row_top - node_row_spacing * node_row_count)
if occlusion_mode == 'MULTIPLY':
mix_node = nodes.new(type="ShaderNodeMixRGB")
mix_node.location = (-200, node_row_top - node_row_spacing * node_row_count)
mix_node.inputs[0].default_value = 1
mix_node.blend_type = 'MULTIPLY'
links.new(node.outputs["Color"], mix_node.inputs["Color1"])
links.new(node_ao.outputs["Color"], mix_node.inputs["Color2"])
links.new(mix_node.outputs[0], bsdf.inputs["Base Color"])
else:
links.new(node.outputs["Color"], bsdf.inputs["Base Color"])
node_row_count += 1
else:
links.new(node.outputs["Color"], bsdf.inputs["Base Color"])
# Metalness map
if metalness_name != None:
tex = bpy.data.images.load(os.path.join(directory, metalness_name), check_existing=True)
tex.colorspace_settings.name = "Non-Color"
node = nodes.new(type="ShaderNodeTexImage")
node.image = tex
node.location = (-500, node_row_top - node_row_spacing * node_row_count)
links.new(node.outputs["Color"], bsdf.inputs["Metallic"])
node_row_count += 1
# Roughness
if roughness_name != None:
tex = bpy.data.images.load(os.path.join(directory, roughness_name), check_existing=True)
tex.colorspace_settings.name = "Non-Color"
node = nodes.new(type="ShaderNodeTexImage")
node.image = tex
node.location = (-500, node_row_top - node_row_spacing * node_row_count)
links.new(node.outputs["Color"], bsdf.inputs["Roughness"])
node_row_count += 1
# Emission
if emission_name != None:
tex = bpy.data.images.load(os.path.join(directory, emission_name), check_existing=True)
node = nodes.new(type="ShaderNodeTexImage")
node.image = tex
node.location = (-500, node_row_top - node_row_spacing * node_row_count)
links.new(node.outputs["Color"], bsdf.inputs["Emission"])
node_row_count += 1
# Displacement
if (displacement_name != None) and ('BUMP' in height_mode or 'DISPLACEMENT' in height_mode):
tex = bpy.data.images.load(os.path.join(directory, displacement_name), check_existing=True)
node = nodes.new(type="ShaderNodeTexImage")
node.image = tex
node.location = (-500, node_row_top - node_row_spacing * node_row_count)
#links.new(node.outputs["Color"], bsdf.inputs[""])
# TODO: connect displacement map
node_row_count += 1
# OpenGL Normal
if normal_gl_name != None:
# "Normal map" node
normal_node = nodes.new(type="ShaderNodeNormalMap")
normal_node.location = (-200, node_row_top - node_row_spacing * node_row_count)
links.new(normal_node.outputs["Normal"], bsdf.inputs["Normal"])
# Image texture node
tex = bpy.data.images.load(os.path.join(directory, normal_gl_name), check_existing=True)
tex.colorspace_settings.name = "Non-Color"
tex_node = nodes.new(type="ShaderNodeTexImage")
tex_node.image = tex
tex_node.location = (-500, node_row_top - node_row_spacing * node_row_count)
links.new(tex_node.outputs["Color"], normal_node.inputs["Color"])
node_row_count += 1
# DirectX normal, only if OpenGL isn't found
elif normal_dx_name != None:
# "Normal map" node
normal_node = nodes.new(type="ShaderNodeNormalMap")
normal_node.location = (-200, node_row_top - node_row_spacing * node_row_count)
links.new(normal_node.outputs["Normal"], bsdf.inputs["Normal"])
# Image texture node
tex = bpy.data.images.load(os.path.join(directory, normal_dx_name), check_existing=True)
tex.colorspace_settings.name = "Non-Color"
tex_node = nodes.new(type="ShaderNodeTexImage")
tex_node.image = tex
tex_node.location = (-1000, node_row_top - node_row_spacing * node_row_count)
# Conversion nodes
separate_node = nodes.new(type="ShaderNodeSeparateRGB")
separate_node.location = (-720, node_row_top - node_row_spacing * node_row_count)
combine_node = nodes.new(type="ShaderNodeCombineRGB")
combine_node.location = (-360, node_row_top - node_row_spacing * node_row_count)
sub_node = nodes.new(type="ShaderNodeMath") # DirectX is converted to OpenGL by inverting the value of the green (vertical) channel: (R, G, B) = (R, 1 - G, B)
sub_node.operation = 'SUBTRACT'
sub_node.inputs[0].default_value = 1
sub_node.location = (-540, node_row_top - node_row_spacing * node_row_count)
sub_node.hide = True
# Connect everything
links.new(tex_node.outputs["Color"], separate_node.inputs["Image"]) # RGB into separate node
links.new(separate_node.outputs[0], combine_node.inputs[0]) # R unchanged to X
links.new(separate_node.outputs[2], combine_node.inputs[2]) # B unchanged to Z
links.new(separate_node.outputs[1], sub_node.inputs[1]) # G inverted
links.new(sub_node.outputs[0], combine_node.inputs[1]) # inverted G to Y
links.new(combine_node.outputs[0], normal_node.inputs["Color"])
node_row_count += 1
if 'ASSET' in actions:
mat.asset_mark()
mat.asset_generate_preview()
if ('ASSIGN' in actions) and (bpy.context.active_object != None):
if len(bpy.context.active_object.material_slots) <= 0:
bpy.context.active_object.data.materials.append(mat)
else:
bpy.context.active_object.data.materials[0] = mat
return ({'FINISHED'}, mat)
class ImportMaterialDialog(bpy.types.Operator):
"""Import AmbientCG Material"""
bl_idname = "ambientcg_import.dialog"
bl_label = "Import AmbientCG Material"
path: bpy.props.StringProperty(name="Folder path", subtype='DIR_PATH')
filter_pattern: bpy.props.StringProperty(name="Filter", description="Regular expression to filter the file names in the specified folder.", default="")
conflict: bpy.props.EnumProperty(name="Name conflict", description="What to do when a material of the desired name already exists", items=[
('NEW', "Add new", "Add a new material"),
('REPLACE', "Replace", "Replace the existing material"),
('CANCEL', "Cancel", "Cancel the operation")])
actions: bpy.props.EnumProperty(name="Actions", description="Actions to perform once the material is imported", items=[
('FAKE_USER', "Fake user", "Enable fake user on the material"),
('ASSET', "Mark as asset", "Mark the material as an asset"),
('ASSIGN', "Assign to active", "Assign the material to the first slot of the active object")], default={'ASSET', 'ASSIGN'}, options={'ENUM_FLAG'})
# height_mode: ...
# height_filter: ...
#ao_mode: bpy.props.BoolProperty(name="Use ambient occlusion", description="If enabled, base color will be multiplied with the ambient occlusion map, if it exists.", default=True)
ao_mode: bpy.props.EnumProperty(name="Ambient occlusion", items=[('NONE', "None", "Ambient occlusion will not be imported"), ('MULTIPLY', "Multiply", "Base color will be multiplied by the ambient occlusion value"), ('DISCONNECTED', "Import, but do not connect", "The ambient occlusion image will be loaded, but the node will remain disconnected")], default='MULTIPLY')
def execute(self, context):
if len(self.path) <= 0:
self.report({'ERROR'}, "The path cannot be empty.")
return {'CANCELLED'}
if not os.path.exists(self.path):
self.report({'ERROR'}, "The path does not exist.")
return {'CANCELLED'}
result = import_material(self.path, filter_pattern=self.filter_pattern, conflict=self.conflict, actions=self.actions, height_mode={}, height_filter='Linear', occlusion_mode=self.ao_mode)
self.report({'INFO'}, "Material imported: " + result[1].name)
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def menu_func(self, context):
self.layout.operator(ImportMaterialDialog.bl_idname, text=ImportMaterialDialog.bl_label)
def register():
bpy.utils.register_class(ImportMaterialDialog)
bpy.types.VIEW3D_MT_image_add.append(menu_func)
def unregister():
bpy.utils.unregister_class(ImportMaterialDialog)
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment