Last active
March 21, 2022 22:17
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ##### 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