Merge pull request #104784 from aaronfranke/gltf-fallback-image

GLTF export: Allow using a PNG or JPEG fallback image
This commit is contained in:
Thaddeus Crews
2025-04-24 17:18:48 -05:00
4 changed files with 173 additions and 78 deletions

View File

@ -115,9 +115,16 @@
</method>
</methods>
<members>
<member name="fallback_image_format" type="String" setter="set_fallback_image_format" getter="get_fallback_image_format" default="&quot;None&quot;">
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.
</member>
<member name="fallback_image_quality" type="float" setter="set_fallback_image_quality" getter="get_fallback_image_quality" default="0.25">
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.
</member>
<member name="image_format" type="String" setter="set_image_format" getter="get_image_format" default="&quot;PNG&quot;">
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.
</member>
<member name="lossy_quality" type="float" setter="set_lossy_quality" getter="get_lossy_quality" default="0.75">
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.

View File

@ -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<PropertyInfo> *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<GLTFDocumentExtension> 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<GLTFDocument> 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);
}

View File

@ -3814,6 +3814,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<GLTFState> p_state) {
Array images;
// Check if any extension wants to be the image saver.
@ -3829,83 +3845,21 @@ Error GLTFDocument::_serialize_images(Ref<GLTFState> 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> 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<DirAccess> 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<GLTFBufferView> 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<uint8_t> buffer;
Ref<ImageTexture> 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> 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);
}
@ -3920,6 +3874,80 @@ Error GLTFDocument::_serialize_images(Ref<GLTFState> p_state) {
return OK;
}
Dictionary GLTFDocument::_serialize_image(Ref<GLTFState> p_state, Ref<Image> p_image, const String &p_image_format, float p_lossy_quality, Ref<GLTFDocumentExtension> 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<DirAccess> 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<GLTFBufferView> 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<uint8_t> buffer;
Ref<ImageTexture> 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<Image> GLTFDocument::_parse_image_bytes_into_image(Ref<GLTFState> p_state, const Vector<uint8_t> &p_bytes, const String &p_mime_type, int p_index, String &r_file_extension) {
Ref<Image> r_image;
r_image.instantiate();
@ -4209,6 +4237,21 @@ Error GLTFDocument::_serialize_textures(Ref<GLTFState> 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> 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();
@ -8169,6 +8212,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"),
@ -8186,6 +8233,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);

View File

@ -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<GLTFDocumentExtension> _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<String> &r_unique_names, const String &p_name);
@ -182,6 +188,7 @@ private:
Error _serialize_textures(Ref<GLTFState> p_state);
Error _serialize_texture_samplers(Ref<GLTFState> p_state);
Error _serialize_images(Ref<GLTFState> p_state);
Dictionary _serialize_image(Ref<GLTFState> p_state, Ref<Image> p_image, const String &p_image_format, float p_lossy_quality, Ref<GLTFDocumentExtension> p_image_save_extension);
Error _serialize_lights(Ref<GLTFState> p_state);
Ref<Image> _parse_image_bytes_into_image(Ref<GLTFState> p_state, const Vector<uint8_t> &p_bytes, const String &p_mime_type, int p_index, String &r_file_extension);
void _parse_image_save_image(Ref<GLTFState> p_state, const Vector<uint8_t> &p_bytes, const String &p_resource_uri, const String &p_file_extension, int p_index, Ref<Image> p_image);