diff --git a/addons/godotvmf/entities/prop_static.gd b/addons/godotvmf/entities/prop_static.gd index 194fd31..9612171 100644 --- a/addons/godotvmf/entities/prop_static.gd +++ b/addons/godotvmf/entities/prop_static.gd @@ -12,13 +12,8 @@ var fade_max: float = 0.0: get: return entity.get('fademaxdist', 0.0) * VMFConfig.import.scale func _get_static_props_node() -> Node3D: - var geometry_node := get_vmfnode().geometry as MeshInstance3D; - if not geometry_node: - geometry_node = MeshInstance3D.new(); - geometry_node.name = "Geometry"; - get_vmfnode().add_child(geometry_node); - geometry_node.set_owner(get_vmfnode().owner); - + if not model_instance: return; + var geometry_node := get_vmfnode().get_geometry_node(); var static_props_node := geometry_node.get_node_or_null("StaticProps"); if not static_props_node: @@ -26,12 +21,11 @@ func _get_static_props_node() -> Node3D: static_props_node.name = "StaticProps"; geometry_node.add_child(static_props_node); static_props_node.set_owner(geometry_node.owner); - + return static_props_node; func assign_model_properties() -> void: model_instance.set_owner(get_owner()); - model_instance.scale *= model_scale; model_instance.gi_mode = GeometryInstance3D.GI_MODE_STATIC; var fade_margin = fade_max - fade_min; diff --git a/addons/godotvmf/entities/prop_studio.gd b/addons/godotvmf/entities/prop_studio.gd index 318a427..5870945 100644 --- a/addons/godotvmf/entities/prop_studio.gd +++ b/addons/godotvmf/entities/prop_studio.gd @@ -21,6 +21,7 @@ var skin: int = 0: func _entity_setup(_entity: VMFEntity) -> void: var model_path = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model); var model_scene: PackedScene = VMFCache.get_cached(model); + var skin_prefix: String = entity.get("skin_prefix", ""); if not model_scene: if not ResourceLoader.exists(model_path): @@ -47,5 +48,18 @@ func _entity_setup(_entity: VMFEntity) -> void: MDLCombiner.apply_skin(instance, skin); + if skin_prefix != "": + var surface_prefix: Array = skin_prefix.split(" "); + + for surface_idx in range(instance.mesh.get_surface_count()): + var prefix := surface_prefix[surface_idx % surface_prefix.size()] as String; + if prefix == "": continue; + + var material_path: String = instance.get_active_material(surface_idx).resource_path.replace(config.materials.target_folder + "/", "").get_basename(); + var new_material_path: String = material_path + "_" + prefix; + + instance.set_surface_override_material(surface_idx, VMTLoader.get_material(new_material_path)); + + add_child(instance); model_instance.set_owner(get_owner()); diff --git a/addons/godotvmf/src/structs/vmf_displacement_info.gd b/addons/godotvmf/src/structs/vmf_displacement_info.gd index 8bf5ca2..01151d0 100644 --- a/addons/godotvmf/src/structs/vmf_displacement_info.gd +++ b/addons/godotvmf/src/structs/vmf_displacement_info.gd @@ -48,6 +48,9 @@ func get_normal(x: float, y: float) -> Vector3: if normals.size() == 0: return Vector3.ZERO; + if index > normals.size() - 1: + return Vector3.ZERO; + return normals[index]; func get_offset(x: float, y: float) -> Vector3: diff --git a/addons/godotvmf/src/vmf_config.gd b/addons/godotvmf/src/vmf_config.gd index 4c174f0..d68dcb7 100644 --- a/addons/godotvmf/src/vmf_config.gd +++ b/addons/godotvmf/src/vmf_config.gd @@ -4,11 +4,17 @@ class_name VMFConfig extends RefCounted const CONFIG_FILE_PATH = "res://vmf.config.json"; const SETTINGS_TO_LOAD = { - "godot_vmf/import/gameinfo_path": { + "godot_vmf/import/paths/gameinfo_path": { "default_value": "res://", "type": TYPE_STRING, "hint": PROPERTY_HINT_GLOBAL_DIR, }, + "godot_vmf/import/paths/additional_import_paths": { + "default_value": [], + "type": TYPE_ARRAY, + "hint": PROPERTY_HINT_ARRAY_TYPE, + "hint_string": "%d/%d:" % [TYPE_STRING, PROPERTY_HINT_GLOBAL_DIR], + }, "godot_vmf/models/import": { "default_value": false, "type": TYPE_BOOL, @@ -53,78 +59,97 @@ const SETTINGS_TO_LOAD = { "type": TYPE_INT, "hint": PROPERTY_HINT_NONE, }, - "godot_vmf/import/scale": { + "godot_vmf/import/general/scale": { "default_value": 0.02, "type": TYPE_FLOAT, "hint": PROPERTY_HINT_RANGE, "hint_string": "0,100.0,0.000001", }, - "godot_vmf/import/generate_lightmap_uv2": { + "godot_vmf/import/general/generate_lightmap_uv2": { "default_value": true, "type": TYPE_BOOL, "hint": PROPERTY_HINT_NONE, }, - "godot_vmf/import/generate_collision": { + "godot_vmf/import/general/generate_collision": { "default_value": true, "type": TYPE_BOOL, "hint": PROPERTY_HINT_NONE, }, - "godot_vmf/import/generate_navigation_mesh": { + "godot_vmf/import/navmesh/enabled": { "default_value": false, "type": TYPE_BOOL, "hint": PROPERTY_HINT_NONE, }, - "godot_vmf/import/navigation_mesh_preset": { + "godot_vmf/import/navmesh/preset": { "default_value": "", "type": TYPE_STRING, "hint": PROPERTY_HINT_RESOURCE_TYPE, "hint_string": "NavigationMesh", }, - "godot_vmf/import/lightmap_texel_size": { + "godot_vmf/import/general/lightmap_texel_size": { "default_value": 0.2, "type": TYPE_FLOAT, "hint": PROPERTY_HINT_RANGE, "hint_string": "0,100.0,0.000001", }, - "godot_vmf/import/instances_folder": { + "godot_vmf/import/folders/instances": { "default_value": "res://instances", "type": TYPE_STRING, "hint": PROPERTY_HINT_DIR, }, - "godot_vmf/import/entities_folder": { + "godot_vmf/import/folders/entities": { "default_value": "res://entities", "type": TYPE_STRING, "hint": PROPERTY_HINT_DIR, }, - "godot_vmf/import/geometry_folder": { + "godot_vmf/import/folders/geometry": { "default_value": "res://geometry", "type": TYPE_STRING, "hint": PROPERTY_HINT_DIR, }, - "godot_vmf/import/entity_aliases": { + "godot_vmf/import/general/entity_aliases": { "default_value": {}, "type": TYPE_DICTIONARY, "hint": PROPERTY_HINT_DICTIONARY_TYPE, "hint_string": "%d:;%d/%d:*.tscn" % [TYPE_STRING, TYPE_STRING, PROPERTY_HINT_FILE], }, - "godot_vmf/import/detail_props_chunk_size": { + "godot_vmf/import/detail_props/chunk_size": { "default_value": 32.0, "type": TYPE_FLOAT, "hint": PROPERTY_HINT_RANGE, "hint_string": "0,1000.0,0.000001", }, - "godot_vmf/import/detail_props_draw_distance": { + "godot_vmf/import/detail_props/draw_distance": { "default_value": 100.0, "type": TYPE_FLOAT, "hint": PROPERTY_HINT_RANGE, "hint_string": "0,1000.0,0.000001", }, - "godot_vmf/import/additional_import_paths": { - "default_value": [], - "type": TYPE_ARRAY, - "hint": PROPERTY_HINT_ARRAY_TYPE, - "hint_string": "%d/%d:" % [TYPE_STRING, PROPERTY_HINT_GLOBAL_DIR], - } + + "godot_vmf/import/chunked_mesh/default_chunk_size": { + "default_value": 256.0, + "description": "The size of the chunks that the level geometry will be split into. This is only used if chunked mesh feature is enabled.", + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,1024.0,0.01", + }, +}; + +const SETTINGS_TO_REMAP = { + "godot_vmf/import/gameinfo_path": "godot_vmf/import/paths/gameinfo_path", + "godot_vmf/import/instances_folder": "godot_vmf/import/folders/instances", + "godot_vmf/import/entities_folder": "godot_vmf/import/folders/entities", + "godot_vmf/import/geometry_folder": "godot_vmf/import/folders/geometry", + "godot_vmf/import/navigation_mesh_preset": "godot_vmf/import/navmesh/preset", + "godot_vmf/import/detail_props_chunk_size": "godot_vmf/import/detail_props/chunk_size", + "godot_vmf/import/detail_props_draw_distance": "godot_vmf/import/detail_props/draw_distance", + "godot_vmf/import/chunked_mesh/chunk_size": "godot_vmf/import/chunked_mesh/default_chunk_size", + "godot_vmf/import/chunked_mesh/enabled": "", ## Will be removed during migration + "godot_vmf/import/scale": "godot_vmf/import/general/scale", + "godot_vmf/import/generate_lightmap_uv2": "godot_vmf/import/general/generate_lightmap_uv2", + "godot_vmf/import/generate_collision": "godot_vmf/import/general/generate_collision", + "godot_vmf/import/lightmap_texel_size": "godot_vmf/import/general/lightmap_texel_size", + "godot_vmf/import/entity_aliases": "godot_vmf/import/general/entity_aliases", }; class ModelsConfig: @@ -166,55 +191,56 @@ class MaterialsConfig: class ImportConfig: ## Target scale that will be applied to all imported models and maps - var scale: float = ProjectSettings.get_setting("godot_vmf/import/scale", 0.02); + var scale: float = ProjectSettings.get_setting("godot_vmf/import/general/scale", 0.02); ## If true, the importer will generate collision meshes for the level geometry - var generate_collision: bool = ProjectSettings.get_setting("godot_vmf/import/generate_collision", true); + var generate_collision: bool = ProjectSettings.get_setting("godot_vmf/import/general/generate_collision", true); ## If true, the importer will generate lightmap UV2 for the level geometry - var generate_lightmap_uv2: bool = ProjectSettings.get_setting("godot_vmf/import/generate_lightmap_uv2", true); + var generate_lightmap_uv2: bool = ProjectSettings.get_setting("godot_vmf/import/general/generate_lightmap_uv2", true); ## Texel size for lightmap - var lightmap_texel_size: float = ProjectSettings.get_setting("godot_vmf/import/lightmap_texel_size", 0.2); + var lightmap_texel_size: float = ProjectSettings.get_setting("godot_vmf/import/general/lightmap_texel_size", 0.2); ## Lightmap texture size var lightmap_size: int = ProjectSettings.get_setting("godot_vmf/import/lightmap_size", 1024); ## The path where imported instances be saved - var instances_folder: String = ProjectSettings.get_setting("godot_vmf/import/instances_folder", "res://instances"); + var instances_folder: String = ProjectSettings.get_setting("godot_vmf/import/folders/instances", "res://instances"); - ## The path from importer will grab entities - var entities_folder: String = ProjectSettings.get_setting("godot_vmf/import/entities_folder", "res://entities"); + ## The path the importer will grab entities from during import + var entities_folder: String = ProjectSettings.get_setting("godot_vmf/import/folders/entities", "res://entities"); ## The path where imported geometry and collision be saved - var geometry_folder: String = ProjectSettings.get_setting("godot_vmf/import/geometry_folder", "res://geometry"); + var geometry_folder: String = ProjectSettings.get_setting("godot_vmf/import/folders/geometry", "res://geometry"); ## A dictionary of entity aliases where key is an entity name and value is a path to the scene - var entity_aliases: Dictionary = ProjectSettings.get_setting("godot_vmf/import/entity_aliases", {}): + var entity_aliases: Dictionary = ProjectSettings.get_setting("godot_vmf/import/general/entity_aliases", {}): set(value): ProjectSettings.set_setting("godot_vmf/entity_aliases", value); ## If true, the importer will generate a navigation mesh for the level geometry - var use_navigation_mesh: bool = ProjectSettings.get_setting("godot_vmf/import/generate_navigation_mesh", false); + var use_navigation_mesh: bool = ProjectSettings.get_setting("godot_vmf/import/navmesh/enabled", false); ## If specified, the importer will use this preset for the navigation mesh - var navigation_mesh_preset: String = ProjectSettings.get_setting("godot_vmf/import/navigation_mesh_preset", ""); + var navigation_mesh_preset: String = ProjectSettings.get_setting("godot_vmf/import/navmesh/preset", ""); ## Detail props are meshes instanced across the level geometry based on material metadata. ## This field defines a size of one chunk of detail props. The bigger the chunk size, the less multimesh instances will be created, ## but the more detail props will be in one chunk, which can lead to worse performance. - var detail_props_chunk_size: float = ProjectSettings.get_setting("godot_vmf/import/detail_props_chunk_size", 32.0); + var detail_props_chunk_size: float = ProjectSettings.get_setting("godot_vmf/import/detail_props/chunk_size", 32.0); ## The maximum distance at which detail props will be visible. This is important for performance, as rendering too many detail props at long distances can be costly. - var detail_props_draw_distance: float = ProjectSettings.get_setting("godot_vmf/import/detail_props_draw_distance", 100.0); - - var gameinfo_path: String = ProjectSettings.get_setting("godot_vmf/import/gameinfo_path", "res://"); + var detail_props_draw_distance: float = ProjectSettings.get_setting("godot_vmf/import/detail_props/draw_distance", 100.0); + var gameinfo_path: String = ProjectSettings.get_setting("godot_vmf/import/paths/gameinfo_path", "res://") var additional_import_paths: Array = ProjectSettings.get_setting("godot_vmf/import/additional_import_paths", []); + var chunked_mesh_default_size: float = ProjectSettings.get_setting("godot_vmf/import/chunked_mesh/default_chunk_size", 256.0); + ## NOTE: Support previous version of this config where this field wasn't a part of ImportConfig static var gameinfo_path: String: - get: return ProjectSettings.get_setting("godot_vmf/import/gameinfo_path", "res://"); + get: return import.gameinfo_path static var models: ModelsConfig: get: @@ -257,8 +283,6 @@ static func is_dictionary_equal(a: Dictionary, b: Dictionary): return true; static func update_config_field(): - gameinfo_path = ProjectSettings.get_setting("godot_vmf/import/gameinfo_path", "res://"); - import = ImportConfig.new(); models = ModelsConfig.new(); materials = MaterialsConfig.new(); @@ -283,6 +307,8 @@ static func define_project_settings(): static func load_config(): if not Engine.is_editor_hint(): return; + remap_old_settings(); + if not FileAccess.file_exists(CONFIG_FILE_PATH): return; var file = FileAccess.open(CONFIG_FILE_PATH, FileAccess.READ); var json = JSON.parse_string(file.get_as_text()); @@ -290,3 +316,15 @@ static func load_config(): assign(VMFConfig, json); update_config_field(); + +## Remap old settings to new settings if they exist in the project settings +static func remap_old_settings(): + for old_setting in SETTINGS_TO_REMAP: + if ProjectSettings.has_setting(old_setting): + var new_setting = SETTINGS_TO_REMAP[old_setting]; + if new_setting == "": + ProjectSettings.set_setting(old_setting, null); + continue; + var value = ProjectSettings.get_setting(old_setting); + ProjectSettings.set_setting(new_setting, value); + ProjectSettings.set_setting(old_setting, null); diff --git a/addons/godotvmf/src/vmf_detail_props.gd b/addons/godotvmf/src/vmf_detail_props.gd index 9839cde..d0be655 100644 --- a/addons/godotvmf/src/vmf_detail_props.gd +++ b/addons/godotvmf/src/vmf_detail_props.gd @@ -6,10 +6,7 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var inv_chunk_size := 1.0 / chunk_size var result: Array[MultiMesh] = [] - - # Pre-compute constant basis tilt (rotation around X by -PI/2) - var tilt_basis := Basis.IDENTITY.rotated(Vector3.RIGHT, -PI / 2) - var rotation_to_rad := PI / 180.0 + var mesh_cache: Dictionary = {} for surface_idx in range(original_mesh.get_surface_count()): var material := original_mesh.surface_get_material(surface_idx) @@ -29,7 +26,22 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var has_colors := not colors.is_empty() var num_triangles := indices.size() / 3 + # Pre-compute triangle areas once per surface, shared across both prop passes + var tri_areas := PackedFloat32Array() + tri_areas.resize(num_triangles) + for i in range(num_triangles): + var i1 := indices[i * 3] + var i2 := indices[i * 3 + 1] + var i3 := indices[i * 3 + 2] + var edge1 := vertices[i2] - vertices[i1] + var edge2 := vertices[i3] - vertices[i1] + tri_areas[i] = 0.5 * edge1.cross(edge2).length() + for prop_index in range(2): + # Without vertex colors, prop 1 always yields alpha = 0 — nothing to place + if not has_colors and prop_index == 1: + continue + var index_postfix := "" if not prop_index else str(prop_index + 1) var detail_prop_path := material_details.get("$detailprop" + index_postfix, "") as String @@ -41,18 +53,27 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var rotation_range: Vector2 = material_details.get("$detailproprotation" + index_postfix, Vector2(0.0, 360.0)) as Vector2 var cast_shadows: bool = material_details.get("$detailpropshadows" + index_postfix, 1) == 1 - if not ResourceLoader.exists(detail_prop_path): - VMFLogger.warn("Detail prop scene not found: " + detail_prop_path) - continue - - var prop_mesh = ResourceLoader.load(detail_prop_path) - if not prop_mesh: - VMFLogger.error("Failed to load detail prop in %s" % material.resource_path) - continue - - if prop_mesh is not Mesh: - VMFLogger.error("The specified detail prop in %s is not Mesh" % material.resource_path) - continue + var prop_mesh: Mesh + if mesh_cache.has(detail_prop_path): + prop_mesh = mesh_cache[detail_prop_path] + if prop_mesh == null: + continue + else: + if not ResourceLoader.exists(detail_prop_path): + VMFLogger.warn("Detail prop scene not found: " + detail_prop_path) + mesh_cache[detail_prop_path] = null + continue + var loaded = ResourceLoader.load(detail_prop_path) + if not loaded: + VMFLogger.error("Failed to load detail prop in %s" % material.resource_path) + mesh_cache[detail_prop_path] = null + continue + if loaded is not Mesh: + VMFLogger.error("The specified detail prop in %s is not Mesh" % material.resource_path) + mesh_cache[detail_prop_path] = null + continue + prop_mesh = loaded as Mesh + mesh_cache[detail_prop_path] = prop_mesh # Pass 1: Count instances per triangle to pre-allocate buffers var tri_counts := PackedInt32Array() @@ -60,24 +81,24 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var total_estimate := 0 for i in range(num_triangles): - var i1 := indices[i * 3] - var i2 := indices[i * 3 + 1] - var i3 := indices[i * 3 + 2] - var edge1 := vertices[i2] - vertices[i1] - var edge2 := vertices[i3] - vertices[i1] - var area := 0.5 * edge1.cross(edge2).length() - var count := int(round(area * density)) + var count := int(round(tri_areas[i] * density)) tri_counts[i] = count total_estimate += count if total_estimate == 0: continue - # Pass 2: Generate points into a flat pre-allocated buffer + # Precompute random ranges outside the hot loop + var scale_lo := scale_range.x + var scale_delta := scale_range.y - scale_range.x + var rot_lo := rotation_range.x * (PI / 180.0) + var rot_delta := (rotation_range.y - rotation_range.x) * (PI / 180.0) + + # Pass 2: Generate points and group into chunks simultaneously. + # Uses Array (reference type) for chunk buckets to ensure append() mutates in place. var point_data := PackedFloat32Array() point_data.resize(total_estimate * 7) - var chunk_indices := PackedInt32Array() - chunk_indices.resize(total_estimate * 3) + var chunk_map: Dictionary = {} var actual_count := 0 for i in range(num_triangles): @@ -89,23 +110,13 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var i2 := indices[i * 3 + 1] var i3 := indices[i * 3 + 2] - var v1 := vertices[i1] - var v2 := vertices[i2] - var v3 := vertices[i3] - var n1 := normals[i1] - var n2 := normals[i2] - var n3 := normals[i3] - - var c1r := 1.0 - var c2r := 1.0 - var c3r := 1.0 + var v1 := vertices[i1]; var v2 := vertices[i2]; var v3 := vertices[i3] + var n1 := normals[i1]; var n2 := normals[i2]; var n3 := normals[i3] + var c1r := 1.0; var c2r := 1.0; var c3r := 1.0 if has_colors: - c1r = colors[i1].r - c2r = colors[i2].r - c3r = colors[i3].r + c1r = colors[i1].r; c2r = colors[i2].r; c3r = colors[i3].r - # Cache component values to avoid repeated property access var v1x := v1.x; var v1y := v1.y; var v1z := v1.z var v2x := v2.x; var v2y := v2.y; var v2z := v2.z var v3x := v3.x; var v3y := v3.y; var v3z := v3.z @@ -122,7 +133,7 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var bu := sqrt_r1 * (1.0 - r2) var bv := sqrt_r1 * r2 - var alpha := absf(prop_index - (c1r * bw + c2r * bu + c3r * bv)) + var alpha := absf(float(prop_index) - (c1r * bw + c2r * bu + c3r * bv)) if alpha <= 0.01: continue @@ -141,28 +152,23 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: point_data[base + 3] = nx; point_data[base + 4] = ny; point_data[base + 5] = nz point_data[base + 6] = alpha - var ci := actual_count * 3 - chunk_indices[ci] = int(floor(px * inv_chunk_size)) - chunk_indices[ci + 1] = int(floor(py * inv_chunk_size)) - chunk_indices[ci + 2] = int(floor(pz * inv_chunk_size)) + var chunk_key := Vector3i( + int(floor(px * inv_chunk_size)), + int(floor(py * inv_chunk_size)), + int(floor(pz * inv_chunk_size)) + ) + if not chunk_map.has(chunk_key): + chunk_map[chunk_key] = [] + chunk_map[chunk_key].append(actual_count) actual_count += 1 if actual_count == 0: continue - # Pass 3: Group instance indices by chunk key - var chunk_map: Dictionary = {} - for k in range(actual_count): - var ci := k * 3 - var chunk_key := Vector3i(chunk_indices[ci], chunk_indices[ci + 1], chunk_indices[ci + 2]) - if not chunk_map.has(chunk_key): - chunk_map[chunk_key] = PackedInt32Array() - chunk_map[chunk_key].append(k) - - # Pass 4: Build MultiMesh transforms per chunk - for instance_ids: PackedInt32Array in chunk_map.values(): - var instance_count := instance_ids.size() + # Pass 3: Build MultiMesh transforms per chunk + for instance_ids in chunk_map.values(): + var instance_count: int = instance_ids.size() var mmi := MultiMesh.new() mmi.transform_format = MultiMesh.TRANSFORM_3D mmi.mesh = prop_mesh @@ -174,22 +180,52 @@ static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: var ti := 0 for k in range(instance_count): - var base := instance_ids[k] * 7 - var point := Vector3(point_data[base], point_data[base + 1], point_data[base + 2]) - var normal := Vector3(point_data[base + 3], point_data[base + 4], point_data[base + 5]) + var pt_idx: int = instance_ids[k] + var base := pt_idx * 7 + var px: float = point_data[base] + var py: float = point_data[base + 1] + var pz: float = point_data[base + 2] + var nx: float = point_data[base + 3] + var ny: float = point_data[base + 4] + var nz: float = point_data[base + 5] var alpha: float = point_data[base + 6] - var scale := randf_range(scale_range.x, scale_range.y) * alpha - var rotation := randf_range(rotation_range.x, rotation_range.y) * rotation_to_rad - - var basis := Basis.IDENTITY.rotated(normal, rotation) \ - * Basis.looking_at(normal, Vector3.UP) \ - * tilt_basis \ - .scaled(Vector3.ONE * scale) - - transforms[ti] = basis.x.x; transforms[ti + 1] = basis.y.x; transforms[ti + 2] = basis.z.x; transforms[ti + 3] = point.x - transforms[ti + 4] = basis.x.y; transforms[ti + 5] = basis.y.y; transforms[ti + 6] = basis.z.y; transforms[ti + 7] = point.y - transforms[ti + 8] = basis.x.z; transforms[ti + 9] = basis.y.z; transforms[ti + 10] = basis.z.z; transforms[ti + 11] = point.z + var scale := (scale_lo + randf() * scale_delta) * alpha + var rotation := rot_lo + randf() * rot_delta + + # Build surface-aligned basis directly (Y-axis = normal) with yaw rotation. + # Avoids creating Basis objects; equivalent to: + # Basis.IDENTITY.rotated(normal, rotation) * Basis.looking_at(normal, UP) * tilt_basis * scale + # tangent = UP.cross(-normal) = (-nz, 0, nx), normalized + var lsq := nx * nx + nz * nz + var tx: float; var ty: float; var tz: float + var bx: float; var by: float; var bz: float + if lsq > 1e-6: + var L := sqrt(lsq) + var linv := 1.0 / L + tx = -nz * linv; ty = 0.0; tz = nx * linv + # bitangent = (-normal).cross(tangent) + bx = -ny * nx * linv; by = L; bz = -ny * nz * linv + else: + # Nearly vertical normal — use RIGHT.cross(-normal) = (0, nz, -ny) + var linv2 := 1.0 / sqrt(nz * nz + ny * ny) + tx = 0.0; ty = nz * linv2; tz = -ny * linv2 + bx = 1.0; by = 0.0; bz = 0.0 + + # Rotate tangent/bitangent around normal (Rodrigues, perpendicular case) + var cos_r := cos(rotation) + var sin_r := sin(rotation) + var xax := (tx * cos_r - bx * sin_r) * scale + var xay := (ty * cos_r - by * sin_r) * scale + var xaz := (tz * cos_r - bz * sin_r) * scale + var zax := (bx * cos_r + tx * sin_r) * scale + var zay := (by * cos_r + ty * sin_r) * scale + var zaz := (bz * cos_r + tz * sin_r) * scale + var yax := nx * scale; var yay := ny * scale; var yaz := nz * scale + + transforms[ti] = xax; transforms[ti + 1] = yax; transforms[ti + 2] = zax; transforms[ti + 3] = px + transforms[ti + 4] = xay; transforms[ti + 5] = yay; transforms[ti + 6] = zay; transforms[ti + 7] = py + transforms[ti + 8] = xaz; transforms[ti + 9] = yaz; transforms[ti + 10] = zaz; transforms[ti + 11] = pz ti += 12 mmi.buffer = transforms diff --git a/addons/godotvmf/src/vmf_entity_node.gd b/addons/godotvmf/src/vmf_entity_node.gd index 35949bb..4719704 100644 --- a/addons/godotvmf/src/vmf_entity_node.gd +++ b/addons/godotvmf/src/vmf_entity_node.gd @@ -43,8 +43,9 @@ func Disable(_param = null): enabled = false; func Kill(_param = null): - if entity.get("targetname", "") in VMFEntityNode.named_entities: - named_entities.erase(entity.targetname); + var targetname := entity.get("targetname", ""); + if targetname in VMFEntityNode.named_entities: + named_entities.erase(targetname); get_parent().remove_child(self); queue_free(); @@ -137,7 +138,7 @@ func get_target(n: String) -> Node3D: if node == null: return null; if not is_instance_valid(node): - VMFEntityNode.named_entities[n].erase(node); + VMFEntityNode.named_entities.get(GROUP_PREFIX + n, []).erase(node); return get_target(n); return node; @@ -153,7 +154,7 @@ func get_all_targets(target_name: String) -> Array: return VMFEntityNode.named_entities.get(group_name, []); func parse_connections() -> void: - if not "entity" in self or not "connections" in entity: return; + if not ("connections" in entity): return; var outputs = entity.connections.keys(); @@ -177,7 +178,7 @@ func parse_connections() -> void: connect(output, func(): call_target_input(target, input, param, delay, self)); ## Returns the VMFNode where the entity placed -func get_vmfnode(): +func get_vmfnode() -> VMFNode: var p = get_parent(); while p: @@ -269,12 +270,7 @@ func get_entity_convex_shape() -> ConvexPolygonShape3D: if not has_solid: return null; var solids = entity.solid if entity.solid is Array else [entity.solid]; - var struct := VMFStructure.new({ - 'world': { - 'solid': solids, - }, - }); - + var struct := VMFStructure.new({ 'world': { 'solid': solids } }); var mesh = VMFTool.create_mesh(struct, global_position); if (not mesh or mesh.get_surface_count() == 0): return; @@ -288,9 +284,9 @@ func get_entity_trimesh_shape() -> ConcavePolygonShape3D: var combiner = CSGCombiner3D.new(); for solid in solids: - var struct = VMFStructure.new({ 'world': { 'solid': [solid] } }); - var csgmesh = CSGMesh3D.new(); - var mesh = VMFTool.create_mesh(struct, global_position); + var struct := VMFStructure.new({ 'world': { 'solid': [solid] } }); + var csgmesh := CSGMesh3D.new(); + var mesh := VMFTool.create_mesh(struct, global_position); if not mesh or mesh.get_surface_count() == 0: continue; @@ -310,8 +306,8 @@ func get_separated_collisions() -> Array[CollisionShape3D]: var solids = entity.solid if entity.solid is Array else [entity.solid]; for solid in solids: - var struct = { 'world': { 'solid': [solid] } }; - var mesh = VMFTool.create_mesh(struct, global_position); + var struct := VMFStructure.new({ 'world': { 'solid': [solid] } }); + var mesh := VMFTool.create_mesh(struct, global_position); var collision_shape := CollisionShape3D.new(); collision_shape.shape = mesh.create_convex_shape(true); collisions.append(collision_shape); diff --git a/addons/godotvmf/src/vmf_instance_manager.gd b/addons/godotvmf/src/vmf_instance_manager.gd index 146ce12..33d3cfb 100644 --- a/addons/godotvmf/src/vmf_instance_manager.gd +++ b/addons/godotvmf/src/vmf_instance_manager.gd @@ -93,7 +93,7 @@ static func import_instance(entity: Dictionary): node.name = instance_name + '_instance'; node.save_geometry = false; node.save_collision = false; - node.import_map(); + await node.import_map(); scn.pack(node); diff --git a/addons/godotvmf/src/vmf_node.gd b/addons/godotvmf/src/vmf_node.gd index 0bfcb93..8905e36 100644 --- a/addons/godotvmf/src/vmf_node.gd +++ b/addons/godotvmf/src/vmf_node.gd @@ -2,13 +2,6 @@ @icon("res://addons/godotvmf/icon.svg") class_name VMFNode extends Node3D; -enum MaterialImportMode { - USE_EXISTING, - IMPORT_FROM_MOD_FOLDER, -} - -@export_category("VMF File") - ## Allow the file picker to select an external file @export var use_external_file: bool = false: set(val): @@ -19,21 +12,23 @@ enum MaterialImportMode { @export_file("*.vmf") var vmf: String = ''; -@export_category("Import") - ## Full import of VMF with specified options -@export var import: bool = false: - set(value): - if not value: return; - import_map(); - import = false; +@export_tool_button("Import map", "Callable") var import_button = import_map; +@export_category("Tweaks") + +## Enable double sided shadow casting for the geometry mesh. This will make the shadows casted by the geometry to be visible from both sides of the faces. @export var double_sided_shadow_cast: bool = false; +## Enable chunked mesh generation. This will split the geometry into smaller chunks for better performance. Increases the import time. +@export var chunked_mesh: bool = true; + +## Size of one chunk. This will affect the number of chunks generated and the performance of the import. +@export_range(32.0, 1024.0, 0.01) var chunked_mesh_size: float = 256.0; + @export_category("Resource Generation") -## During import the importer will remove all invisible faces from the mesh. -## Increases the import time +## If true, removes merged faces from the geometry mesh. This will reduce the number of faces in the mesh and improve performance. Increases the import time @export var remove_merged_faces: bool = true; ## Save the resulting geometry mesh as a resource (saves to the "Geometry folder" in Project Settings) @@ -56,13 +51,8 @@ var _owner: return o; -var geometry: Node3D: - get: - var node = get_node_or_null("Geometry"); - node = get_node_or_null(NodePath("NavigationMesh/Geometry")) \ - if node == null else node; - - return node; +@export_storage var navmesh: NavigationRegion3D; +@export_storage var geometry: Node3D; var detail_props: Node3D: get: return geometry.get_node_or_null("DetailProps") if geometry else null; @@ -70,11 +60,11 @@ var detail_props: Node3D: var entities: Node3D: get: return get_node_or_null("Entities"); -var navmesh: NavigationRegion3D: - get: return get_node_or_null("NavigationMesh"); - var has_imported_resources = false; +func is_instance() -> bool: + return get_meta("instance", false); + func _validate_property(property: Dictionary) -> void: if property.name == "vmf": property.hint = PROPERTY_HINT_GLOBAL_FILE if use_external_file else PROPERTY_HINT_FILE @@ -82,11 +72,34 @@ func _validate_property(property: Dictionary) -> void: func _ready() -> void: add_to_group("vmfnode_group"); + storage_navmesh_node(); + storage_geometry_node(); + +func storage_geometry_node(): + geometry = get_node_or_null("Geometry") if not navmesh else navmesh.get_node_or_null("Geometry") + +func storage_navmesh_node(): + navmesh = get_node_or_null("NavigationMesh"); + +func get_geometry_node(clazz := MeshInstance3D) -> Node3D: + if geometry: return geometry; + + geometry = clazz.new(); + geometry.name = "Geometry"; + if not navmesh: + add_child(geometry); + else: + navmesh.add_child(geometry); + + geometry.set_owner(_owner); + geometry.set_display_folded(true); + return geometry; + func clear_scene_groups(): var tree = get_tree(); - var groups := get_tree().edited_scene_root.get_groups() if tree else self.get_groups(); + var groups: Array[StringName] = tree.edited_scene_root.get_groups() if tree else self.get_groups(); for group in groups: - var nodes := get_tree().get_nodes_in_group(group); + var nodes := tree.get_nodes_in_group(group); for node in nodes: node.remove_from_group(group); @@ -102,6 +115,8 @@ func reimport_geometry() -> void: import_geometry(); + if Engine.is_editor_hint(): + EditorInterface.mark_scene_as_unsaved(); func generate_detail_props(geometry_mesh: MeshInstance3D) -> void: var detail_props := VMFDetailProps.generate(geometry_mesh.mesh); if detail_props.size() == 0: return; @@ -123,6 +138,7 @@ func generate_detail_props(geometry_mesh: MeshInstance3D) -> void: mmi.visibility_range_end = VMFConfig.import.detail_props_draw_distance; mmi.visibility_range_end_margin = VMFConfig.import.detail_props_draw_distance / 2.0; mmi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF; + mmi.gi_mode = GeometryInstance3D.GI_MODE_DYNAMIC; detail_node.add_child(mmi); mmi.set_owner(_owner); @@ -136,8 +152,13 @@ func reimport_detail_props(): generate_detail_props(geometry_mesh); + if Engine.is_editor_hint(): + EditorInterface.mark_scene_as_unsaved(); + func generate_shadow_mesh(raw_geometry_mesh: ArrayMesh) -> void: var shadow_mesh := VMFTool.generate_shadow_mesh(raw_geometry_mesh); + if not shadow_mesh: return; + var shadow_mesh_instance := MeshInstance3D.new(); shadow_mesh_instance.name = "ShadowMesh"; shadow_mesh_instance.mesh = shadow_mesh; @@ -150,81 +171,88 @@ func unwrap_lightmap(geometry_mesh: MeshInstance3D) -> void: if VMFConfig.import.generate_lightmap_uv2 and not is_runtime: geometry_mesh.mesh.lightmap_unwrap(geometry_mesh.global_transform, texel_size); -func import_geometry() -> void: - var _t := Time.get_ticks_msec(); - - if navmesh: navmesh.free(); - if geometry: geometry.free(); - - var mesh: ArrayMesh = VMFTool.create_mesh(vmf_structure, Vector3.ZERO, remove_merged_faces); - if not mesh: return; - - var geometry_mesh := MeshInstance3D.new() - geometry_mesh.name = "Geometry"; - geometry_mesh.set_display_folded(true); - +func process_geometry(geometry_mesh: MeshInstance3D) -> void: if double_sided_shadow_cast: geometry_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_DOUBLE_SIDED; - - add_child(geometry_mesh); - geometry_mesh.set_owner(_owner); - - var transform = geometry_mesh.global_transform; - geometry_mesh.mesh = mesh; if VMFConfig.import.generate_collision: VMFTool.generate_collisions(geometry_mesh, default_physics_mask); - save_collision_file(); - - if not get_meta("instance", false): - generate_navmesh(geometry_mesh); + save_collision_file(geometry_mesh, geometry_mesh.name); - generate_shadow_mesh(geometry_mesh.mesh); cleanup_geometry(geometry_mesh); generate_detail_props(geometry_mesh); unwrap_lightmap(geometry_mesh); save_geometry_file(geometry_mesh); - VMFLogger.log("import_geometry took %d ms" % (Time.get_ticks_msec() - _t)); +func import_geometry() -> void: + if geometry: geometry.free(); + if navmesh: navmesh.free(); + + var mesh: ArrayMesh = VMFTool.create_mesh(vmf_structure, Vector3.ZERO, remove_merged_faces); + if not mesh: return; + + var geometry_root: Node3D; + generate_shadow_mesh(mesh); -func generate_navmesh(geometry_mesh: MeshInstance3D): + if not chunked_mesh: + geometry_root = get_geometry_node(); + geometry_root.mesh = mesh; + process_geometry(geometry_root); + else: + geometry_root = get_geometry_node(Node3D); + var chunk_meshes := VMFTool.split_mesh_by_chunks(mesh, chunked_mesh_size); + var chunk_id := 0; + + for chunk_mesh in chunk_meshes: + var mi := MeshInstance3D.new(); + mi.name = "chunk_" + str(chunk_id); + mi.mesh = VMFTool.generate_lods(chunk_mesh); + mi.set_display_folded(true); + geometry_root.add_child(mi); + mi.set_owner(_owner); + process_geometry(mi); + chunk_id += 1; + + generate_navmesh(geometry_root); + +func generate_navmesh(geometry_mesh: Node3D): if not VMFConfig.import.use_navigation_mesh: return; if get_meta("instance", false): return; - var navreg := NavigationRegion3D.new(); + navmesh = NavigationRegion3D.new(); var navmesh_preset := VMFConfig.import.navigation_mesh_preset; if navmesh_preset == "": - navreg.navigation_mesh = NavigationMesh.new(); + navmesh.navigation_mesh = NavigationMesh.new(); if ResourceLoader.exists(navmesh_preset): var res := load(navmesh_preset); if res is not NavigationMesh: VMFLogger.warn("Navigation mesh preset \"%s\" is not a NavigationMesh resource. Falling back to default." % navmesh_preset); - navreg.navigation_mesh = NavigationMesh.new(); + navmesh.navigation_mesh = NavigationMesh.new(); else: - navreg.navigation_mesh = load(navmesh_preset); + navmesh.navigation_mesh = load(navmesh_preset).duplicate(); else: VMFLogger.warn("Navigation mesh preset \"%s\" is not found. Falling back to default." % navmesh_preset); - navreg.navigation_mesh = NavigationMesh.new(); + navmesh.navigation_mesh = NavigationMesh.new(); - navreg.name = "NavigationMesh"; + navmesh.name = "NavigationMesh"; - add_child(navreg); - navreg.set_owner(_owner); - geometry_mesh.reparent(navreg); + add_child(navmesh); + navmesh.set_owner(_owner); + geometry_mesh.reparent(navmesh); - navreg.bake_navigation_mesh.call_deferred(); + navmesh.bake_navigation_mesh.call_deferred(); func cleanup_geometry(target_mesh_instance: MeshInstance3D) -> void: target_mesh_instance.mesh = VMFTool.cleanup_mesh(target_mesh_instance.mesh); -func save_geometry_file(target_mesh_instance: MeshInstance3D) -> void: +func save_geometry_file(target_mesh_instance: MeshInstance3D, postfix: String = "") -> void: var target_mesh: Mesh = target_mesh_instance.mesh; if not save_geometry: return; - var resource_path: String = "%s/%s_import.mesh" % [VMFConfig.import.geometry_folder, _vmf_identifer()]; + var resource_path: String = "%s/%s%s_import.mesh" % [VMFConfig.import.geometry_folder, _vmf_identifer(), postfix]; if not DirAccess.dir_exists_absolute(resource_path.get_base_dir()): DirAccess.make_dir_recursive_absolute(resource_path.get_base_dir()); @@ -237,15 +265,16 @@ func save_geometry_file(target_mesh_instance: MeshInstance3D) -> void: target_mesh.take_over_path(resource_path); target_mesh_instance.mesh = load(resource_path); -func save_collision_file() -> void: +func save_collision_file(mesh_instance: MeshInstance3D = geometry, postfix = "") -> void: if save_collision == false: return; - - var collisions = $Geometry.get_children() as Array[StaticBody3D]; + var collisions := mesh_instance.get_children(); for body in collisions: + if body is not StaticBody3D: continue; + var collision := body.get_node('collision'); var shape = collision.shape; - var save_path := "%s/%s_collision_%s.res" % [VMFConfig.import.geometry_folder, _vmf_identifer(), body.name]; + var save_path := "%s/%s%s_collision_%s.res" % [VMFConfig.import.geometry_folder, _vmf_identifer(), postfix, body.name]; if not DirAccess.dir_exists_absolute(save_path.get_base_dir()): DirAccess.make_dir_recursive_absolute(save_path.get_base_dir()); diff --git a/addons/godotvmf/src/vmf_runtime_controller.gd b/addons/godotvmf/src/vmf_runtime_controller.gd index 9cdba31..f0f5fd6 100644 --- a/addons/godotvmf/src/vmf_runtime_controller.gd +++ b/addons/godotvmf/src/vmf_runtime_controller.gd @@ -5,14 +5,14 @@ class_name VMFRuntimeController extends Node3D; @export var default_map_name: String = ""; @export_dir var maps_folder: String = "res://maps"; -const IMPORTANT_MESSAGE = "In hammer do following steps: +const IMPORTANT_MESSAGE = """In hammer do following steps: 1. Open Tools -> Options -> Build programs tab. In the \"Game Executable\" specify path to the Godot Engine launcher. 2. Open Run Map window (F9). 3. Click \"Edit\" and add new configuration. 4. Select the created configuration in Configurations field 5. Click \"New\" and add into \"Command\" field this - $game_exe 6. Add into \"Parameters\" field this - --path $gamedir {scene_path} --vmf $file"; + --path $gamedir {scene_path} --vmf $file"""; @export_multiline var hammer_setup: String; @@ -23,11 +23,11 @@ static var instance: VMFRuntimeController; func kill_existing_process(): var pid = FileAccess.open(PROCESS_FILE, FileAccess.READ); if pid: - var processToKill = int(pid.get_line()); + var process_to_kill = int(pid.get_line()); - if processToKill: - print("Killing existing process: {0}".format([pid.get_line()])); - OS.kill(processToKill); + if process_to_kill: + print("Killing existing process: {0}".format([process_to_kill])); + OS.kill(process_to_kill); pid.close(); @@ -40,7 +40,7 @@ func launch_map(): var vmf_arg = args.find("--vmf"); var map_name = default_map_name; - if vmf_arg != -1: + if vmf_arg != -1 && vmf_arg + 1 < args.size(): map_name = args[vmf_arg + 1]; var map_path = (maps_folder + "/{0}.vmf").format([map_name]); @@ -61,7 +61,7 @@ func launch_map(): vmf.save_geometry = false; vmf.save_collision = false; vmf.is_runtime = true; - vmf.import_map(); + await vmf.import_map(); vmf.set_owner(scene); func _ready(): diff --git a/addons/godotvmf/src/vmf_tool.gd b/addons/godotvmf/src/vmf_tool.gd index 5715fda..e3ac778 100644 --- a/addons/godotvmf/src/vmf_tool.gd +++ b/addons/godotvmf/src/vmf_tool.gd @@ -8,10 +8,7 @@ static func generate_shadow_mesh(source_mesh: ArrayMesh) -> ArrayMesh: for surface_idx in range(source_mesh.get_surface_count()): var material = source_mesh.surface_get_material(surface_idx); - - if not material: - shadow_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, source_mesh.surface_get_arrays(surface_idx)); - continue; + if not material: continue; var material_compile_keys: Array = material.get_meta("compile_keys", []); var is_shadowmesh := false; @@ -28,6 +25,8 @@ static func generate_shadow_mesh(source_mesh: ArrayMesh) -> ArrayMesh: if not is_shadowmesh: continue; shadow_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, source_mesh.surface_get_arrays(surface_idx)); + + if shadow_mesh.get_surface_count() == 0: return null; return VMFUtils.merge_surfaces(shadow_mesh); @@ -66,8 +65,6 @@ static func generate_collisions(mesh_instance: MeshInstance3D, physics_mask: int is_no_collision = true; break; - if is_no_collision: break; - if corrector._get_nocollision_keys().has(key): is_no_collision = true; break; @@ -88,7 +85,7 @@ static func generate_collisions(mesh_instance: MeshInstance3D, physics_mask: int var static_body = StaticBody3D.new(); var collision = CollisionShape3D.new(); var compilekeys = surface_props[surface_prop].get_meta("compile_keys", []); - + surface_props[surface_prop] = VMFMeshUtils.simplify_mesh(surface_props[surface_prop], 0.8); static_body.collision_layer = physics_mask; for key in compilekeys: @@ -143,7 +140,7 @@ static func cleanup_mesh(original_mesh: ArrayMesh) -> ArrayMesh: if is_material_ignored: break; if extend_corrector: is_norender = extend_corrector._get_norender_keys().has(key); - break; + if is_norender: break; is_norender = corrector._get_norender_keys().has(key); if is_norender: break; @@ -317,6 +314,101 @@ static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.Z return mesh; +static func split_mesh_by_chunks(mesh: ArrayMesh, chunk_size = VMFConfig.import.chunked_mesh_default_size) -> Array: + var output: Array[ArrayMesh] = []; + + if chunk_size <= 1.0: + VMFLogger.warn("Chunk size must be greater than 1.0"); + return output; + + if not mesh or mesh.get_surface_count() == 0: + return output; + + var chunk_meshes: Dictionary = {}; + var surface_count: int = mesh.get_surface_count(); + + for surface_idx in range(surface_count): + var arrays := mesh.surface_get_arrays(surface_idx); + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array; + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array; + + if not vertices or vertices.is_empty() or not indices or indices.is_empty(): + continue; + + var material := mesh.surface_get_material(surface_idx); + var surface_material_name: String = mesh.get_meta("surface_material_" + str(surface_idx), ""); + + var src_normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array; + var src_uvs := arrays[Mesh.ARRAY_TEX_UV] as PackedVector2Array; + var src_colors := arrays[Mesh.ARRAY_COLOR] as PackedColorArray; + var src_tangents := arrays[Mesh.ARRAY_TANGENT] as PackedFloat32Array; + + # Group triangle indices by spatial chunk key + var chunk_triangles: Dictionary = {}; + for i in range(0, indices.size(), 3): + var i0 := indices[i]; + var i1 := indices[i + 1]; + var i2 := indices[i + 2]; + var centroid := (vertices[i0] + vertices[i1] + vertices[i2]) / 3.0; + var chunk_key := Vector3i( + int(floor(centroid.x / chunk_size)), + int(floor(centroid.y / chunk_size)), + int(floor(centroid.z / chunk_size)) + ); + + var tri: PackedInt32Array = chunk_triangles.get(chunk_key, PackedInt32Array()); + tri.append(i0); + tri.append(i1); + tri.append(i2); + chunk_triangles[chunk_key] = tri; + + # Build per-chunk surfaces, remapping only the vertices used by each chunk + for chunk_key in chunk_triangles: + var tri_indices := chunk_triangles[chunk_key] as PackedInt32Array; + var old_to_new: Dictionary = {}; + var new_vertices := PackedVector3Array(); + var new_normals := PackedVector3Array(); + var new_uvs := PackedVector2Array(); + var new_uv2s := PackedVector2Array(); + var new_colors := PackedColorArray(); + var new_tangents := PackedFloat32Array(); + var new_indices := PackedInt32Array(); + + for old_idx in tri_indices: + if not old_idx in old_to_new: + old_to_new[old_idx] = new_vertices.size(); + new_vertices.append(vertices[old_idx]); + if src_normals: new_normals.append(src_normals[old_idx]); + if src_uvs: new_uvs.append(src_uvs[old_idx]); + if src_colors: new_colors.append(src_colors[old_idx]); + if src_tangents: + var t := old_idx * 4; + new_tangents.append(src_tangents[t]); + new_tangents.append(src_tangents[t + 1]); + new_tangents.append(src_tangents[t + 2]); + new_tangents.append(src_tangents[t + 3]); + new_indices.append(old_to_new[old_idx]); + + var new_arrays: Array = []; + new_arrays.resize(Mesh.ARRAY_MAX); + new_arrays[Mesh.ARRAY_VERTEX] = new_vertices; + new_arrays[Mesh.ARRAY_INDEX] = new_indices; + if not new_normals.is_empty(): new_arrays[Mesh.ARRAY_NORMAL] = new_normals; + if not new_uvs.is_empty(): new_arrays[Mesh.ARRAY_TEX_UV] = new_uvs; + if not new_colors.is_empty(): new_arrays[Mesh.ARRAY_COLOR] = new_colors; + if not new_tangents.is_empty(): new_arrays[Mesh.ARRAY_TANGENT] = new_tangents; + + if not chunk_key in chunk_meshes: + chunk_meshes[chunk_key] = ArrayMesh.new(); + + var chunk_mesh := chunk_meshes[chunk_key] as ArrayMesh; + var new_surface_idx := chunk_mesh.get_surface_count(); + chunk_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, new_arrays); + chunk_mesh.surface_set_material(new_surface_idx, material); + chunk_mesh.set_meta("surface_material_" + str(new_surface_idx), surface_material_name); + + return chunk_meshes.values(); + static func generate_lods(mesh: ArrayMesh) -> ArrayMesh: if not mesh.get_surface_count(): return mesh; @@ -330,6 +422,11 @@ static func generate_lods(mesh: ArrayMesh) -> ArrayMesh: 'surface_' + str(surface_idx) ); - importer_mesh.generate_lods(60, 60, []); + importer_mesh.generate_lods(180, -1, []); - return importer_mesh.get_mesh(); + var result := importer_mesh.get_mesh(); + for surface_idx in range(mesh.get_surface_count()): + var meta_key := "surface_material_" + str(surface_idx); + if mesh.has_meta(meta_key): + result.set_meta(meta_key, mesh.get_meta(meta_key)); + return result;