Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions project/addons/terrain_3d/tools/importer.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ func reset_settings() -> void:
height_file_name = ""
control_file_name = ""
color_file_name = ""
import_splat_maps = false
splat_map_paths.clear()
material_ids.clear()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks 👍

destination_directory = ""
import_position = Vector2i.ZERO
height_offset = 0.0
Expand Down Expand Up @@ -44,6 +47,16 @@ func update_heights() -> void:
@export_global_file var control_file_name: String = ""
## Any RGB or RGBA format is fine; PNG or Webp are recommended.
@export_global_file var color_file_name: String = ""

@export_subgroup("Advanced")
Comment thread
stan4dbunny marked this conversation as resolved.
## Enable to import splat maps.
@export var import_splat_maps: bool = false
## Should use RGBA format; PNG recommended. Must match size of heightmap. Max 8 splat maps supported.
@export_global_file var splat_map_paths: Array[String]
## Mark used channels with the corresponding index. Mark -1 for unused.
@export var material_ids: PackedInt32Array
@export_subgroup("")

## The top left (-X, -Y) corner position of where to place the imported data. Positions are descaled and ignore the vertex_spacing setting.
@export var import_position: Vector2i = Vector2i(0, 0) : set = set_import_position
## This scales the height of imported values.
Expand Down Expand Up @@ -92,6 +105,23 @@ func start_import() -> void:
material.show_colormap = true
var pos := Vector3(import_position.x * vertex_spacing, 0, import_position.y * vertex_spacing)
data.import_images(imported_images, pos, height_offset, import_scale)

if import_splat_maps:
if splat_map_paths.is_empty():
push_error("The list of splat maps is empty")
elif splat_map_paths.size() > 8:
push_error("There can be max 8 splat maps because max 32 textures can be represented")
else:
var splat_images : Array[Image]
var splat_tex_array : Texture2DArray = Texture2DArray.new()
for file : String in splat_map_paths:
var splat : Texture2D = ResourceLoader.load(file, "Texture2D", ResourceLoader.CACHE_MODE_IGNORE)
var splat_img : Image = splat.get_image()
if splat_img.is_compressed():
splat_img.decompress()
splat_images.append(splat_img)
data.set_splat_channel_to_material_list(material_ids, splat_images.size())
data.import_splat_map(splat_images, pos)
print("Terrain3DImporter: Import finished")


Expand Down
184 changes: 184 additions & 0 deletions src/terrain_3d_data.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,188 @@ Ref<Image> Terrain3DData::layered_to_image(const MapType p_map_type) const {
return img;
}

void Terrain3DData::set_splat_channel_to_material_list(const PackedInt32Array &p_array, const int &p_splat_count) {
LOG(INFO, "Setting splat channel to material list with ", p_array.size(), " entries");
if (!differs(_splat_channel_to_material_list, p_array)) {
return;
}
int max_size = Terrain3DAssets::MAX_TEXTURES;
int min_size = p_splat_count * 4;
int array_size = CLAMP(p_array.size(), min_size, max_size);
_splat_channel_to_material_list = p_array;
_splat_channel_to_material_list.resize(array_size);
for (int i = 0; i < array_size; i++) {
int id = _splat_channel_to_material_list[i];
if (id >= array_size || id < -1) {
_splat_channel_to_material_list[i] = -1;
}
}
}

int Terrain3DData::splat_channel_to_material(const int p_channel_idx) {
if (p_channel_idx < 0) {
return -1;
}
return _splat_channel_to_material_list[p_channel_idx];
}

Terrain3DData::ControlIds Terrain3DData::compute_control_ids_and_blend(const PackedFloat32Array &p_weights) {
int dom_channel_idx = -1;
int second_dom_channel_idx = -1;
float dom_channel = -1.f;
float second_dom_channel = -1.f;
float noise = 1.f / 255.f; // Max color value
ControlIds control_ids;
control_ids.base_id = -1;
control_ids.overlay_id = -1;
control_ids.blend = 0;

for (int i = 0; i < p_weights.size(); i++) {
if (p_weights[i] > dom_channel) {
second_dom_channel = dom_channel;
second_dom_channel_idx = dom_channel_idx;
dom_channel = p_weights[i];
dom_channel_idx = i;
} else if (p_weights[i] > second_dom_channel) {
second_dom_channel = p_weights[i];
second_dom_channel_idx = i;
}
}

if (dom_channel_idx == -1 || dom_channel <= noise) {
return control_ids;
}
int base_id = splat_channel_to_material(dom_channel_idx);
if (base_id == -1) {
return control_ids;
}

if (second_dom_channel_idx == -1 || second_dom_channel <= noise) {
control_ids.base_id = base_id;
control_ids.overlay_id = base_id;
return control_ids;
}
int overlay_id = splat_channel_to_material(second_dom_channel_idx);
float sum = dom_channel + second_dom_channel;
if (overlay_id == -1 || overlay_id == base_id || sum <= 0.f) {
control_ids.base_id = base_id;
control_ids.overlay_id = base_id;
return control_ids;
}

float percentage = CLAMP(second_dom_channel / sum, 0.f, 1.f);
control_ids.base_id = base_id;
control_ids.overlay_id = overlay_id;
control_ids.blend = (int)round(percentage * 255.f);
return control_ids;
}

void Terrain3DData::import_splat_map(const TypedArray<Image> &p_splat_images, const Vector3 &p_global_position) {
int splat_count = p_splat_images.size();
int channel_count = splat_count * 4;
Vector2i img_size = V2I_ZERO;
for (int i = 0; i < splat_count; i++) {
Ref<Image> img = p_splat_images[i];
if (img.is_valid() && !img->is_empty()) {
if (img_size == V2I_ZERO) {
img_size = img->get_size();
} else if (img_size != img->get_size()) {
LOG(ERROR, "Included Images in p_splat_images have different dimensions. Aborting import");
return;
}
}
}
if (img_size == V2I_ZERO) {
LOG(ERROR, "All images are empty. Nothing to import");
return;
}
Vector3 descaled_position = p_global_position / _vertex_spacing;
int max_dimension = _region_size * REGION_MAP_SIZE / 2;
if ((std::abs(descaled_position.x) > max_dimension) || (std::abs(descaled_position.z) > max_dimension)) {
LOG(ERROR, "Specify a position within +/-", Vector3(max_dimension, 0.f, max_dimension) * _vertex_spacing);
return;
}
if ((descaled_position.x + img_size.x > max_dimension) ||
(descaled_position.z + img_size.y > max_dimension)) {
LOG(ERROR, img_size, " image will not fit at ", p_global_position,
". Try ", -(img_size * _vertex_spacing) / 2.f, " to center");
return;
}
Ref<Image> img = p_splat_images[0];
PackedFloat32Array weights = PackedFloat32Array();
weights.resize(channel_count);
Ref<Image> temp = Image::create_empty(img->get_width(), img->get_height(), false, Image::FORMAT_RF);
for (int y = 0; y < temp->get_height(); y++) {
for (int x = 0; x < temp->get_width(); x++) {
int w_i = 0;
for (int i = 0; i < splat_count; i++) {
Ref<Image> splat = p_splat_images[i];
if (splat.is_null()) {
weights[w_i + 0] = 0.f;
weights[w_i + 1] = 0.f;
weights[w_i + 2] = 0.f;
weights[w_i + 3] = 0.f;
} else {
Color color = splat->get_pixel(x, y);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ERROR: Can't get_pixel() on compressed image, sorry. at: _get_color_at_ofs (core/io/image.cpp:3257) GDScript backtrace (most recent call first): [0] start_import (res://addons/terrain_3d/tools/importer.gd:120)

I get this error because the image is not decompressed.

Copy link
Copy Markdown
Contributor Author

@stan4dbunny stan4dbunny Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using VRAM compressed splats and changing the code in importer.gd to this. Does it work for you?

for file : String in splat_map_paths:
				var splat : Texture2D = ResourceLoader.load(file, "Texture2D", ResourceLoader.CACHE_MODE_IGNORE)
				var splat_img : Image = splat.get_image()
				if splat_img.is_compressed():
					splat_img.decompress()
				splat_images.append(splat_img)

weights[w_i + 0] = _splat_channel_to_material_list[w_i + 0] != -1 ? color.r : 0.f;
weights[w_i + 1] = _splat_channel_to_material_list[w_i + 1] != -1 ? color.g : 0.f;
weights[w_i + 2] = _splat_channel_to_material_list[w_i + 2] != -1 ? color.b : 0.f;
weights[w_i + 3] = _splat_channel_to_material_list[w_i + 3] != -1 ? color.a : 0.f;
}
w_i += 4;
}
ControlIds control_ids = compute_control_ids_and_blend(weights);
uint32_t bits = enc_base(control_ids.base_id) | enc_overlay(control_ids.overlay_id) | enc_blend(control_ids.blend);

// Write back to pixel in FORMAT_RF. Must be a 32-bit float
Color color = Color(as_float(bits), 0.f, 0.f, 1.f);
temp->set_pixel(x, y, color);
}
}
// Slice up incoming image into segments of region_size^2, and pad any remainder
int slices_width = ceil(real_t(img_size.x) / real_t(_region_size));
int slices_height = ceil(real_t(img_size.y) / real_t(_region_size));
slices_width = CLAMP(slices_width, 1, REGION_MAP_SIZE);
slices_height = CLAMP(slices_height, 1, REGION_MAP_SIZE);
LOG(DEBUG, "Creating ", Vector2i(slices_width, slices_height), " slices for ", img_size, " images.");
for (int y = 0; y < slices_height; y++) {
for (int x = 0; x < slices_width; x++) {
Vector2i start_coords = Vector2i(x * _region_size, y * _region_size);
Vector2i end_coords = Vector2i((x + 1) * _region_size - 1, (y + 1) * _region_size - 1);
LOG(DEBUG, "Reviewing image section ", start_coords, " to ", end_coords);

Vector2i size_to_copy;
if (end_coords.x <= img_size.x && end_coords.y <= img_size.y) {
size_to_copy = _region_sizev;
} else {
size_to_copy.x = img_size.x - start_coords.x;
size_to_copy.y = img_size.y - start_coords.y;
LOG(DEBUG, "Uneven end piece. Copying padded slice ", Vector2i(x, y), " size to copy: ", size_to_copy);
}
LOG(DEBUG, "Copying ", size_to_copy, " sized segment");
Vector3 global_position = Vector3(descaled_position.x + start_coords.x, 0.f, descaled_position.z + start_coords.y) * _vertex_spacing;
Vector2i region_loc = get_region_location(global_position);
Ref<Terrain3DRegion> region = get_region(region_loc);
if (region.is_null()) {
region.instantiate();
region->set_location(region_loc);
region->set_region_size(_region_size);
region->set_vertex_spacing(_vertex_spacing);
region->set_modified(true);
add_region(region, false);
}
Ref<Image> img_slice;
if (temp.is_valid() && !temp->is_empty()) {
img_slice = Util::get_filled_image(_region_sizev, COLOR_CONTROL, false, temp->get_format());
img_slice->blit_rect(temp, Rect2i(start_coords, size_to_copy), V2I_ZERO);
region->set_control_map(img_slice);
}
region->sanitize_maps();
} // for x < slices_width
} // for y < slices_height
update_maps(TYPE_CONTROL, true, false);
}

void Terrain3DData::dump(const bool verbose) const {
LOG(MESG, "_region_locations (", _region_locations.size(), "): ", _region_locations);
Array keys = _regions.keys();
Expand Down Expand Up @@ -1272,6 +1454,8 @@ void Terrain3DData::_bind_methods() {
ClassDB::bind_method(D_METHOD("import_images", "images", "global_position", "offset", "scale"), &Terrain3DData::import_images, DEFVAL(V3_ZERO), DEFVAL(0.f), DEFVAL(1.f));
ClassDB::bind_method(D_METHOD("export_image", "file_name", "map_type"), &Terrain3DData::export_image);
ClassDB::bind_method(D_METHOD("layered_to_image", "map_type"), &Terrain3DData::layered_to_image);
ClassDB::bind_method(D_METHOD("set_splat_channel_to_material_list", "array", "splat_count"), &Terrain3DData::set_splat_channel_to_material_list);
ClassDB::bind_method(D_METHOD("import_splat_map", "splat_images", "global_position"), &Terrain3DData::import_splat_map, DEFVAL(V3_ZERO));
ClassDB::bind_method(D_METHOD("dump", "verbose"), &Terrain3DData::dump, DEFVAL(false));

int ro_flags = PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY;
Expand Down
16 changes: 16 additions & 0 deletions src/terrain_3d_data.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ class Terrain3DData : public Object {
AABB _edited_area;
Vector2 _master_height_range = V2_ZERO;

// Import splat map
PackedInt32Array _splat_channel_to_material_list;

struct ControlIds {
int base_id;
int overlay_id;
int blend;
};

/////////
// Terrain3DRegions house the maps, instances, and other data for each region.
// Regions are dual indexed:
Expand Down Expand Up @@ -184,6 +193,13 @@ class Terrain3DData : public Object {
Error export_image(const String &p_file_name, const MapType p_map_type = TYPE_HEIGHT) const;
Ref<Image> layered_to_image(const MapType p_map_type) const;

// Import splat map
void set_splat_channel_to_material_list(const PackedInt32Array &p_array, const int &p_splat_count);
PackedInt32Array get_splat_channel_to_material_list() { return _splat_channel_to_material_list; };
int splat_channel_to_material(const int p_channel_idx);
ControlIds compute_control_ids_and_blend(const PackedFloat32Array &p_weights);
void import_splat_map(const TypedArray<Image> &p_splat_images, const Vector3 &p_global_position = V3_ZERO);

// Utility
void dump(const bool verbose = false) const;

Expand Down
25 changes: 25 additions & 0 deletions src/terrain_3d_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,30 @@ Ref<Image> Terrain3DUtil::load_image(const String &p_file_name, const int p_cach
return img;
}

Ref<Image> Terrain3DUtil::load_raw_image(const String &p_file_name, const int p_width, const int p_height) {
if (p_file_name.is_empty()) {
LOG(ERROR, "No file specified. Nothing imported");
return Ref<Image>();
}
if (!FileAccess::file_exists(p_file_name)) {
LOG(ERROR, "File ", p_file_name, " does not exist. Nothing to import");
return Ref<Image>();
}
Ref<FileAccess> file = FileAccess::open(p_file_name, FileAccess::READ);
int expected_length = p_width * p_height * 4;
int actual_length = file->get_length();
if (actual_length < expected_length) {
LOG(ERROR, "File ", p_file_name, " is smaller than expected");
file->close();
return Ref<Image>();
} else if (actual_length > expected_length) {
LOG(WARN, "File ", p_file_name, " is bigger than expected. Extra bytes will be ignored");
}
PackedByteArray data = file->get_buffer(expected_length);
file->close();
return Image::create_from_data(p_width, p_height, false, Image::FORMAT_RGBA8, data);
}

/* From source RGB and selected source for Alpha channel, create a new RGBA image.
* If p_invert_green is true, the destination green channel will be 1.0 - input green channel.
* If p_invert_alpha is true, the destination alpha channel will be 1.0 - input source channel.
Expand Down Expand Up @@ -554,6 +578,7 @@ void Terrain3DUtil::_bind_methods() {
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("get_thumbnail", "image", "size"), &Terrain3DUtil::get_thumbnail, DEFVAL(V2I(256)));
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("get_filled_image", "size", "color", "create_mipmaps", "format"), &Terrain3DUtil::get_filled_image);
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("load_image", "file_name", "cache_mode", "r16_height_range", "r16_size"), &Terrain3DUtil::load_image, DEFVAL(ResourceLoader::CACHE_MODE_IGNORE), DEFVAL(Vector2(0.f, 255.f)), DEFVAL(V2I_ZERO));
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("load_raw_image", "file_name", "width", "height"), &Terrain3DUtil::load_raw_image);
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("pack_image", "src_rgb", "src_a", "src_ao", "invert_green", "invert_alpha", "normalize_alpha", "alpha_channel", "ao_channel"), &Terrain3DUtil::pack_image, DEFVAL(false), DEFVAL(false), DEFVAL(false), DEFVAL(0), DEFVAL(0));
ClassDB::bind_static_method("Terrain3DUtil", D_METHOD("luminance_to_height", "src_rgb"), &Terrain3DUtil::luminance_to_height);
}
1 change: 1 addition & 0 deletions src/terrain_3d_util.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class Terrain3DUtil : public Object {
const Image::Format p_format = Image::FORMAT_MAX);
static Ref<Image> load_image(const String &p_file_name, const int p_cache_mode = ResourceLoader::CACHE_MODE_IGNORE,
const Vector2 &p_r16_height_range = Vector2(0.f, 255.f), const Vector2i &p_r16_size = V2I_ZERO);
static Ref<Image> load_raw_image(const String &p_file_name, const int p_width, const int p_height);
static Ref<Image> pack_image(const Ref<Image> &p_src_rgb,
const Ref<Image> &p_src_a,
const Ref<Image> &p_src_ao,
Expand Down
Loading