From 03b793464f78183378b4db50ad4029ece1bcd913 Mon Sep 17 00:00:00 2001 From: Aaron Franke Date: Sat, 29 Mar 2025 15:36:29 -0700 Subject: [PATCH] GLTF export: Allow using a PNG or JPEG fallback image --- modules/gltf/doc_classes/GLTFDocument.xml | 9 +- .../editor_scene_exporter_gltf_settings.cpp | 38 +++- modules/gltf/gltf_document.cpp | 197 +++++++++++------- modules/gltf/gltf_document.h | 7 + 4 files changed, 173 insertions(+), 78 deletions(-) diff --git a/modules/gltf/doc_classes/GLTFDocument.xml b/modules/gltf/doc_classes/GLTFDocument.xml index 47ffc624bad..ba685be6945 100644 --- a/modules/gltf/doc_classes/GLTFDocument.xml +++ b/modules/gltf/doc_classes/GLTFDocument.xml @@ -115,9 +115,16 @@ + + The user-friendly name of the fallback image format. This is used when exporting the glTF file, including writing to a file and writing to a byte array. + This property may only be one of "None", "PNG", or "JPEG", and is only used when the [member image_format] is not one of "None", "PNG", or "JPEG". If having multiple extension image formats is desired, that can be done using a [GLTFDocumentExtension] class - this property only covers the use case of providing a base glTF fallback image when using a custom image format. + + + The quality of the fallback image, if any. For PNG files, this downscales the image on both dimensions by this factor. For JPEG files, this is the lossy quality of the image. A low value is recommended, since including multiple high quality images in a glTF file defeats the file size gains of using a more efficient image format. + The user-friendly name of the export image format. This is used when exporting the glTF file, including writing to a file and writing to a byte array. - By default, Godot allows the following options: "None", "PNG", "JPEG", "Lossless WebP", and "Lossy WebP". Support for more image formats can be added in [GLTFDocumentExtension] classes. + By default, Godot allows the following options: "None", "PNG", "JPEG", "Lossless WebP", and "Lossy WebP". Support for more image formats can be added in [GLTFDocumentExtension] classes. A single extension class can provide multiple options for the specific format to use, or even an option that uses multiple formats at once. If [member image_format] is a lossy image format, this determines the lossy quality of the image. On a range of [code]0.0[/code] to [code]1.0[/code], where [code]0.0[/code] is the lowest quality and [code]1.0[/code] is the highest quality. A lossy quality of [code]1.0[/code] is not the same as lossless. diff --git a/modules/gltf/editor/editor_scene_exporter_gltf_settings.cpp b/modules/gltf/editor/editor_scene_exporter_gltf_settings.cpp index 372314578f5..c0b25df3c67 100644 --- a/modules/gltf/editor/editor_scene_exporter_gltf_settings.cpp +++ b/modules/gltf/editor/editor_scene_exporter_gltf_settings.cpp @@ -46,6 +46,15 @@ bool EditorSceneExporterGLTFSettings::_set(const StringName &p_name, const Varia _document->set_lossy_quality(p_value); return true; } + if (p_name == StringName("fallback_image_format")) { + _document->set_fallback_image_format(p_value); + emit_signal(CoreStringName(property_list_changed)); + return true; + } + if (p_name == StringName("fallback_image_quality")) { + _document->set_fallback_image_quality(p_value); + return true; + } if (p_name == StringName("root_node_mode")) { _document->set_root_node_mode((GLTFDocument::RootNodeMode)(int64_t)p_value); return true; @@ -66,6 +75,14 @@ bool EditorSceneExporterGLTFSettings::_get(const StringName &p_name, Variant &r_ r_ret = _document->get_lossy_quality(); return true; } + if (p_name == StringName("fallback_image_format")) { + r_ret = _document->get_fallback_image_format(); + return true; + } + if (p_name == StringName("fallback_image_quality")) { + r_ret = _document->get_fallback_image_quality(); + return true; + } if (p_name == StringName("root_node_mode")) { r_ret = _document->get_root_node_mode(); return true; @@ -76,10 +93,21 @@ bool EditorSceneExporterGLTFSettings::_get(const StringName &p_name, Variant &r_ void EditorSceneExporterGLTFSettings::_get_property_list(List *p_list) const { for (PropertyInfo prop : _property_list) { if (prop.name == "lossy_quality") { - String image_format = get("image_format"); - bool is_image_format_lossy = image_format == "JPEG" || image_format.containsn("Lossy"); + const String image_format = get("image_format"); + const bool is_image_format_lossy = image_format == "JPEG" || image_format.containsn("Lossy"); prop.usage = is_image_format_lossy ? PROPERTY_USAGE_DEFAULT : PROPERTY_USAGE_STORAGE; } + if (prop.name == "fallback_image_format") { + const String image_format = get("image_format"); + const bool is_image_format_extension = image_format != "None" && image_format != "PNG" && image_format != "JPEG"; + prop.usage = is_image_format_extension ? PROPERTY_USAGE_DEFAULT : PROPERTY_USAGE_STORAGE; + } + if (prop.name == "fallback_image_quality") { + const String image_format = get("image_format"); + const bool is_image_format_extension = image_format != "None" && image_format != "PNG" && image_format != "JPEG"; + const String fallback_format = get("fallback_image_format"); + prop.usage = (is_image_format_extension && fallback_format != "None") ? PROPERTY_USAGE_DEFAULT : PROPERTY_USAGE_STORAGE; + } p_list->push_back(prop); } } @@ -117,7 +145,7 @@ String get_friendly_config_prefix(Ref p_extension) { return config_prefix; } const String class_name = p_extension->get_class_name(); - config_prefix = class_name.trim_prefix("GLTFDocumentExtension").capitalize(); + config_prefix = class_name.trim_prefix("GLTFDocumentExtension").trim_suffix("GLTFDocumentExtension").capitalize(); if (!config_prefix.is_empty()) { return config_prefix; } @@ -166,6 +194,10 @@ void EditorSceneExporterGLTFSettings::generate_property_list(Ref p _property_list.push_back(image_format_prop); PropertyInfo lossy_quality_prop = PropertyInfo(Variant::FLOAT, "lossy_quality", PROPERTY_HINT_RANGE, "0,1,0.01"); _property_list.push_back(lossy_quality_prop); + PropertyInfo fallback_image_format_prop = PropertyInfo(Variant::STRING, "fallback_image_format", PROPERTY_HINT_ENUM, "None,PNG,JPEG"); + _property_list.push_back(fallback_image_format_prop); + PropertyInfo fallback_image_quality_prop = PropertyInfo(Variant::FLOAT, "fallback_image_quality", PROPERTY_HINT_RANGE, "0,1,0.01"); + _property_list.push_back(fallback_image_quality_prop); PropertyInfo root_node_mode_prop = PropertyInfo(Variant::INT, "root_node_mode", PROPERTY_HINT_ENUM, "Single Root,Keep Root,Multi Root"); _property_list.push_back(root_node_mode_prop); } diff --git a/modules/gltf/gltf_document.cpp b/modules/gltf/gltf_document.cpp index 14b4f96c8df..696f703e8bf 100644 --- a/modules/gltf/gltf_document.cpp +++ b/modules/gltf/gltf_document.cpp @@ -3804,6 +3804,22 @@ float GLTFDocument::get_lossy_quality() const { return _lossy_quality; } +void GLTFDocument::set_fallback_image_format(const String &p_fallback_image_format) { + _fallback_image_format = p_fallback_image_format; +} + +String GLTFDocument::get_fallback_image_format() const { + return _fallback_image_format; +} + +void GLTFDocument::set_fallback_image_quality(float p_fallback_image_quality) { + _fallback_image_quality = p_fallback_image_quality; +} + +float GLTFDocument::get_fallback_image_quality() const { + return _fallback_image_quality; +} + Error GLTFDocument::_serialize_images(Ref p_state) { Array images; // Check if any extension wants to be the image saver. @@ -3819,83 +3835,21 @@ Error GLTFDocument::_serialize_images(Ref p_state) { // Serialize every image in the state's images array. for (int i = 0; i < p_state->images.size(); i++) { Dictionary image_dict; - - ERR_CONTINUE(p_state->images[i].is_null()); - - Ref image = p_state->images[i]->get_image(); - ERR_CONTINUE(image.is_null()); - if (image->is_compressed()) { - image->decompress(); - ERR_FAIL_COND_V_MSG(image->is_compressed(), ERR_INVALID_DATA, "glTF: Image was compressed, but could not be decompressed."); - } - - if (p_state->filename.to_lower().ends_with("gltf")) { - String img_name = p_state->images[i]->get_name(); - if (img_name.is_empty()) { - img_name = itos(i).pad_zeros(3); - } - img_name = _gen_unique_name(p_state, img_name); - String relative_texture_dir = "textures"; - String full_texture_dir = p_state->base_path.path_join(relative_texture_dir); - Ref da = DirAccess::open(p_state->base_path); - ERR_FAIL_COND_V(da.is_null(), FAILED); - - if (!da->dir_exists(full_texture_dir)) { - da->make_dir(full_texture_dir); - } - if (_image_save_extension.is_valid()) { - img_name = img_name + _image_save_extension->get_image_file_extension(); - Error err = _image_save_extension->save_image_at_path(p_state, image, full_texture_dir.path_join(img_name), _image_format, _lossy_quality); - ERR_FAIL_COND_V_MSG(err != OK, err, "glTF: Failed to save image in '" + _image_format + "' format as a separate file."); - } else if (_image_format == "PNG") { - img_name = img_name + ".png"; - image->save_png(full_texture_dir.path_join(img_name)); - } else if (_image_format == "JPEG") { - img_name = img_name + ".jpg"; - image->save_jpg(full_texture_dir.path_join(img_name), _lossy_quality); - } else { - ERR_FAIL_V_MSG(ERR_UNAVAILABLE, "glTF: Unknown image format '" + _image_format + "'."); - } - image_dict["uri"] = relative_texture_dir.path_join(img_name).uri_encode(); + if (p_state->images[i].is_null()) { + ERR_PRINT("glTF export: Image Texture2D is null."); } else { - GLTFBufferViewIndex bvi; - - Ref bv; - bv.instantiate(); - - const GLTFBufferIndex bi = 0; - bv->buffer = bi; - bv->byte_offset = p_state->buffers[bi].size(); - ERR_FAIL_INDEX_V(bi, p_state->buffers.size(), ERR_PARAMETER_RANGE_ERROR); - - Vector buffer; - Ref img_tex = image; - if (img_tex.is_valid()) { - image = img_tex->get_image(); - } - // Save in various image formats. Note that if the format is "None", - // the state's images will be empty, so this code will not be reached. - if (_image_save_extension.is_valid()) { - buffer = _image_save_extension->serialize_image_to_bytes(p_state, image, image_dict, _image_format, _lossy_quality); - } else if (_image_format == "PNG") { - buffer = image->save_png_to_buffer(); - image_dict["mimeType"] = "image/png"; - } else if (_image_format == "JPEG") { - buffer = image->save_jpg_to_buffer(_lossy_quality); - image_dict["mimeType"] = "image/jpeg"; + Ref image = p_state->images[i]->get_image(); + if (image.is_null()) { + ERR_PRINT("glTF export: Image's image is null."); } else { - ERR_FAIL_V_MSG(ERR_UNAVAILABLE, "glTF: Unknown image format '" + _image_format + "'."); + String image_name = p_state->images[i]->get_name(); + if (image_name.is_empty()) { + image_name = itos(i).pad_zeros(3); + } + image_name = _gen_unique_name(p_state, image_name); + image->set_name(image_name); + image_dict = _serialize_image(p_state, image, _image_format, _lossy_quality, _image_save_extension); } - ERR_FAIL_COND_V_MSG(buffer.is_empty(), ERR_INVALID_DATA, "glTF: Failed to save image in '" + _image_format + "' format."); - - bv->byte_length = buffer.size(); - p_state->buffers.write[bi].resize(p_state->buffers[bi].size() + bv->byte_length); - memcpy(&p_state->buffers.write[bi].write[bv->byte_offset], buffer.ptr(), buffer.size()); - ERR_FAIL_COND_V(bv->byte_offset + bv->byte_length > p_state->buffers[bi].size(), ERR_FILE_CORRUPT); - - p_state->buffer_views.push_back(bv); - bvi = p_state->buffer_views.size() - 1; - image_dict["bufferView"] = bvi; } images.push_back(image_dict); } @@ -3910,6 +3864,80 @@ Error GLTFDocument::_serialize_images(Ref p_state) { return OK; } +Dictionary GLTFDocument::_serialize_image(Ref p_state, Ref p_image, const String &p_image_format, float p_lossy_quality, Ref p_image_save_extension) { + Dictionary image_dict; + if (p_image->is_compressed()) { + p_image->decompress(); + ERR_FAIL_COND_V_MSG(p_image->is_compressed(), image_dict, "glTF: Image was compressed, but could not be decompressed."); + } + + if (p_state->filename.to_lower().ends_with("gltf")) { + String relative_texture_dir = "textures"; + String full_texture_dir = p_state->base_path.path_join(relative_texture_dir); + Ref da = DirAccess::open(p_state->base_path); + ERR_FAIL_COND_V(da.is_null(), image_dict); + + if (!da->dir_exists(full_texture_dir)) { + da->make_dir(full_texture_dir); + } + String image_file_name = p_image->get_name(); + if (p_image_save_extension.is_valid()) { + image_file_name = image_file_name + p_image_save_extension->get_image_file_extension(); + Error err = p_image_save_extension->save_image_at_path(p_state, p_image, full_texture_dir.path_join(image_file_name), p_image_format, p_lossy_quality); + ERR_FAIL_COND_V_MSG(err != OK, image_dict, "glTF: Failed to save image in '" + p_image_format + "' format as a separate file, error " + itos(err) + "."); + } else if (p_image_format == "PNG") { + image_file_name = image_file_name + ".png"; + p_image->save_png(full_texture_dir.path_join(image_file_name)); + } else if (p_image_format == "JPEG") { + image_file_name = image_file_name + ".jpg"; + p_image->save_jpg(full_texture_dir.path_join(image_file_name), p_lossy_quality); + } else { + ERR_FAIL_V_MSG(image_dict, "glTF: Unknown image format '" + p_image_format + "'."); + } + image_dict["uri"] = relative_texture_dir.path_join(image_file_name).uri_encode(); + } else { + GLTFBufferViewIndex bvi; + + Ref bv; + bv.instantiate(); + + const GLTFBufferIndex bi = 0; + bv->buffer = bi; + ERR_FAIL_INDEX_V(bi, p_state->buffers.size(), image_dict); + bv->byte_offset = p_state->buffers[bi].size(); + + Vector buffer; + Ref img_tex = p_image; + if (img_tex.is_valid()) { + p_image = img_tex->get_image(); + } + // Save in various image formats. Note that if the format is "None", + // the state's images will be empty, so this code will not be reached. + if (_image_save_extension.is_valid()) { + buffer = _image_save_extension->serialize_image_to_bytes(p_state, p_image, image_dict, p_image_format, p_lossy_quality); + } else if (p_image_format == "PNG") { + buffer = p_image->save_png_to_buffer(); + image_dict["mimeType"] = "image/png"; + } else if (p_image_format == "JPEG") { + buffer = p_image->save_jpg_to_buffer(p_lossy_quality); + image_dict["mimeType"] = "image/jpeg"; + } else { + ERR_FAIL_V_MSG(image_dict, "glTF: Unknown image format '" + p_image_format + "'."); + } + ERR_FAIL_COND_V_MSG(buffer.is_empty(), image_dict, "glTF: Failed to save image in '" + p_image_format + "' format."); + + bv->byte_length = buffer.size(); + p_state->buffers.write[bi].resize(p_state->buffers[bi].size() + bv->byte_length); + memcpy(&p_state->buffers.write[bi].write[bv->byte_offset], buffer.ptr(), buffer.size()); + ERR_FAIL_COND_V(bv->byte_offset + bv->byte_length > p_state->buffers[bi].size(), image_dict); + + p_state->buffer_views.push_back(bv); + bvi = p_state->buffer_views.size() - 1; + image_dict["bufferView"] = bvi; + } + return image_dict; +} + Ref GLTFDocument::_parse_image_bytes_into_image(Ref p_state, const Vector &p_bytes, const String &p_mime_type, int p_index, String &r_file_extension) { Ref r_image; r_image.instantiate(); @@ -4199,6 +4227,21 @@ Error GLTFDocument::_serialize_textures(Ref p_state) { if (_image_save_extension.is_valid()) { Error err = _image_save_extension->serialize_texture_json(p_state, texture_dict, gltf_texture, _image_format); ERR_FAIL_COND_V(err != OK, err); + // If a fallback image format was specified, serialize another image for it. + // Note: This must only be done after serializing other images to keep the indices of those consistent. + if (_fallback_image_format != "None" && p_state->json.has("images")) { + Array json_images = p_state->json["images"]; + texture_dict["source"] = json_images.size(); + Ref image = p_state->source_images[gltf_texture->get_src_image()]; + String fallback_name = _gen_unique_name(p_state, image->get_name() + "_fallback"); + image = image->duplicate(); + image->set_name(fallback_name); + ERR_CONTINUE(image.is_null()); + if (_fallback_image_format == "PNG") { + image->resize(image->get_width() * _fallback_image_quality, image->get_height() * _fallback_image_quality); + } + json_images.push_back(_serialize_image(p_state, image, _fallback_image_format, _fallback_image_quality, nullptr)); + } } else { ERR_CONTINUE(gltf_texture->get_src_image() == -1); texture_dict["source"] = gltf_texture->get_src_image(); @@ -8159,6 +8202,10 @@ void GLTFDocument::_bind_methods() { ClassDB::bind_method(D_METHOD("get_image_format"), &GLTFDocument::get_image_format); ClassDB::bind_method(D_METHOD("set_lossy_quality", "lossy_quality"), &GLTFDocument::set_lossy_quality); ClassDB::bind_method(D_METHOD("get_lossy_quality"), &GLTFDocument::get_lossy_quality); + ClassDB::bind_method(D_METHOD("set_fallback_image_format", "fallback_image_format"), &GLTFDocument::set_fallback_image_format); + ClassDB::bind_method(D_METHOD("get_fallback_image_format"), &GLTFDocument::get_fallback_image_format); + ClassDB::bind_method(D_METHOD("set_fallback_image_quality", "fallback_image_quality"), &GLTFDocument::set_fallback_image_quality); + ClassDB::bind_method(D_METHOD("get_fallback_image_quality"), &GLTFDocument::get_fallback_image_quality); ClassDB::bind_method(D_METHOD("set_root_node_mode", "root_node_mode"), &GLTFDocument::set_root_node_mode); ClassDB::bind_method(D_METHOD("get_root_node_mode"), &GLTFDocument::get_root_node_mode); ClassDB::bind_method(D_METHOD("append_from_file", "path", "state", "flags", "base_path"), @@ -8176,6 +8223,8 @@ void GLTFDocument::_bind_methods() { ADD_PROPERTY(PropertyInfo(Variant::STRING, "image_format"), "set_image_format", "get_image_format"); ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "lossy_quality"), "set_lossy_quality", "get_lossy_quality"); + ADD_PROPERTY(PropertyInfo(Variant::STRING, "fallback_image_format"), "set_fallback_image_format", "get_fallback_image_format"); + ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "fallback_image_quality"), "set_fallback_image_quality", "get_fallback_image_quality"); ADD_PROPERTY(PropertyInfo(Variant::INT, "root_node_mode"), "set_root_node_mode", "get_root_node_mode"); ClassDB::bind_static_method("GLTFDocument", D_METHOD("import_object_model_property", "state", "json_pointer"), &GLTFDocument::import_object_model_property); diff --git a/modules/gltf/gltf_document.h b/modules/gltf/gltf_document.h index 38e668606d8..875b0d1a881 100644 --- a/modules/gltf/gltf_document.h +++ b/modules/gltf/gltf_document.h @@ -65,6 +65,8 @@ private: int _naming_version = 1; String _image_format = "PNG"; float _lossy_quality = 0.75f; + String _fallback_image_format = "None"; + float _fallback_image_quality = 0.25f; Ref _image_save_extension; RootNodeMode _root_node_mode = RootNodeMode::ROOT_NODE_MODE_SINGLE_ROOT; @@ -92,6 +94,10 @@ public: String get_image_format() const; void set_lossy_quality(float p_lossy_quality); float get_lossy_quality() const; + void set_fallback_image_format(const String &p_fallback_image_format); + String get_fallback_image_format() const; + void set_fallback_image_quality(float p_fallback_image_quality); + float get_fallback_image_quality() const; void set_root_node_mode(RootNodeMode p_root_node_mode); RootNodeMode get_root_node_mode() const; static String _gen_unique_name_static(HashSet &r_unique_names, const String &p_name); @@ -182,6 +188,7 @@ private: Error _serialize_textures(Ref p_state); Error _serialize_texture_samplers(Ref p_state); Error _serialize_images(Ref p_state); + Dictionary _serialize_image(Ref p_state, Ref p_image, const String &p_image_format, float p_lossy_quality, Ref p_image_save_extension); Error _serialize_lights(Ref p_state); Ref _parse_image_bytes_into_image(Ref p_state, const Vector &p_bytes, const String &p_mime_type, int p_index, String &r_file_extension); void _parse_image_save_image(Ref p_state, const Vector &p_bytes, const String &p_resource_uri, const String &p_file_extension, int p_index, Ref p_image);