diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_image.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_image.py index 8e441f9c8..4a56e895a 100644 --- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_image.py +++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_image.py @@ -22,7 +22,7 @@ from io_scene_gltf2.io.exp import gltf2_io_binary_data from io_scene_gltf2.io.exp import gltf2_io_image_data from io_scene_gltf2.io.com import gltf2_io_debug -from io_scene_gltf2.blender.exp.gltf2_blender_image import Channel, ExportImage, FillImage +from io_scene_gltf2.blender.exp.gltf2_blender_image import Channel, SpecularColorSource, ExportImage, FillImage from io_scene_gltf2.blender.exp.gltf2_blender_gather_cache import cached from io_scene_gltf2.io.exp.gltf2_io_user_extensions import export_user_extensions @@ -187,14 +187,14 @@ def __get_image_data(sockets, export_settings) -> ExportImage: # in a helper class. During generation of the glTF in the exporter these will then be combined to actual binary # resources. results = [__get_tex_from_socket(socket, export_settings) for socket in sockets] + + is_specular_color = False + for socket in sockets: + if socket.name == 'Specular' or socket.name == 'Specular Tint': + is_specular_color = True + composed_image = ExportImage() for result, socket in zip(results, sockets): - if result.shader_node.image.channels == 0: - gltf2_io_debug.print_console("WARNING", - "Image '{}' has no color channels and cannot be exported.".format( - result.shader_node.image)) - continue - # Assume that user know what he does, and that channels/images are already combined correctly for pbr # If not, we are going to keep only the first texture found # Example : If user set up 2 or 3 different textures for Metallic / Roughness / Occlusion @@ -202,21 +202,22 @@ def __get_image_data(sockets, export_settings) -> ExportImage: # This Warning is displayed in UI of this option if export_settings['gltf_keep_original_textures']: composed_image = ExportImage.from_original(result.shader_node.image) - - else: + else: # rudimentarily try follow the node tree to find the correct image data. - src_chan = Channel.R - for elem in result.path: - if isinstance(elem.from_node, bpy.types.ShaderNodeSeparateRGB): - src_chan = { - 'R': Channel.R, - 'G': Channel.G, - 'B': Channel.B, - }[elem.from_socket.name] - if elem.from_socket.name == 'Alpha': - src_chan = Channel.A + if result: + src_chan = Channel.R + for elem in result.path: + if isinstance(elem.from_node, bpy.types.ShaderNodeSeparateRGB): + src_chan = { + 'R': Channel.R, + 'G': Channel.G, + 'B': Channel.B, + }[elem.from_socket.name] + if elem.from_socket.name == 'Alpha': + src_chan = Channel.A dst_chan = None + specular_color_src_type = None # some sockets need channel rewriting (gltf pbr defines fixed channels for some attributes) if socket.name == 'Metallic': @@ -225,13 +226,29 @@ def __get_image_data(sockets, export_settings) -> ExportImage: dst_chan = Channel.G elif socket.name == 'Occlusion': dst_chan = Channel.R - elif socket.name == 'Alpha': + elif socket.name == 'Alpha' and len(sockets) > 1 and sockets[1] is not None: dst_chan = Channel.A elif socket.name == 'Clearcoat': dst_chan = Channel.R elif socket.name == 'Clearcoat Roughness': dst_chan = Channel.G - + elif is_specular_color and socket.name == 'Specular': + specular_color_src_type = SpecularColorSource.Specular + elif is_specular_color and socket.name == 'Specular Tint': + specular_color_src_type = SpecularColorSource.SpecularTint + elif is_specular_color and socket.name == 'Base Color': + specular_color_src_type = SpecularColorSource.BaseColor + elif is_specular_color and socket.name == 'Transmission': + specular_color_src_type = SpecularColorSource.Transmission + elif is_specular_color and socket.name == 'IOR': + specular_color_src_type = SpecularColorSource.IOR + + if specular_color_src_type is None and result.shader_node.image.channels == 0: + gltf2_io_debug.print_console("WARNING", + "Image '{}' has no color channels and cannot be exported.".format( + result.shader_node.image)) + continue + if dst_chan is not None: composed_image.fill_image(result.shader_node.image, dst_chan, src_chan) @@ -242,8 +259,12 @@ def __get_image_data(sockets, export_settings) -> ExportImage: elif socket.name == 'Roughness' and not composed_image.is_filled(Channel.B): composed_image.fill_white(Channel.B) else: - # copy full image...eventually following sockets might overwrite things - composed_image = ExportImage.from_blender_image(result.shader_node.image) + if specular_color_src_type is not None: + # bake specular color texture + composed_image.fill_specular_color(result.shader_node.image if result else None, specular_color_src_type, socket.default_value) + else: + # copy full image...eventually following sockets might overwrite things + composed_image = ExportImage.from_blender_image(result.shader_node.image) return composed_image diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_materials.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_materials.py index 6bd7834b9..5dfb25046 100644 --- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_materials.py +++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_materials.py @@ -163,12 +163,20 @@ def __gather_extensions(blender_material, export_settings): if clearcoat_extension: extensions["KHR_materials_clearcoat"] = clearcoat_extension + # TODO KHR_materials_pbrSpecularGlossiness # KHR_materials_transmission transmission_extension = __gather_transmission_extension(blender_material, export_settings) if transmission_extension: extensions["KHR_materials_transmission"] = transmission_extension + # KHR_materials_specular and KHR_materials_ior + ior_extension, specular_extension = __gather_ior_and_specular_extensions(blender_material, export_settings) + if ior_extension: + extensions["KHR_materials_ior"] = ior_extension + if specular_extension: + extensions["KHR_materials_specular"] = specular_extension + return extensions if extensions else None @@ -386,3 +394,74 @@ def __gather_material_unlit(blender_material, export_settings): export_user_extensions('gather_material_unlit_hook', export_settings, material, blender_material) return material + +def __gather_ior_and_specular_extensions(blender_material, export_settings): + lerp = lambda a, b, v: (1-v)*a + v*b + luminance = lambda c: 0.3 * c[0] + 0.6 * c[1] + 0.1 * c[2] + + specular_ext_enabled = False + ior_ext_enabled = False + + specular_extension = {} + ior_extension = {} + + specular_socket = gltf2_blender_get.get_socket(blender_material, 'Specular') + specular_tint_socket = gltf2_blender_get.get_socket(blender_material, 'Specular Tint') + base_color_socket = gltf2_blender_get.get_socket(blender_material, 'Base Color') + transmission_socket = gltf2_blender_get.get_socket(blender_material, 'Transmission') + ior_socket = gltf2_blender_get.get_socket(blender_material, 'IOR') + + specular_not_linked = isinstance(specular_socket, bpy.types.NodeSocket) and not specular_socket.is_linked + specular_tint_not_linked = isinstance(specular_tint_socket, bpy.types.NodeSocket) and not specular_tint_socket.is_linked + base_color_not_linked = isinstance(base_color_socket, bpy.types.NodeSocket) and not base_color_socket.is_linked + transmission_not_linked = isinstance(transmission_socket, bpy.types.NodeSocket) and not transmission_socket.is_linked + ior_not_linked = isinstance(ior_socket, bpy.types.NodeSocket) and not ior_socket.is_linked + + specular = specular_socket.default_value if specular_not_linked else None + specular_tint = specular_tint_socket.default_value if specular_tint_not_linked else None + transmission = transmission_socket.default_value if transmission_not_linked else None + ior = ior_socket.default_value if ior_not_linked else 1.5 # textures not supported + + no_texture = (specular_not_linked and specular_tint_not_linked and + (specular_tint == 0.0 or (specular_tint != 0.0 and base_color_not_linked))) + + if ior != 1.5: + ior_ext_enabled = True + ior_extension['ior'] = ior + + if no_texture: + if specular != 0.5 or specular_tint != 0.0: + specular_ext_enabled = True + base_color = base_color_socket.default_value[0:3] if base_color_not_linked else [0, 0, 0] + normalized_base_color = [bc / luminance(base_color) if luminance(base_color) > 0 else 0 for bc in base_color] + specular_color = [lerp(1, bc, specular_tint) for bc in normalized_base_color] + + # The IOR dictates the maximal reflection strength, therefore we need to clamp + # reflection strenth of non-transmissive (aka plastic) fraction (if any) + plastic = [1/((ior - 1) / (ior + 1))**2 * 0.08 * specular * sc for sc in specular_color] + glass = specular_color + specular_extension['specularColorFactor'] = [lerp(plastic[c], glass[c], transmission) for c in range(0,3)] + else: + sockets = (specular_socket, specular_tint_socket, base_color_socket, transmission_socket, ior_socket) + primary_socket = specular_socket + if specular_not_linked: + primary_socket = specular_tint_socket + if specular_tint_not_linked: + primary_socket = base_color_socket + if base_color_not_linked: + primary_socket = transmission_socket + info = gltf2_blender_gather_texture_info.gather_texture_info(primary_socket, sockets, export_settings) + if info is None: + return None, None + + specular_ext_enabled = True + specular_extension['specularColorTexture'] = info + + # If specular>0.5, specular color may be >1. As we cannot store values >1 in a byte texture, + # we use the specular color factor to rescale the value range. + specular_extension['specularColorFactor'] = [2, 2, 2] + + ior_extension = Extension('KHR_materials_ior', ior_extension, False) if ior_ext_enabled else None + specular_extension = Extension('KHR_materials_specular', specular_extension, False) if specular_ext_enabled else None + + return ior_extension, specular_extension diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture.py index a8ec7f792..e980a9989 100644 --- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture.py +++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture.py @@ -72,13 +72,14 @@ def __gather_name(blender_shader_sockets, export_settings): def __gather_sampler(blender_shader_sockets, export_settings): - shader_nodes = [__get_tex_from_socket(socket).shader_node for socket in blender_shader_sockets] + shader_nodes = [__get_tex_from_socket(socket) for socket in blender_shader_sockets] if len(shader_nodes) > 1: gltf2_io_debug.print_console("WARNING", "More than one shader node tex image used for a texture. " "The resulting glTF sampler will behave like the first shader node tex image.") + first_valid_shader_node = next(filter(lambda x: x is not None, shader_nodes)).shader_node return gltf2_blender_gather_sampler.gather_sampler( - shader_nodes[0], + first_valid_shader_node, export_settings) diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture_info.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture_info.py index ae4bd7099..0a2a79bfc 100644 --- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture_info.py +++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_texture_info.py @@ -46,13 +46,18 @@ def __gather_texture_info_helper( blender_shader_sockets: typing.Tuple[bpy.types.NodeSocket], kind: str, export_settings): - if not __filter_texture_info(primary_socket, blender_shader_sockets, export_settings): + is_specular_color = False + for socket in blender_shader_sockets: + if socket and (socket.name == 'Specular' or socket.name == 'Specular Tint'): + is_specular_color = True + + if not is_specular_color and not __filter_texture_info(primary_socket, blender_shader_sockets, export_settings): return None tex_transform, tex_coord = __gather_texture_transform_and_tex_coord(primary_socket, export_settings) fields = { - 'extensions': __gather_extensions(tex_transform, export_settings), + 'extensions': None if is_specular_color else __gather_extensions(tex_transform, export_settings), 'extras': __gather_extras(blender_shader_sockets, export_settings), 'index': __gather_index(blender_shader_sockets, export_settings), 'tex_coord': tex_coord, diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_image.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_image.py index 8ac272d8c..325c5ceda 100644 --- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_image.py +++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_image.py @@ -18,6 +18,7 @@ import numpy as np import tempfile import enum +from io_scene_gltf2.io.com.gltf2_io_debug import print_console class Channel(enum.IntEnum): @@ -26,6 +27,13 @@ class Channel(enum.IntEnum): B = 2 A = 3 +class SpecularColorSource(enum.IntEnum): + Specular = 0 + SpecularTint = 1 + BaseColor = 2 + Transmission = 3 + IOR = 4 + # These describe how an ExportImage's channels should be filled. class FillImage: @@ -38,6 +46,60 @@ class FillWhite: """Fills a channel with all ones (1.0).""" pass +class FillSpecularColor: + """Fills a channel of the specular color from a Blender image.""" + def __init__(self): + self.size = None + self.images = {} + self.src_default_values = {} # used if image is None + + def append(self, image: bpy.types.Image, src_type: SpecularColorSource, src_default_value): + self.images[src_type] = image + self.src_default_values[src_type] = src_default_value + + if src_type is SpecularColorSource.IOR and image is not None: + # IOR textures not supported + self.images[SpecularColorSource.IOR] = None + self.src_default_values = 1.0 + + if image is not None: + if self.size is None: + # first image determines size + self.size = image.size + else: + # all other images must have the same size + if self.size[0] != image.size[0] or self.size[1] != image.size[1]: + print_console("WARNING", + "Specular, specular tint, transmission and/or base color textures have different " + "sizes. Textures will be ignored.") + self.images[src_type] = None + + def has_image(self, src_type: SpecularColorSource): + return src_type in self.images and self.images[src_type] is not None + + def image_size(self): + return (self.size[0], self.size[1]) + + def as_buffer(self, src_type: SpecularColorSource): + width, height = self.image_size() + if self.has_image(src_type): + out_buf = np.ones(height * width * 4, np.float32) + self.images[src_type].pixels.foreach_get(out_buf) + out_buf = np.reshape(out_buf, (height, width, 4)) + if src_type == SpecularColorSource.BaseColor: + out_buf = out_buf[:,:,0:3] + else: + out_buf = out_buf[:,:,0] + else: + if src_type == SpecularColorSource.BaseColor: + rgb = [self.src_default_values[src_type][0], + self.src_default_values[src_type][1], + self.src_default_values[src_type][2]] + out_buf = np.full((height, width, 3), rgb) + else: + f = self.src_default_values[src_type] + out_buf = np.full((height, width, 1), f) + return out_buf class ExportImage: """Custom image class. @@ -66,6 +128,7 @@ class ExportImage: def __init__(self, original=None): self.fills = {} + self.specular_color_fill = None # In case of keeping original texture images self.original = original @@ -87,6 +150,12 @@ def fill_image(self, image: bpy.types.Image, dst_chan: Channel, src_chan: Channe def fill_white(self, dst_chan: Channel): self.fills[dst_chan] = FillWhite() + def fill_specular_color(self, image: bpy.types.Image, src_type: SpecularColorSource, src_default_value): + self.fills[Channel.A] = FillWhite() + if self.specular_color_fill is None: + self.specular_color_fill = FillSpecularColor() + self.specular_color_fill.append(image, src_type, src_default_value) + def is_filled(self, chan: Channel) -> bool: return chan in self.fills @@ -120,6 +189,10 @@ def encode(self, mime_type: Optional[str]) -> bytes: "image/png": "PNG" }.get(mime_type, "PNG") + # Specular color path = we need to bake the specular color image + if self.specular_color_fill is not None: + return self.__encode_specular_color() + # Happy path = we can just use an existing Blender image if self.__on_happy_path(): return self.__encode_happy() @@ -213,6 +286,32 @@ def __encode_from_image(self, image: bpy.types.Image) -> bytes: tmp_image = guard.image return _encode_temp_image(tmp_image, self.file_format) + def __encode_specular_color(self) -> bytes: + lerp = lambda a, b, v: (1-v)*a + v*b + luminance = lambda c: 0.3 * c[:,:,0] + 0.6 * c[:,:,1] + 0.1 * c[:,:,2] + stack3 = lambda v: np.dstack([v]*3) + + ior = self.specular_color_fill.src_default_values[SpecularColorSource.IOR] + + specular_buf = self.specular_color_fill.as_buffer(SpecularColorSource.Specular) + specular_tint_buf = self.specular_color_fill.as_buffer(SpecularColorSource.SpecularTint) + base_color_buf = self.specular_color_fill.as_buffer(SpecularColorSource.BaseColor) + transmission_buf = self.specular_color_fill.as_buffer(SpecularColorSource.Transmission) + + width, height = self.specular_color_fill.image_size() + + normalized_base_color_buf = base_color_buf / stack3(luminance(base_color_buf)) + np.nan_to_num(normalized_base_color_buf, copy=False, nan=0.0) # if luminance in a pixel was zero + + sc = lerp(1, normalized_base_color_buf, stack3(specular_tint_buf)) + plastic = 1/((ior - 1) / (ior + 1))**2 * 0.08 * stack3(specular_buf) * sc + glass = sc + out_buf = lerp(plastic, glass, stack3(transmission_buf)) + + out_buf = np.dstack((out_buf, np.ones((height, width)))) + out_buf = np.reshape(out_buf, (width * height * 4)) + out_buf *= 0.5 # specularColorFactor is 2 + return self.__encode_from_numpy_array(out_buf.astype(np.float32), (width, height)) def _encode_temp_image(tmp_image: bpy.types.Image, file_format: str) -> bytes: with tempfile.TemporaryDirectory() as tmpdirname: