diff --git a/addons/io_scene_gltf2/__init__.py b/addons/io_scene_gltf2/__init__.py index ae3361591..a033b4641 100644 --- a/addons/io_scene_gltf2/__init__.py +++ b/addons/io_scene_gltf2/__init__.py @@ -360,6 +360,8 @@ class ExportGLTF2_Base(ConvertGLTF2_Base): 'Be aware of a possible loss in quality'), ('WEBP', 'WebP Format', 'Save images as WebPs as main image (no fallback)'), + ('KTX2', 'KTX2 Format', + 'Save images as KTX2 with BasisU supercompression'), ('NONE', 'None', 'Don\'t export images'), ), @@ -370,23 +372,33 @@ class ExportGLTF2_Base(ConvertGLTF2_Base): default='AUTO' ) - export_image_add_webp: BoolProperty( - name='Create WebP', + export_image_add_compressed_images: BoolProperty( + name='Create Compressed Images', description=( - "Creates WebP textures for every texture. " - "For already WebP textures, nothing happens" + "Creates compressed textures (WebP or KTX2)for every texture. " + "For already compressed textures, nothing happens" ), default=False ) - export_image_webp_fallback: BoolProperty( - name='WebP Fallback', + export_compressed_images_fallback: BoolProperty( + name='Compressed Images Fallback', description=( - "For all WebP textures, create a PNG fallback texture" + "For all compressed textures (WebP or KTX2), create a PNG fallback texture" ), default=False ) + export_compressed_images_type: EnumProperty( + name='Compressed Images Type', + items=(('WEBP', 'WebP', 'Use WebP format for compressed textures'), + ('KTX2', 'KTX2', 'Use KTX2 format with BasisU supercompression for compressed textures')), + description=( + "Choose the format for compressed textures fallback (WebP or KTX2 with BasisU supercompression)" + ), + default='WEBP' + ) + export_texture_dir: StringProperty( name='Textures', description='Folder to place texture files in. Relative to the .gltf file', @@ -1139,8 +1151,9 @@ def execute(self, context): export_settings['gltf_format'] = self.export_format export_settings['gltf_image_format'] = self.export_image_format - export_settings['gltf_add_webp'] = self.export_image_add_webp - export_settings['gltf_webp_fallback'] = self.export_image_webp_fallback + export_settings['gltf_add_compressed_images'] = self.export_image_add_compressed_images + export_settings['gltf_compressed_images_fallback'] = self.export_compressed_images_fallback + export_settings['gltf_compressed_images_type'] = self.export_compressed_images_type export_settings['gltf_image_quality'] = self.export_image_quality export_settings['gltf_copyright'] = self.export_copyright export_settings['gltf_texcoords'] = self.export_texcoords @@ -1548,13 +1561,16 @@ def export_panel_data_material(layout, operator): if operator.export_image_format in ["AUTO", "JPEG", "WEBP"]: col.prop(operator, 'export_image_quality') col = body.column() - col.active = operator.export_image_format != "WEBP" and operator.export_materials not in [ + col.active = operator.export_image_format not in ["WEBP", "KTX2"] and operator.export_materials not in [ 'PLACEHOLDER', 'NONE', 'VIEWPORT'] - col.prop(operator, "export_image_add_webp") + col.prop(operator, "export_image_add_compressed_images") + col = body.column() + col.active = operator.export_image_add_compressed_images + col.prop(operator, "export_compressed_images_type") col = body.column() - col.active = operator.export_image_format != "WEBP" and operator.export_materials not in [ + col.active = operator.export_image_format not in ["WEBP", "KTX2"] and operator.export_materials not in [ 'PLACEHOLDER', 'NONE', 'VIEWPORT'] - col.prop(operator, "export_image_webp_fallback") + col.prop(operator, "export_compressed_images_fallback") header, sub_body = body.panel("GLTF_export_data_material_unused", default_closed=True) header.label(text="Unused Textures & Images") diff --git a/addons/io_scene_gltf2/blender/exp/material/encode_image.py b/addons/io_scene_gltf2/blender/exp/material/encode_image.py index 729cbe770..aad6859d1 100644 --- a/addons/io_scene_gltf2/blender/exp/material/encode_image.py +++ b/addons/io_scene_gltf2/blender/exp/material/encode_image.py @@ -229,7 +229,8 @@ def encode(self, mime_type: Optional[str], export_settings) -> Tuple[bytes, bool self.file_format = { "image/jpeg": "JPEG", "image/png": "PNG", - "image/webp": "WEBP" + "image/webp": "WEBP", + "image/ktx2": "KTX2", }.get(mime_type, "PNG") # Happy path = we can just use an existing Blender image @@ -415,6 +416,9 @@ def __encode_from_image(self, image: bpy.types.Image, export_settings) -> bytes: elif self.file_format == 'WEBP': if data[8:12] == b'WEBP': return data + elif self.file_format == 'KTX2': + if data[0:12] == b'\xABKTX 20\xBB\r\n\x1A\n': + return data # Copy to a temp image and save. with TmpImageGuard() as guard: @@ -440,7 +444,9 @@ def __encode_from_image_tile(self, udim_image, tile, export_settings): elif self.file_format == 'WEBP': if data[8:12] == b'WEBP': return data - + elif self.file_format == 'KTX2': + if data[0:12] == b'\xABKTX 20\xBB\r\n\x1A\n': + return data # We don't manage UDIM packed image, so this could not happen to be here # Lets display an error export_settings['log'].error( @@ -459,6 +465,8 @@ def _encode_temp_image(tmp_image: bpy.types.Image, file_format: str, export_sett # if image is jpeg, use quality export settings if file_format in ["JPEG", "WEBP"]: tmp_image.save(quality=export_settings['gltf_image_quality']) + elif file_format == "KTX2": + tmp_image.save() # TODO add KTX2 options else: tmp_image.save() diff --git a/addons/io_scene_gltf2/blender/exp/material/image.py b/addons/io_scene_gltf2/blender/exp/material/image.py index 9faf71e11..91981f303 100644 --- a/addons/io_scene_gltf2/blender/exp/material/image.py +++ b/addons/io_scene_gltf2/blender/exp/material/image.py @@ -150,11 +150,15 @@ def __gather_mime_type(sockets, export_image, export_settings): if socket.socket.identifier == "Alpha": if export_settings["gltf_image_format"] == "WEBP": return "image/webp" + elif export_settings["gltf_image_format"] == "KTX2": + return "image/ktx2" else: # If we keep image as is (no channel composition), we need to keep original format (for WebP) image = export_image.blender_image(export_settings) if image is not None and __is_blender_image_a_webp(image): return "image/webp" + elif image is not None and __is_blender_image_a_ktx2(image): + return "image/ktx2" return "image/png" if export_settings["gltf_image_format"] == "AUTO": @@ -168,12 +172,16 @@ def __gather_mime_type(sockets, export_image, export_settings): return "image/jpeg" elif image is not None and __is_blender_image_a_webp(image): return "image/webp" + elif image is not None and __is_blender_image_a_ktx2(image): + return "image/ktx2" return "image/png" elif export_settings["gltf_image_format"] == "WEBP": return "image/webp" elif export_settings["gltf_image_format"] == "JPEG": return "image/jpeg" + elif export_settings["gltf_image_format"] == "KTX2": + return "image/ktx2" def __gather_name(export_image, use_tile, export_settings): @@ -486,6 +494,17 @@ def __is_blender_image_a_webp(image: bpy.types.Image) -> bool: return path.endswith('.webp') +def __is_blender_image_a_ktx2(image: bpy.types.Image) -> bool: + if image.source not in ['FILE', 'TILED']: + return False + if image.filepath_raw == '' and image.packed_file: + # Magic of ktx2 + return image.packed_file.data[:12] == b'\xABKTX 22\xBB\r\n\x1A\n' + else: + path = image.filepath_raw.lower() + return path.endswith('.ktx2') + + def get_gltf_image_from_blender_image(blender_image_name, export_settings): export_image = ExportImage.from_blender_image(bpy.data.images[blender_image_name]) @@ -518,7 +537,15 @@ def __get_mime_type_of_image(blender_image_name, export_settings): return "image/jpeg" elif __is_blender_image_a_webp(image): return "image/webp" + elif __is_blender_image_a_ktx2(image): + return "image/ktx2" return "image/png" elif export_settings["gltf_image_format"] == "JPEG": return "image/jpeg" + elif export_settings["gltf_image_format"] == "WEBP": + return "image/webp" + elif export_settings["gltf_image_format"] == "KTX2": + return "image/ktx2" + else: + return "image/png" diff --git a/addons/io_scene_gltf2/blender/exp/material/texture.py b/addons/io_scene_gltf2/blender/exp/material/texture.py index 2f837f19a..d316d2ac4 100644 --- a/addons/io_scene_gltf2/blender/exp/material/texture.py +++ b/addons/io_scene_gltf2/blender/exp/material/texture.py @@ -42,10 +42,11 @@ def gather_texture( if not __filter_texture(blender_shader_sockets, export_settings): return None, None, None - source, webp_image, image_data, factor, udim_image = __gather_source( + source, webp_image, ktx2_image, image_data, factor, udim_image = __gather_source( blender_shader_sockets, use_tile, export_settings) - exts, remove_source = __gather_extensions(blender_shader_sockets, source, webp_image, image_data, export_settings) + exts, remove_source = __gather_extensions(blender_shader_sockets, source, + webp_image, ktx2_image, image_data, export_settings) texture = gltf2_io.Texture( extensions=exts, @@ -72,14 +73,16 @@ def __filter_texture(blender_shader_sockets, export_settings): return True -def __gather_extensions(blender_shader_sockets, source, webp_image, image_data, export_settings): +def __gather_extensions(blender_shader_sockets, source, webp_image, ktx2_image, image_data, export_settings): extensions = {} remove_source = False - required = False + required_webp = False + required_ktx2 = False ext_webp = {} + ext_ktx2 = {} # If user want to keep original textures, and these textures are WebP, we need to remove source from # gltf2_io.Texture, and populate extension @@ -88,18 +91,35 @@ def __gather_extensions(blender_shader_sockets, source, webp_image, image_data, and source.mime_type == "image/webp": ext_webp["source"] = source remove_source = True - required = True + required_webp = True + + # If user want to keep original textures, and these textures are KTX2, we need to remove source from + # gltf2_io.Texture, and populate extension + if export_settings['gltf_keep_original_textures'] is True \ + and source is not None \ + and source.mime_type == "image/ktx2": + ext_ktx2["source"] = source + remove_source = True + required_ktx2 = True # If user want to export in WebP format (so without fallback in png/jpg) if export_settings['gltf_image_format'] == "WEBP": # We create all image without fallback ext_webp["source"] = source remove_source = True - required = True + required_webp = True + +# If user want to export in KTX2 format (so without fallback in png/jpg) + if export_settings['gltf_image_format'] == "KTX2": + # We create all image without fallback + ext_ktx2["source"] = source + remove_source = True + required_ktx2 = True # If user doesn't want to export in WebP format, but want WebP too. Texture is not WebP if export_settings['gltf_image_format'] != "WEBP" \ - and export_settings['gltf_add_webp'] \ + and export_settings['gltf_add_compressed_images'] is True \ + and export_settings['gltf_compressed_images_type'] == "WEBP" \ and source is not None \ and source.mime_type != "image/webp": # We need here to create some WebP textures @@ -130,31 +150,95 @@ def __gather_extensions(blender_shader_sockets, source, webp_image, image_data, ext_webp["source"] = webp_image +# If user doesn't want to export in KTX2 format, but want KTX2 too. Texture is not KTX2 + if export_settings['gltf_image_format'] != "KTX2" \ + and export_settings['gltf_add_compressed_images'] is True \ + and export_settings['gltf_compressed_images_type'] == "KTX2" \ + and source is not None \ + and source.mime_type != "image/ktx2": + # We need here to create some KTX2 textures + + new_mime_type = "image/ktx2" + new_data, _ = image_data.encode(new_mime_type, export_settings) + if len(new_data) == 0: + export_settings['log'].warning("Image data is empty, not exporting image") + return None, False + + if export_settings['gltf_format'] == 'GLTF_SEPARATE': + + uri = ImageData( + data=new_data, + mime_type=new_mime_type, + name=source.uri.name + ) + buffer_view = None + name = source.uri.name + image.set_real_uri(uri, export_settings) # Note: image, here, is the imported image python file + + else: + buffer_view = BinaryData(data=new_data) + uri = None + name = source.name + + ktx2_image = __make_ktx2_image(buffer_view, None, None, new_mime_type, name, uri, export_settings) + + ext_ktx2["source"] = ktx2_image -# If user doesn't want to export in WebP format, but want WebP too. Texture is WebP +# If user doesn't want to export in WebP format, but want WebP too. +# Texture is WebP => Need to make webp required, an remove original if export_settings['gltf_image_format'] != "WEBP" \ and source is not None \ and source.mime_type == "image/webp": # User does not want fallback - if export_settings['gltf_webp_fallback'] is False: + if export_settings['gltf_compressed_images_fallback'] is False: ext_webp["source"] = source remove_source = True - required = True + required_webp = True + +# Is user doesn't want to export in KTX2 format, but want KTX2 too. +# Texture is KTX2 => Need to make KTX2 required, an remove original + if export_settings['gltf_image_format'] != "KTX2" \ + and source is not None \ + and source.mime_type == "image/ktx2": + + # User does not want fallback + if export_settings['gltf_compressed_images_fallback'] is False: + ext_ktx2["source"] = source + remove_source = True + required_ktx2 = True # If user doesn't want to export in webp format, but want WebP too as fallback. Texture is WebP if export_settings['gltf_image_format'] != "WEBP" \ and webp_image is not None \ - and export_settings['gltf_webp_fallback'] is True: + and export_settings['gltf_compressed_images_fallback'] is True: # Already managed in __gather_source, we only have to assign ext_webp["source"] = webp_image # Not needed in code, for for documentation: # remove_source = False - # required = False + # required_webp = False + + +# If user doesn't want to export in KTX2 format, but want KTX2 too as fallback. Texture is KTX2 + if export_settings['gltf_image_format'] != "KTX2" \ + and ktx2_image is not None \ + and export_settings['gltf_compressed_images_fallback'] is True: + # Already managed in __gather_source, we only have to assign + ext_ktx2["source"] = ktx2_image + + # Not needed in code, for for documentation: + # remove_source = False + # required_ktx2 = False + some_extension_added = len(ext_webp) > 0 or len(ext_ktx2) > 0 if len(ext_webp) > 0: - extensions["EXT_texture_webp"] = Extension('EXT_texture_webp', ext_webp, required) + extensions["EXT_texture_webp"] = Extension('EXT_texture_webp', ext_webp, required_webp) + + if len(ext_ktx2) > 0: + extensions["KHR_texture_basisu"] = Extension('KHR_texture_basisu', ext_ktx2, required_ktx2) + + if some_extension_added: return extensions, remove_source else: return None, False @@ -172,6 +256,18 @@ def __make_webp_image(buffer_view, extensions, extras, mime_type, name, uri, exp ) +@cached +def __make_ktx2_image(buffer_view, extensions, extras, mime_type, name, uri, export_settings): + return gltf2_io.Image( + buffer_view=buffer_view, + extensions=extensions, + extras=extras, + mime_type=mime_type, + name=name, + uri=uri + ) + + def __gather_extras(blender_shader_sockets, export_settings): return None @@ -227,14 +323,49 @@ def __gather_sampler(blender_shader_sockets, export_settings): def __gather_source(blender_shader_sockets, use_tile, export_settings): source, image_data, factor, udim_image = image.gather_image(blender_shader_sockets, use_tile, export_settings) + if export_settings['gltf_keep_original_textures'] is False \ + and export_settings['gltf_image_format'] != "KTX2" \ + and source is not None \ + and source.mime_type == "image/ktx2": + if export_settings['gltf_compressed_images_fallback'] is False: + # Already managed in __gather_extensions + return source, None, None, image_data, factor, udim_image + else: + # Need to create a PNG texture + + new_mime_type = "image/png" + new_data, _ = image_data.encode(new_mime_type, export_settings) + # We should not have empty data here, as we are calculating fallback, so the real image should be ok already + + if export_settings['gltf_format'] == 'GLTF_SEPARATE': + buffer_view = None + uri = ImageData( + data=new_data, + mime_type=new_mime_type, + name=source.uri.name + ) + name = source.uri.name + + image.set_real_uri(uri, export_settings) # Note: image, here, is the imported image python file + + else: + uri = None + buffer_view = BinaryData(data=new_data) + name = source.name + + png_image = __make_ktx2_image(buffer_view, None, None, new_mime_type, name, uri, export_settings) + + # We inverted the png & KTX2 image, to have the png as main source + return png_image, None, source, image_data, factor, udim_image + if export_settings['gltf_keep_original_textures'] is False \ and export_settings['gltf_image_format'] != "WEBP" \ and source is not None \ and source.mime_type == "image/webp": - if export_settings['gltf_webp_fallback'] is False: + if export_settings['gltf_compressed_images_fallback'] is False: # Already managed in __gather_extensions - return source, None, image_data, factor, udim_image + return source, None, None, image_data, factor, udim_image else: # Need to create a PNG texture @@ -261,5 +392,5 @@ def __gather_source(blender_shader_sockets, use_tile, export_settings): png_image = __make_webp_image(buffer_view, None, None, new_mime_type, name, uri, export_settings) # We inverted the png & WebP image, to have the png as main source - return png_image, source, image_data, factor, udim_image - return source, None, image_data, factor, udim_image + return png_image, source, None, image_data, factor, udim_image + return source, None, None, image_data, factor, udim_image diff --git a/addons/io_scene_gltf2/io/exp/image_data.py b/addons/io_scene_gltf2/io/exp/image_data.py index c75da9049..8090a75e3 100644 --- a/addons/io_scene_gltf2/io/exp/image_data.py +++ b/addons/io_scene_gltf2/io/exp/image_data.py @@ -53,6 +53,8 @@ def file_extension(self): return ".jpg" elif self._mime_type == "image/webp": return ".webp" + elif self._mime_type == "image/ktx2": + return ".ktx2" return ".png" @property diff --git a/tests/scenes/32_ktx2_mode_auto_with_create_ktx2.blend b/tests/scenes/32_ktx2_mode_auto_with_create_ktx2.blend new file mode 100644 index 000000000..c767a9d67 Binary files /dev/null and b/tests/scenes/32_ktx2_mode_auto_with_create_ktx2.blend differ diff --git a/tests/scenes/32_ktx2_mode_auto_with_fallback.blend b/tests/scenes/32_ktx2_mode_auto_with_fallback.blend new file mode 100644 index 000000000..d9a591a33 Binary files /dev/null and b/tests/scenes/32_ktx2_mode_auto_with_fallback.blend differ diff --git a/tests/scenes/32_ktx2_mode_auto_with_fallback_and_create_ktx2.blend b/tests/scenes/32_ktx2_mode_auto_with_fallback_and_create_ktx2.blend new file mode 100644 index 000000000..cb1d2cceb Binary files /dev/null and b/tests/scenes/32_ktx2_mode_auto_with_fallback_and_create_ktx2.blend differ diff --git a/tests/scenes/32_ktx2_mode_ktx2.blend b/tests/scenes/32_ktx2_mode_ktx2.blend new file mode 100644 index 000000000..42f2be52a Binary files /dev/null and b/tests/scenes/32_ktx2_mode_ktx2.blend differ diff --git a/tests/scenes/32_webp_mode_auto_with_create_webp.blend b/tests/scenes/32_webp_mode_auto_with_create_webp.blend index a08dca303..db3c8aac1 100644 Binary files a/tests/scenes/32_webp_mode_auto_with_create_webp.blend and b/tests/scenes/32_webp_mode_auto_with_create_webp.blend differ diff --git a/tests/scenes/32_webp_mode_auto_with_fallback.blend b/tests/scenes/32_webp_mode_auto_with_fallback.blend index 99187e232..17eb63a46 100644 Binary files a/tests/scenes/32_webp_mode_auto_with_fallback.blend and b/tests/scenes/32_webp_mode_auto_with_fallback.blend differ diff --git a/tests/scenes/32_webp_mode_auto_with_fallback_and_create_webp.blend b/tests/scenes/32_webp_mode_auto_with_fallback_and_create_webp.blend index ff57a8bdc..364e79925 100644 Binary files a/tests/scenes/32_webp_mode_auto_with_fallback_and_create_webp.blend and b/tests/scenes/32_webp_mode_auto_with_fallback_and_create_webp.blend differ diff --git a/tests/scenes/Untitled.ktx2 b/tests/scenes/Untitled.ktx2 new file mode 100644 index 000000000..798e2ac6a Binary files /dev/null and b/tests/scenes/Untitled.ktx2 differ diff --git a/tests/test/test.js b/tests/test/test.js index 55e08059a..b074225da 100644 --- a/tests/test/test.js +++ b/tests/test/test.js @@ -2125,6 +2125,21 @@ describe('Exporter', function () { }); + it('exports KTX2 mode', function () { + let gltfPath_1 = path.resolve(outDirPath, '32_ktx2_mode_ktx2.gltf'); + var asset = JSON.parse(fs.readFileSync(gltfPath_1)); + + for (var i = 0; i < asset.images.length; i++) { + assert.strictEqual(asset.images[i].mimeType, 'image/ktx2'); + } + + for (var i = 0; i < asset.textures.length; i++) { + assert.strictEqual(asset.textures[i].source, undefined); + assert.ok("extensions" in asset.textures[i]); + } + + }); + it('exports auto mode + webp fallback', function () { let gltfPath_1 = path.resolve(outDirPath, '32_webp_mode_auto_with_fallback.gltf'); var asset = JSON.parse(fs.readFileSync(gltfPath_1)); @@ -2142,6 +2157,23 @@ describe('Exporter', function () { } }); + it('exports auto mode + ktx2 fallback', function () { + let gltfPath_1 = path.resolve(outDirPath, '32_ktx2_mode_auto_with_fallback.gltf'); + var asset = JSON.parse(fs.readFileSync(gltfPath_1)); + + var texture_ktx2 = asset.materials[0].pbrMetallicRoughness.baseColorTexture.index; + + for (var i = 0; i < asset.textures.length; i++) { + if (i == texture_ktx2) { + assert.ok("extensions" in asset.textures[i]); + assert.ok(asset.textures[i].source != undefined); + } else { + assert.ok(asset.textures[i].source != undefined); + assert.ok(!("extensions" in asset.textures[i])); + } + } + }); + it('exports auto mode + create WebP', function () { let gltfPath_1 = path.resolve(outDirPath, '32_webp_mode_auto_with_create_webp.gltf'); var asset = JSON.parse(fs.readFileSync(gltfPath_1)); @@ -2159,6 +2191,23 @@ describe('Exporter', function () { } }); + it('exports auto mode + create ktx2', function () { + let gltfPath_1 = path.resolve(outDirPath, '32_ktx2_mode_auto_with_create_ktx2.gltf'); + var asset = JSON.parse(fs.readFileSync(gltfPath_1)); + + var texture_ktx2 = asset.materials[0].pbrMetallicRoughness.baseColorTexture.index; + + for (var i = 0; i < asset.textures.length; i++) { + if (i == texture_ktx2) { + assert.ok("extensions" in asset.textures[i]); + assert.strictEqual(asset.textures[i].source, undefined); + } else { + assert.ok(asset.textures[i].source != undefined); + assert.ok("extensions" in asset.textures[i]); + } + } + }); + it('exports auto mode + create WebP + fallback', function () { let gltfPath_1 = path.resolve(outDirPath, '32_webp_mode_auto_with_fallback_and_create_webp.gltf'); var asset = JSON.parse(fs.readFileSync(gltfPath_1)); @@ -2169,6 +2218,16 @@ describe('Exporter', function () { } }); + it('exports auto mode + create ktx2 + fallback', function () { + let gltfPath_1 = path.resolve(outDirPath, '32_ktx2_mode_auto_with_fallback_and_create_ktx2.gltf'); + var asset = JSON.parse(fs.readFileSync(gltfPath_1)); + + for (var i = 0; i < asset.textures.length; i++) { + assert.ok("extensions" in asset.textures[i]); + assert.ok(asset.textures[i].source != undefined); + } + }); + it('exports UDIM', function () { let gltfPath_1 = path.resolve(outDirPath, '33_udim.gltf'); var asset = JSON.parse(fs.readFileSync(gltfPath_1));