Skip to content

Commit

Permalink
Add an import option to correct a heightmap texture's baseline or nor…
Browse files Browse the repository at this point in the history
…malize it

Automatic detection is implemented in the BaseMaterial3D shader.
It can be integrated in custom shaders by adding `hint_height` to the relevant
sampler uniforms or setting the texture mode to Height Map
in VisualShaderNodeTextureParameter.

When a texture is detected to be used as a heightmap, it'll switch to
the Correct Baseline mode automatically, which reduces parallax issues
without requiring manual adjustments to the heightmap scale.

If you want to further improve quality (or reduce the number of deep
parallax steps without impacting quality), you can use the Normalized
mode which requires adjusting the heightmap scale used in the material.
When doing so, the required heightmap scale multiplier is printed
on import so you can get the same effective heightmap scale as before.
  • Loading branch information
Calinou committed Dec 9, 2024
1 parent a372214 commit 92d31a2
Show file tree
Hide file tree
Showing 20 changed files with 199 additions and 15 deletions.
2 changes: 1 addition & 1 deletion doc/classes/BaseMaterial3D.xml
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@
</member>
<member name="heightmap_texture" type="Texture2D" setter="set_texture" getter="get_texture">
The texture to use as a height map. See also [member heightmap_enabled].
For best results, the texture should be normalized (with [member heightmap_scale] reduced to compensate). In [url=https://gimp.org]GIMP[/url], this can be done using [b]Colors &gt; Auto &gt; Equalize[/b]. If the texture only uses a small part of its available range, the parallax effect may look strange, especially when the camera moves.
For best results, the texture should be normalized (with [member heightmap_scale] reduced to compensate). This allows the deep parallax effect to make full use of the number of steps defined in [member heightmap_min_layers] and [member heightmap_max_layers]. This can be done by setting [member ResourceImporterTexture.process/height_map_adjust] to [b]Normalize[/b]. Alternatively, [member ResourceImporterTexture.process/height_map_adjust] can be set to [b]Correct Baseline[/b] without needing adjustments to [member heightmap_scale], but this won't ensure the deep parallax effect is optimal.
[b]Note:[/b] To reduce memory usage and improve loading times, you may be able to use a lower-resolution heightmap texture as most heightmaps are only comprised of low-frequency data.
</member>
<member name="metallic" type="float" setter="set_metallic" getter="get_metallic" default="0.0">
Expand Down
7 changes: 7 additions & 0 deletions doc/classes/ResourceImporterTexture.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
Some HDR panorama images you can find online may contain extremely bright pixels, due to being taken from real life sources without any clipping.
While these HDR panorama images are accurate to real life, this can cause the radiance map generated by Godot to contain sparkles when used as a background sky. This can be seen in material reflections (even on rough materials in extreme cases). Enabling [member process/hdr_clamp_exposure] can resolve this.
</member>
<member name="process/height_map_adjust" type="int" setter="" getter="" default="0">
Process the texture on import to improve its appearance when used as a heightmap texture (see [member BaseMaterial3D.heightmap_texture]).
- [b]Detect[/b] Detect whether the texture is used as a height map in [BaseMaterial3D] ([member BaseMaterial3D.heightmap_texture]) or a custom shader that uses [code]hint_height[/code] on one of its sampler uniforms. If so, [member process/height_map_adjust] is automatically set to [b]Correct Baseline[/b].
- [b]Disabled:[/b] Don't perform any adjustments on the texture.
- [b]Correct Baseline:[/b] Adjust the texture so its brightest pixel is white, brightening other pixels with the same fixed offset. This improves the heightmap effect's appearance, since heightmapping is designed to use white as a a value that doesn't change the texture's height.
- [b]Normalize:[/b] Adjust the texture so its brightest pixel is white and its darkest pixel is black. The texture's contrast is expanded in the process. This further improves the texture's appearance compared to [b]Correct Baseline[/b] by making better use of the deep parallax steps, but the heightmap scale must be manually reduced to match the appearance of [b]Correct Baseline[/b] (see [member BaseMaterial3D.heightmap_scale]).
</member>
<member name="process/normal_map_invert_y" type="bool" setter="" getter="" default="false">
If [code]true[/code], convert the normal map from Y- (DirectX-style) to Y+ (OpenGL-style) by inverting its green color channel. This is the normal map convention expected by Godot.
More information about normal maps (including a coordinate order table for popular engines) can be found [url=http://wiki.polycount.com/wiki/Normal_Map_Technical_Details]here[/url].
Expand Down
8 changes: 8 additions & 0 deletions drivers/gles3/storage/texture_storage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,14 @@ void TextureStorage::texture_set_detect_roughness_callback(RID p_texture, RS::Te
texture->detect_roughness_callback_ud = p_userdata;
}

void TextureStorage::texture_set_detect_height_callback(RID p_texture, RS::TextureDetectCallback p_callback, void *p_userdata) {
Texture *texture = texture_owner.get_or_null(p_texture);
ERR_FAIL_NULL(texture);

texture->detect_height_callback = p_callback;
texture->detect_height_callback_ud = p_userdata;
}

void TextureStorage::texture_debug_usage(List<RS::TextureInfo> *r_info) {
List<RID> textures;
texture_owner.get_owned_list(&textures);
Expand Down
6 changes: 6 additions & 0 deletions drivers/gles3/storage/texture_storage.h
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ struct Texture {
RS::TextureDetectRoughnessCallback detect_roughness_callback = nullptr;
void *detect_roughness_callback_ud = nullptr;

RS::TextureDetectCallback detect_height_callback = nullptr;
void *detect_height_callback_ud = nullptr;

CanvasTexture *canvas_texture = nullptr;

void copy_from(const Texture &o) {
Expand Down Expand Up @@ -236,6 +239,8 @@ struct Texture {
detect_normal_callback_ud = o.detect_normal_callback_ud;
detect_roughness_callback = o.detect_roughness_callback;
detect_roughness_callback_ud = o.detect_roughness_callback_ud;
detect_height_callback = o.detect_height_callback;
detect_height_callback_ud = o.detect_height_callback_ud;
}

// texture state
Expand Down Expand Up @@ -546,6 +551,7 @@ class TextureStorage : public RendererTextureStorage {
void texture_set_detect_srgb_callback(RID p_texture, RS::TextureDetectCallback p_callback, void *p_userdata);
virtual void texture_set_detect_normal_callback(RID p_texture, RS::TextureDetectCallback p_callback, void *p_userdata) override;
virtual void texture_set_detect_roughness_callback(RID p_texture, RS::TextureDetectRoughnessCallback p_callback, void *p_userdata) override;
virtual void texture_set_detect_height_callback(RID p_texture, RS::TextureDetectCallback p_callback, void *p_userdata) override;

virtual void texture_debug_usage(List<RS::TextureInfo> *r_info) override;

Expand Down
103 changes: 97 additions & 6 deletions editor/import/resource_importer_texture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ void ResourceImporterTexture::_texture_reimport_normal(const Ref<CompressedTextu
singleton->make_flags[path].flags |= MAKE_NORMAL_FLAG;
}

void ResourceImporterTexture::_texture_reimport_height(const Ref<CompressedTexture2D> &p_tex) {
ERR_FAIL_COND(p_tex.is_null());

MutexLock lock(singleton->mutex);

StringName path = p_tex->get_path();

if (!singleton->make_flags.has(path)) {
singleton->make_flags[path] = MakeInfo();
}

singleton->make_flags[path].flags |= MAKE_HEIGHT_FLAG;
}

void ResourceImporterTexture::update_imports() {
if (EditorFileSystem::get_singleton()->is_scanning() || EditorFileSystem::get_singleton()->is_importing()) {
return; // do nothing for now
Expand Down Expand Up @@ -129,6 +143,16 @@ void ResourceImporterTexture::update_imports() {
changed = true;
}

if (E.value.flags & MAKE_HEIGHT_FLAG && int(cf->get_value("params", "process/height_map_adjust")) == HEIGHTMAP_ADJUST_DETECT) {
String message = vformat(TTR("%s: Texture detected as used as a height map in 3D. Enabling height baseline correction to avoid parallax issues."), String(E.key));
#ifdef TOOLS_ENABLED
EditorToaster::get_singleton()->popup_str(message);
#endif
print_line(message);
cf->set_value("params", "process/height_map_adjust", HEIGHTMAP_ADJUST_CORRECT_BASELINE);
changed = true;
}

if (E.value.flags & MAKE_3D_FLAG && bool(cf->get_value("params", "detect_3d/compress_to"))) {
const int compress_to = cf->get_value("params", "detect_3d/compress_to");
String compress_string;
Expand Down Expand Up @@ -239,6 +263,7 @@ void ResourceImporterTexture::get_import_options(const String &p_path, List<Impo
r_options->push_back(ImportOption(PropertyInfo(Variant::BOOL, "process/fix_alpha_border"), p_preset != PRESET_3D));
r_options->push_back(ImportOption(PropertyInfo(Variant::BOOL, "process/premult_alpha"), false));
r_options->push_back(ImportOption(PropertyInfo(Variant::BOOL, "process/normal_map_invert_y"), false));
r_options->push_back(ImportOption(PropertyInfo(Variant::INT, "process/height_map_adjust", PROPERTY_HINT_ENUM, "Detect,Disabled,Correct Baseline,Normalize"), HEIGHTMAP_ADJUST_DETECT));
r_options->push_back(ImportOption(PropertyInfo(Variant::BOOL, "process/hdr_as_srgb"), false));
r_options->push_back(ImportOption(PropertyInfo(Variant::BOOL, "process/hdr_clamp_exposure"), false));

Expand Down Expand Up @@ -345,7 +370,7 @@ void ResourceImporterTexture::save_to_ctex_format(Ref<FileAccess> f, const Ref<I
}
}

void ResourceImporterTexture::_save_ctex(const Ref<Image> &p_image, const String &p_to_path, CompressMode p_compress_mode, float p_lossy_quality, Image::CompressMode p_vram_compression, bool p_mipmaps, bool p_streamable, bool p_detect_3d, bool p_detect_roughness, bool p_detect_normal, bool p_force_normal, bool p_srgb_friendly, bool p_force_po2_for_compressed, uint32_t p_limit_mipmap, const Ref<Image> &p_normal, Image::RoughnessChannel p_roughness_channel) {
void ResourceImporterTexture::_save_ctex(const Ref<Image> &p_image, const String &p_to_path, CompressMode p_compress_mode, float p_lossy_quality, Image::CompressMode p_vram_compression, bool p_mipmaps, bool p_streamable, bool p_detect_3d, bool p_detect_roughness, bool p_detect_normal, bool p_detect_height, bool p_force_normal, bool p_srgb_friendly, bool p_force_po2_for_compressed, uint32_t p_limit_mipmap, const Ref<Image> &p_normal, Image::RoughnessChannel p_roughness_channel) {
Ref<FileAccess> f = FileAccess::open(p_to_path, FileAccess::WRITE);
ERR_FAIL_COND(f.is_null());
f->store_8('G');
Expand Down Expand Up @@ -375,6 +400,9 @@ void ResourceImporterTexture::_save_ctex(const Ref<Image> &p_image, const String
if (p_detect_normal) {
flags |= CompressedTexture2D::FORMAT_BIT_DETECT_NORMAL;
}
if (p_detect_height) {
flags |= CompressedTexture2D::FORMAT_BIT_DETECT_HEIGHT;
}

f->store_32(flags);
f->store_32(p_limit_mipmap);
Expand Down Expand Up @@ -455,6 +483,7 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
const bool fix_alpha_border = p_options["process/fix_alpha_border"];
const bool premult_alpha = p_options["process/premult_alpha"];
const bool normal_map_invert_y = p_options["process/normal_map_invert_y"];
const HeightmapAdjust height_map_adjust = HeightmapAdjust(int(p_options["process/height_map_adjust"]));
// Support for texture streaming is not implemented yet.
const bool stream = false;

Expand Down Expand Up @@ -595,6 +624,66 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
}
}

// Correct a heightmap texture's baseline so it starts from a fully white pixel,
// without affecting its scale.
// This reduces distortion as the camera moves and ensures the material's appearance
// doesn't change too much when heightmapping is disabled.
if (height_map_adjust == HEIGHTMAP_ADJUST_CORRECT_BASELINE) {
const int height = target_image->get_height();
const int width = target_image->get_width();

// Determine the correction offset, i.e. how much each pixel should be darkened in the image.
// This is `1.0` if black needs to be turned white (if there are only black pixels),
// or `0.0` if no correction is needed at all (because the brightest pixel is already fully white).
float correction_offset = 1.0;
for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
correction_offset = MIN(correction_offset, 1.0 - target_image->get_pixel(i, j).get_v());
if (Math::is_zero_approx(correction_offset)) {
// It can't go any lower, so we can stop searching.
break;
}
}
}

// Adjust all pixels to bring the brightest pixel to white, while keeping the existing
// contrast (= scale) in place.
for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
target_image->set_pixel(i, j, target_image->get_pixel(i, j) + Color(1, 1, 1) * correction_offset);
}
}
} else if (height_map_adjust == HEIGHTMAP_ADJUST_NORMALIZE) {
const int height = target_image->get_height();
const int width = target_image->get_width();

// Determine the brightest and darkest pixels in the texture.
float brightest_pixel = 0.0;
float darkest_pixel = 1.0;
for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
const float pixel_value = target_image->get_pixel(i, j).get_v();
brightest_pixel = MAX(brightest_pixel, pixel_value);
darkest_pixel = MIN(darkest_pixel, pixel_value);
if (Math::is_equal_approx(brightest_pixel, 1.0f) && Math::is_zero_approx(darkest_pixel)) {
// The brightest pixel can't be any brighter and the darkest pixel can't be any darker, so we can stop searching.
break;
}
}
}

// Normalize pixel data by remapping it to the pixel range we found.
// This changes the texture's contrast, so the user must reduce the heightmap scale in the material afterwards.
// However, this improves quality by making the shader use the full range of the heightmap texture.
for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
target_image->set_pixel(i, j, Color(1, 1, 1) * Math::remap(target_image->get_pixel(i, j).get_v(), darkest_pixel, brightest_pixel, 0.0f, 1.0f));
}
}

print_line(vformat("%s: Heightmap texture normalized. Use this heightmap scale multiplier to match the non-normalized appearance: (existing scale) * %.4f", p_source_file, brightest_pixel - darkest_pixel));
}

// Clamp HDR exposure.
if (hdr_clamp_exposure) {
// Clamp HDR exposure following Filament's tonemapping formula.
Expand Down Expand Up @@ -629,6 +718,7 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
bool detect_roughness = roughness == 0;
bool detect_normal = normal == 0;
bool force_normal = normal == 1;
bool detect_height = height_map_adjust == HEIGHTMAP_ADJUST_DETECT;
bool srgb_friendly_pack = pack_channels == 0;

Array formats_imported;
Expand Down Expand Up @@ -678,7 +768,7 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
}

if (use_uncompressed) {
_save_ctex(image, p_save_path + ".ctex", COMPRESS_VRAM_UNCOMPRESSED, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
_save_ctex(image, p_save_path + ".ctex", COMPRESS_VRAM_UNCOMPRESSED, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, detect_height, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
} else {
if (can_s3tc_bptc) {
Image::CompressMode image_compress_mode;
Expand All @@ -690,7 +780,7 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
image_compress_mode = Image::COMPRESS_S3TC;
image_compress_format = "s3tc";
}
_save_ctex(image, p_save_path + "." + image_compress_format + ".ctex", compress_mode, lossy, image_compress_mode, mipmaps, stream, detect_3d, detect_roughness, detect_normal, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
_save_ctex(image, p_save_path + "." + image_compress_format + ".ctex", compress_mode, lossy, image_compress_mode, mipmaps, stream, detect_3d, detect_roughness, detect_normal, detect_height, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
r_platform_variants->push_back(image_compress_format);
}

Expand All @@ -704,17 +794,17 @@ Error ResourceImporterTexture::import(ResourceUID::ID p_source_id, const String
image_compress_mode = Image::COMPRESS_ETC2;
image_compress_format = "etc2";
}
_save_ctex(image, p_save_path + "." + image_compress_format + ".ctex", compress_mode, lossy, image_compress_mode, mipmaps, stream, detect_3d, detect_roughness, detect_normal, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
_save_ctex(image, p_save_path + "." + image_compress_format + ".ctex", compress_mode, lossy, image_compress_mode, mipmaps, stream, detect_3d, detect_roughness, detect_normal, detect_height, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
r_platform_variants->push_back(image_compress_format);
}
}
} else {
// Import normally.
_save_ctex(image, p_save_path + ".ctex", compress_mode, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
_save_ctex(image, p_save_path + ".ctex", compress_mode, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, detect_height, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
}

if (editor_image.is_valid()) {
_save_ctex(editor_image, p_save_path + ".editor.ctex", compress_mode, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);
_save_ctex(editor_image, p_save_path + ".editor.ctex", compress_mode, lossy, Image::COMPRESS_S3TC /*this is ignored */, mipmaps, stream, detect_3d, detect_roughness, detect_normal, detect_height, force_normal, srgb_friendly_pack, false, mipmap_limit, normal_image, roughness_channel);

// Generate and save editor-specific metadata, which we cannot save to the .import file.
Dictionary editor_meta;
Expand Down Expand Up @@ -832,6 +922,7 @@ ResourceImporterTexture::ResourceImporterTexture(bool p_singleton) {
CompressedTexture2D::request_3d_callback = _texture_reimport_3d;
CompressedTexture2D::request_roughness_callback = _texture_reimport_roughness;
CompressedTexture2D::request_normal_callback = _texture_reimport_normal;
CompressedTexture2D::request_height_callback = _texture_reimport_height;
}

ResourceImporterTexture::~ResourceImporterTexture() {
Expand Down
13 changes: 11 additions & 2 deletions editor/import/resource_importer_texture.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ class ResourceImporterTexture : public ResourceImporter {
COMPRESS_BASIS_UNIVERSAL
};

enum HeightmapAdjust {
HEIGHTMAP_ADJUST_DETECT,
HEIGHTMAP_ADJUST_DISABLED,
HEIGHTMAP_ADJUST_CORRECT_BASELINE,
HEIGHTMAP_ADJUST_NORMALIZE,
};

protected:
enum {
MAKE_3D_FLAG = 1,
MAKE_ROUGHNESS_FLAG = 2,
MAKE_NORMAL_FLAG = 4
MAKE_NORMAL_FLAG = 4,
MAKE_HEIGHT_FLAG = 8,
};

Mutex mutex;
Expand All @@ -70,11 +78,12 @@ class ResourceImporterTexture : public ResourceImporter {
static void _texture_reimport_roughness(const Ref<CompressedTexture2D> &p_tex, const String &p_normal_path, RenderingServer::TextureDetectRoughnessChannel p_channel);
static void _texture_reimport_3d(const Ref<CompressedTexture2D> &p_tex);
static void _texture_reimport_normal(const Ref<CompressedTexture2D> &p_tex);
static void _texture_reimport_height(const Ref<CompressedTexture2D> &p_tex);

static ResourceImporterTexture *singleton;
static const char *compression_formats[];

void _save_ctex(const Ref<Image> &p_image, const String &p_to_path, CompressMode p_compress_mode, float p_lossy_quality, Image::CompressMode p_vram_compression, bool p_mipmaps, bool p_streamable, bool p_detect_3d, bool p_detect_srgb, bool p_detect_normal, bool p_force_normal, bool p_srgb_friendly, bool p_force_po2_for_compressed, uint32_t p_limit_mipmap, const Ref<Image> &p_normal, Image::RoughnessChannel p_roughness_channel);
void _save_ctex(const Ref<Image> &p_image, const String &p_to_path, CompressMode p_compress_mode, float p_lossy_quality, Image::CompressMode p_vram_compression, bool p_mipmaps, bool p_streamable, bool p_detect_3d, bool p_detect_srgb, bool p_detect_normal, bool p_detect_height, bool p_force_normal, bool p_srgb_friendly, bool p_force_po2_for_compressed, uint32_t p_limit_mipmap, const Ref<Image> &p_normal, Image::RoughnessChannel p_roughness_channel);
void _save_editor_meta(const Dictionary &p_metadata, const String &p_to_path);
Dictionary _load_editor_meta(const String &p_to_path) const;

Expand Down
Loading

0 comments on commit 92d31a2

Please sign in to comment.