diff --git a/addons/godotvmf/entities/func_detail.gd b/addons/godotvmf/entities/func_detail.gd index 5edba70..a6b9a9c 100644 --- a/addons/godotvmf/entities/func_detail.gd +++ b/addons/godotvmf/entities/func_detail.gd @@ -12,4 +12,4 @@ func _entity_setup(entity: VMFEntity): return; $MeshInstance3D.set_mesh(mesh); - $MeshInstance3D/StaticBody3D/CollisionShape3D.shape = $MeshInstance3D.mesh.create_trimesh_shape(); + $MeshInstance3D/StaticBody3D/CollisionShape3D.shape = mesh.create_trimesh_shape(); diff --git a/addons/godotvmf/godotmdl/mdl_combiner.gd b/addons/godotvmf/godotmdl/mdl_combiner.gd index 45a4bc0..b3be4e8 100644 --- a/addons/godotvmf/godotmdl/mdl_combiner.gd +++ b/addons/godotvmf/godotmdl/mdl_combiner.gd @@ -185,7 +185,8 @@ func generate_collision(): vertices.append_array([v1, v2, v3]); collision.shape.set_faces(PackedVector3Array(vertices)); - if skeleton and skeleton.find_bone("static_body") == -1: + + if skeleton and skeleton.find_bone("static_prop") == -1: var bone_attachment: BoneAttachment3D = BoneAttachment3D.new(); bone_attachment.name = "bone_attachment_" + str(surface_index) + "_" + str(solid_index); bone_attachment.bone_idx = max(0, solid.bone_index - 1); @@ -246,12 +247,21 @@ func assign_materials(): for tex in mdl.textures: + var found = false for dir in mdl.textureDirs: var path = VMFUtils.normalize_path(dir + "/" + tex.name); if not VMTLoader.has_material(path.to_lower()): continue; var material = VMTLoader.get_material(path.to_lower()); if not material: continue; materials.append(material); + found = true + break + if not found: + var path = VMFUtils.normalize_path(tex.name); + if VMTLoader.has_material(path.to_lower()): + var material = VMTLoader.get_material(path.to_lower()); + if material: + materials.append(material); var surfaces = mesh_instance.mesh.get_surface_count(); var skin_id = 0; diff --git a/addons/godotvmf/godotmdl/reader.gd b/addons/godotvmf/godotmdl/reader.gd index 8c95fee..66423da 100644 --- a/addons/godotvmf/godotmdl/reader.gd +++ b/addons/godotvmf/godotmdl/reader.gd @@ -76,6 +76,10 @@ static func read_by_structure(file: FileAccess, Clazz, read_from = -1): return result; +static func read_char(file: FileAccess): + var byte = file.get_8(); + return char(byte) if byte != 0 else ""; + static func _read_data(file: FileAccess, type: Type): match type: Type.FLOAT: return file.get_float(); @@ -84,10 +88,10 @@ static func _read_data(file: FileAccess, type: Type): Type.SHORT: return read_signed_short(file); Type.LONG: return file.get_64() - 0x80000000; Type.UNSIGNED_SHORT: return file.get_16(); - Type.UNSIGNED_CHAR: return file.get_8(); + Type.UNSIGNED_CHAR: return read_char(file); - Type.STRING: return char(file.get_8()); - Type.CHAR: return char(file.get_8()); + Type.STRING: return read_char(file); + Type.CHAR: return read_char(file); Type.VECTOR2: return Vector2(file.get_float(), file.get_float()); Type.VECTOR4: return Vector4(file.get_float(), file.get_float(), file.get_float(), file.get_float()); diff --git a/addons/godotvmf/godotvmf.gd b/addons/godotvmf/godotvmf.gd index 578d2e1..1929f85 100644 --- a/addons/godotvmf/godotvmf.gd +++ b/addons/godotvmf/godotvmf.gd @@ -77,6 +77,11 @@ func GetExistingVMFNodes() -> Array[VMFNode]: nodes.assign(get_tree().get_nodes_in_group("vmfnode_group")); return nodes.filter(func(node): return not node.get_meta("instance", false)); +func _on_import_progress(phase: String, current: int, total: int): + var label = dock.get_node_or_null('ProgressBar/label'); + if label: + label.text = "%s (%d/%d)" % [phase, current, total]; + func ReimportVMF(): var nodes := GetExistingVMFNodes(); @@ -84,18 +89,21 @@ func ReimportVMF(): await get_tree().create_timer(0.1).timeout for node in nodes: - node.import_map(); + node.import_progress.connect(_on_import_progress); + await node.import_map(); + node.import_progress.disconnect(_on_import_progress); dock.get_node('ProgressBar').hide(); func ReimportEntities(): var nodes := GetExistingVMFNodes(); - dock.get_node('ProgressBar').show(); dock.get_node('ProgressBar').show(); await get_tree().create_timer(0.1).timeout - for node in nodes: - node.reimport_entities(); + for node in nodes: + node.import_progress.connect(_on_import_progress); + await node.reimport_entities(); + node.import_progress.disconnect(_on_import_progress); dock.get_node('ProgressBar').hide(); func ReimportGeometry(): @@ -105,5 +113,7 @@ func ReimportGeometry(): await get_tree().create_timer(0.1).timeout for node in nodes: - node.reimport_geometry(); + node.import_progress.connect(_on_import_progress); + await node.reimport_geometry(); + node.import_progress.disconnect(_on_import_progress); dock.get_node('ProgressBar').hide(); diff --git a/addons/godotvmf/godotvmt/formats/vtf_dxt.gd b/addons/godotvmf/godotvmt/formats/vtf_dxt.gd new file mode 100644 index 0000000..c12f7bb --- /dev/null +++ b/addons/godotvmf/godotvmt/formats/vtf_dxt.gd @@ -0,0 +1,33 @@ +class_name VTFDXT extends VTFFrameReader + +static func read(vtf: VTFLoader, frame: int, srgb_conversion_method: VTFLoader.SRGBConversionMethod) -> ImageTexture: + var data = PackedByteArray(); + var bytes_read := 0; + var is_dxt_1 := vtf.hires_image_format == vtf.ImageFormat.DXT1; + var multiplier = 8 if is_dxt_1 else 16; + var format := vtf.format_map[str(vtf.hires_image_format)] as Image.Format; + var use_mipmaps := not (vtf.flags & vtf.Flags.TEXTUREFLAGS_NOMIP); + + frame = vtf.frames - 1 - frame; + + for i in range(vtf.mipmap_count): + var mipWidth = max(1, vtf.width >> i); + var mipHeight = max(1, vtf.height >> i); + + var mip_size = max(1, mipWidth / 4) * max(1, mipHeight / 4) * multiplier; + + vtf.file.seek(vtf.file.get_length() - bytes_read - mip_size - mip_size * frame); + data += vtf.file.get_buffer(mip_size); + + bytes_read += mip_size + mip_size * (vtf.frames - 1); + + var img := Image.create_from_data(vtf.width, vtf.height, use_mipmaps, format, data); + if not img: return null; + + if srgb_conversion_method == vtf.SRGBConversionMethod.DURING_IMPORT: + img.decompress(); + img.compress(Image.COMPRESS_S3TC); + + vtf.alpha = vtf.flags & vtf.Flags.TEXTUREFLAGS_ONEBITALPHA or vtf.flags & vtf.Flags.TEXTUREFLAGS_EIGHTBITALPHA; + + return ImageTexture.create_from_image(img); diff --git a/addons/godotvmf/godotvmt/formats/vtf_frame_reader.gd b/addons/godotvmf/godotvmt/formats/vtf_frame_reader.gd new file mode 100644 index 0000000..0a3b75a --- /dev/null +++ b/addons/godotvmf/godotvmt/formats/vtf_frame_reader.gd @@ -0,0 +1,6 @@ +class_name VTFFrameReader extends RefCounted + + +## Base class for reading frames from a VTF file. Each image format should have its own implementation of this class. +static func read(vtf: VTFLoader, frame: int, srgb_conversion_method: VTFLoader.SRGBConversionMethod) -> ImageTexture: + return null; diff --git a/addons/godotvmf/godotvmt/formats/vtf_i8.gd b/addons/godotvmf/godotvmt/formats/vtf_i8.gd new file mode 100644 index 0000000..cd808cc --- /dev/null +++ b/addons/godotvmf/godotvmt/formats/vtf_i8.gd @@ -0,0 +1,27 @@ +class_name VTFI8 extends VTFFrameReader + +static func read(vtf: VTFLoader, frame: int, srgb_conversion_method: VTFLoader.SRGBConversionMethod) -> ImageTexture: + var data := PackedByteArray(); + var bytes_read := 0; + var use_mipmaps := not (vtf.flags & vtf.Flags.TEXTUREFLAGS_NOMIP); + var multiplier := 1 if vtf.hires_image_format == vtf.ImageFormat.I8 else 2; + var output_format := Image.FORMAT_L8 if vtf.hires_image_format == vtf.ImageFormat.I8 else Image.FORMAT_LA8; + + frame = vtf.frames - 1 - frame; + + for i in range(vtf.mipmap_count): + var mip_width = max(1, vtf.width >> i); + var mip_height = max(1, vtf.height >> i); + var mip_size = mip_width * mip_height * multiplier; # I8 has 1 byte per pixel + + vtf.file.seek(vtf.file.get_length() - bytes_read - mip_size - mip_size * frame); + var chunk := vtf.file.get_buffer(mip_size); + + data += chunk; + bytes_read += mip_size + mip_size * (vtf.frames - 1); + + var img := Image.create_from_data(vtf.width, vtf.height, use_mipmaps, output_format, data); + + if not img: return null; + + return ImageTexture.create_from_image(img); diff --git a/addons/godotvmf/godotvmt/formats/vtf_rgba.gd b/addons/godotvmf/godotvmt/formats/vtf_rgba.gd new file mode 100644 index 0000000..aec6924 --- /dev/null +++ b/addons/godotvmf/godotvmt/formats/vtf_rgba.gd @@ -0,0 +1,36 @@ +class_name VTFRGBA extends VTFFrameReader + +static func read(vtf: VTFLoader, frame: int, srgb_conversion_method: VTFLoader.SRGBConversionMethod) -> ImageTexture: + var data := PackedByteArray(); + var bytes_read := 0; + var use_mipmaps := not (vtf.flags & vtf.Flags.TEXTUREFLAGS_NOMIP); + var is_abgr := vtf.hires_image_format == vtf.ImageFormat.ABGR8888 || vtf.hires_image_format == vtf.ImageFormat.BGR888; + var is_no_alpha := vtf.hires_image_format >= vtf.ImageFormat.RGB888; + var channels_count := 3 if is_no_alpha else 4; + var output_format := Image.FORMAT_RGB8 if is_no_alpha else Image.FORMAT_RGBA8; + + + frame = vtf.frames - 1 - frame; + + for i in range(vtf.mipmap_count): + var mip_width = max(1, vtf.width >> i); + var mip_height = max(1, vtf.height >> i); + var mip_size = mip_width * mip_height * channels_count; # RGBA8888 has 4 bytes per pixel + + vtf.file.seek(vtf.file.get_length() - bytes_read - mip_size - mip_size * frame); + var chunk := vtf.file.get_buffer(mip_size); + + if is_abgr: chunk.reverse(); + + data += chunk; + bytes_read += mip_size + mip_size * (vtf.frames - 1); + + var img := Image.create_from_data(vtf.width, vtf.height, use_mipmaps, output_format, data); + + if is_abgr: + img.flip_x(); + img.flip_y(); + + if not img: return null; + + return ImageTexture.create_from_image(img); diff --git a/addons/godotvmf/godotvmt/vmt_loader.gd b/addons/godotvmf/godotvmt/vmt_loader.gd index abb7225..4cabec3 100644 --- a/addons/godotvmf/godotvmt/vmt_loader.gd +++ b/addons/godotvmf/godotvmt/vmt_loader.gd @@ -31,6 +31,14 @@ static func parse_transform(transform_data: String): translate = translate, } +static func get_material_load_path(material: String) -> String: + var path_root = normalize_path(VMFConfig.materials.target_folder + "/" + material).to_lower(); + + if ResourceLoader.exists(path_root + ".tres"): return path_root + ".tres"; + if ResourceLoader.exists(path_root + ".res"): return path_root + ".res"; + if ResourceLoader.exists(path_root + ".vmt"): return path_root + ".vmt"; + return ""; + static func load(path: String): var structure = VDFParser.parse(path, true); @@ -75,8 +83,8 @@ static func load(path: String): key = key.replace('$', '').replace('%', ''); if is_compile_key and value and key != "keywords": - var compile_keys = material.get_meta("compile_keys", []); - compile_keys.append(key); + var compile_keys := material.get_meta("compile_keys", []) as Array; + compile_keys.append(key.to_lower()); material.set_meta("compile_keys", compile_keys); if material is ShaderMaterial && not is_blend_texture: @@ -106,27 +114,18 @@ static func normalize_path(path: String) -> String: return path.replace('\\', '/').replace('//', '/').replace('res:/', 'res://'); static func has_material(material: String) -> bool: - var material_path = normalize_path(VMFConfig.materials.target_folder + "/" + material + ".tres").to_lower(); - - if not ResourceLoader.exists(material_path): - material_path = material_path.replace(".tres", ".vmt"); - - if not ResourceLoader.exists(material_path): - return false; + return get_material_load_path(material) != ""; - return true; +static func is_material_ignored(material_name: String) -> bool: + return VMFConfig.materials.ignore.any(func(rx: String) -> bool: return material_name.match(rx.to_lower())); static func get_material(material: String) -> Material: var cached_material = VMFCache.get_cached(material); if cached_material: return cached_material as Material; - var material_path = normalize_path(VMFConfig.materials.target_folder + "/" + material + ".tres").to_lower(); - - if not ResourceLoader.exists(material_path): - material_path = material_path.replace(".tres", ".vmt"); - - if not ResourceLoader.exists(material_path): + var material_path = get_material_load_path(material); + if material_path == "": if not VMFCache.is_file_logged(material_path): VMFLogger.warn("Material not found: " + material_path); VMFCache.add_logged_file(material_path); @@ -161,8 +160,8 @@ static func get_texture_size(side_material: String) -> Vector2: if not texture and (material is ShaderMaterial): texture = material.get_shader_parameter('basetexture'); - - if not texture: + + if not texture: cached_value = Vector2(default_texture_size, default_texture_size); VMFCache.add_cached(cache_key, cached_value); return cached_value; diff --git a/addons/godotvmf/godotvmt/vmt_material_conversion_context_menu.gd b/addons/godotvmf/godotvmt/vmt_material_conversion_context_menu.gd index 4c8db63..e2f29b0 100644 --- a/addons/godotvmf/godotvmt/vmt_material_conversion_context_menu.gd +++ b/addons/godotvmf/godotvmt/vmt_material_conversion_context_menu.gd @@ -44,7 +44,7 @@ func create_blend_material(paths: PackedStringArray): var path1 = (vmts[0] as String).get_base_dir() var path2 = (vmts[1] as String).get_base_dir() - var save_path = path1.get_base_dir() + '/' + blend_file_name; + var save_path = path1 + '/' + blend_file_name; print("Saving blended VMT to: " + save_path); var file := FileAccess.open(save_path, FileAccess.WRITE); diff --git a/addons/godotvmf/godotvmt/vmt_transformer.gd b/addons/godotvmf/godotvmt/vmt_transformer.gd index a208cca..fe030a6 100644 --- a/addons/godotvmf/godotvmt/vmt_transformer.gd +++ b/addons/godotvmf/godotvmt/vmt_transformer.gd @@ -121,18 +121,29 @@ func detailblendmode(material: Material, value: Variant): if "detail_blend_mode" in material: material.set("detail_blend_mode", value); +func color2(material: Material, value: Variant): + if material is not BaseMaterial3D: return; + var color_str = str(value).replace("{", "").replace("}", "").strip_edges(); + var parts = color_str.split(" ", false); + if parts.size() >= 3: + material.albedo_color = Color( + parts[0].to_float() / 255.0, + parts[1].to_float() / 255.0, + parts[2].to_float() / 255.0 + ); + func surfaceprop(material: Material, value: Variant): material.set_meta("surfaceprop", value); func basetexturetransform(material: Material, value: Variant): - if "uv1_scale" not in material: - return; - if "uv1_offset" not in material: - return; - var transform = VMTLoader.parse_transform(value); - material.uv1_scale = Vector3(transform.scale.x, transform.scale.y, 1); - material.uv1_offset = Vector3(transform.translate.x, transform.translate.y, 0); + + if material is ShaderMaterial: + material.set_shader_parameter("uv1_scale", Vector3(transform.scale.x, transform.scale.y, 1)); + material.set_shader_parameter("uv1_offset", Vector3(transform.translate.x, transform.translate.y, 0)); + elif "uv1_scale" in material and "uv1_offset" in material: + material.uv1_scale = Vector3(transform.scale.x, transform.scale.y, 1); + material.uv1_offset = Vector3(transform.translate.x, transform.translate.y, 0); func basetexturetransform2(material: Material, value: Variant): if "uv1_scale2" not in material: diff --git a/addons/godotvmf/godotvmt/vtf_loader.gd b/addons/godotvmf/godotvmt/vtf_loader.gd index 88d8227..77a425c 100644 --- a/addons/godotvmf/godotvmt/vtf_loader.gd +++ b/addons/godotvmf/godotvmt/vtf_loader.gd @@ -6,34 +6,34 @@ enum SRGBConversionMethod { } enum ImageFormat { - IMAGE_FORMAT_RGBA8888, - IMAGE_FORMAT_ABGR8888, - IMAGE_FORMAT_RGB888, - IMAGE_FORMAT_BGR888, - IMAGE_FORMAT_RGB565, - IMAGE_FORMAT_I8, - IMAGE_FORMAT_IA88, - IMAGE_FORMAT_P8, - IMAGE_FORMAT_A8, - IMAGE_FORMAT_RGB888_BLUESCREEN, - IMAGE_FORMAT_BGR888_BLUESCREEN, - IMAGE_FORMAT_ARGB8888, - IMAGE_FORMAT_BGRA8888, - IMAGE_FORMAT_DXT1, - IMAGE_FORMAT_DXT3, - IMAGE_FORMAT_DXT5, - IMAGE_FORMAT_BGRX8888, - IMAGE_FORMAT_BGR565, - IMAGE_FORMAT_BGRX5551, - IMAGE_FORMAT_BGRA4444, - IMAGE_FORMAT_DXT1_ONEBITALPHA, - IMAGE_FORMAT_BGRA5551, - IMAGE_FORMAT_UV88, - IMAGE_FORMAT_UVWQ8888, - IMAGE_FORMAT_RGBA16161616F, - IMAGE_FORMAT_RGBA16161616, - IMAGE_FORMAT_UVLX8888, - IMAGE_FORMAT_NONE = -1 + RGBA8888, + ABGR8888, + RGB888, + BGR888, + RGB565, + I8, + IA88, + P8, + A8, + RGB888_BLUESCREEN, + BGR888_BLUESCREEN, + ARGB8888, + BGRA8888, + DXT1, + DXT3, + DXT5, + BGRX8888, + BGR565, + BGRX5551, + BGRA4444, + DXT1_ONEBITALPHA, + BGRA5551, + UV88, + UVWQ8888, + RGBA16161616F, + RGBA16161616, + UVLX8888, + NONE = -1 } enum Flags @@ -66,7 +66,7 @@ enum Flags TEXTUREFLAGS_BORDER = 0x20000000, }; -static var formatLabels: +static var format_labels: get: return [ "RGBA8888", "ABGR8888", @@ -98,6 +98,19 @@ static var formatLabels: "NONE", ]; +static var frame_readers: Dictionary: + get: return { + ImageFormat.DXT1: VTFDXT, + ImageFormat.DXT3: VTFDXT, + ImageFormat.DXT5: VTFDXT, + ImageFormat.RGBA8888: VTFRGBA, + ImageFormat.ABGR8888: VTFRGBA, + ImageFormat.RGB888: VTFRGBA, + ImageFormat.BGR888: VTFRGBA, + ImageFormat.I8: VTFI8, + ImageFormat.IA88: VTFI8, + }; + static var format_map: get: return { "13": Image.Format.FORMAT_DXT1, @@ -105,92 +118,84 @@ static var format_map: "15": Image.Format.FORMAT_DXT5, }; -static var supported_formats: - get: return [ - ImageFormat.IMAGE_FORMAT_DXT1, - ImageFormat.IMAGE_FORMAT_DXT3, - ImageFormat.IMAGE_FORMAT_DXT5, - ]; - -var file = null; -var signature: +var file: FileAccess; +var signature: String: get: return seek(0).get_buffer(16).get_string_from_utf8(); -var version: +var version: float: get: file.seek(4); return float(".".join([file.get_32(), file.get_32()])); -var header_size: +var header_size: int: get: return seek(12).get_32(); -var width: +var width: int: get: var width = seek(16).get_16(); return width if width > 0 else 512; -var height: +var height: int: get: var height = seek(18).get_16(); return height if height > 0 else 512; -var flags: +var flags: int: get: return seek(20).get_32(); -var frames: +var frames: int: get: return seek(24).get_16(); -var first_frame: +var first_frame: int: get: return seek(26).get_16(); -var reflectivity: +var reflectivity: Vector3: get: file.seek(32); return Vector3(file.get_float(), file.get_float(), file.get_float()); -var bump_scale: +var bump_scale: float: get: return seek(48).get_float(); -var hires_image_format: +var hires_image_format: int: get: return seek(52).get_32(); -var mipmap_count: +var mipmap_count: int: get: if not use_mipmaps: return 1; return seek(56).get_8(); -var low_res_image_format: +var low_res_image_format: int: get: return seek(57).get_32(); -var low_res_image_width: +var low_res_image_width: int: get: return seek(61).get_8(); -var low_res_image_height: +var low_res_image_height: int: get: return seek(62).get_8(); -var depth: +var depth: int: get: if version < 7.2: return 0; return seek(63).get_8(); -var num_resources: +var num_resources: int: get: if version < 7.3: return 0; return seek(75).get_32(); -var use_mipmaps: +var use_mipmaps: bool: get: return not (flags & Flags.TEXTUREFLAGS_NOMIP); -var frame_duration = 0; -var path = ''; -var alpha = false; +var frame_duration: float = 0; +var path: String = ''; +var alpha: bool = false; var file_name: String = '': get: return path.get_file().get_basename(); -func seek(v: int): - file.seek(v); - return file; +var is_format_supported: bool: + get: return hires_image_format in frame_readers; static func create(path: String, duration: float = 0): if not FileAccess.file_exists(path): @@ -199,33 +204,37 @@ static func create(path: String, duration: float = 0): var vtf = VTFLoader.new(path, duration); - if not supported_formats.has(vtf.hires_image_format): - push_warning("Texture format {0} in not supported ({1})" \ - .format([VTFLoader.formatLabels[vtf.hires_image_format], path.get_file()])); + if not vtf.is_format_supported: + VMFLogger.warn("Texture format {0} in not supported ({1})" \ + .format([VTFLoader.format_labels[vtf.hires_image_format], path.get_file()])); vtf.done(); return null; return vtf; +func seek(v: int) -> FileAccess: + file.seek(v); + return file; + func done(): file.close(); func compile_texture(srgb_conversion_method: SRGBConversionMethod): if width == 0 or height == 0: - push_error("Corrupted file: {0}".format([file.get_path()])); + VMFLogger.error("Corrupted file: {0}".format([file.get_path()])); return null; - var tex; + var tex: Texture; if frames > 1: tex = AnimatedTexture.new(); tex.frames = frames; for frame in range(0, frames): - tex.set_frame_texture(frame, _read_frame(frame, srgb_conversion_method)); + tex.set_frame_texture(frame, read_frame(frame, srgb_conversion_method)); tex.set_frame_duration(frame, frame_duration); else: - tex = _read_frame(0, srgb_conversion_method); + tex = read_frame(0, srgb_conversion_method); if not tex: push_error("Texture not loaded: {0}".format([path])); @@ -236,39 +245,21 @@ func compile_texture(srgb_conversion_method: SRGBConversionMethod): static var normal_conversion_shader: Shader; static var shader_material: ShaderMaterial; -func _read_frame(frame, srgb_conversion_method: SRGBConversionMethod): - var data = PackedByteArray(); - var byteRead = 0; - var is_dxt_1 = hires_image_format == ImageFormat.IMAGE_FORMAT_DXT1; - var format = format_map[str(hires_image_format)]; - var use_mipmaps = not (flags & Flags.TEXTUREFLAGS_NOMIP); - frame = frames - 1 - frame; +func read_frame(frame, srgb_conversion_method: SRGBConversionMethod): + var reader = VTFLoader.frame_readers.get(hires_image_format, null); - for i in range(mipmap_count): - var mipWidth = max(1, width >> i); - var mipHeight = max(1, height >> i); - - var multiplier = 8 if is_dxt_1 else 16; - var mip_size = max(1, mipWidth / 4) * max(1, mipHeight / 4) * multiplier; - - file.seek(file.get_length() - byteRead - mip_size - mip_size * frame); - data += file.get_buffer(mip_size); - - byteRead += mip_size + mip_size * (frames - 1); - - var img := Image.create_from_data(width, height, use_mipmaps, format, data); - - if srgb_conversion_method == SRGBConversionMethod.DURING_IMPORT: - img.decompress(); - img.compress(Image.COMPRESS_S3TC); + if not reader: + VMFLogger.error("Unsupported texture format: {0} in file {1}" \ + .format([VTFLoader.format_labels[hires_image_format], file.get_path()])); + return null; - alpha = flags & Flags.TEXTUREFLAGS_ONEBITALPHA or flags & Flags.TEXTUREFLAGS_EIGHTBITALPHA; + var tex := reader.read(self, frame, srgb_conversion_method) as Texture; - if not img: - push_error("Corrupted file: {0}".format([file.get_path()])); + if not tex: + VMFLogger.error("Corrupted file: {0}".format([file.get_path()])); return null; - return ImageTexture.create_from_image(img); + return tex; func _init(path, duration): self.path = path; @@ -279,6 +270,12 @@ func _init(path, duration): static func get_texture(texture: String): texture = texture.to_lower(); const extensions_priority = ['.vtf', '.tga', '.png', '.jpg']; + + # Strip existing extension if present (some VMTs include .vtf in the path) + for ext in extensions_priority: + if texture.ends_with(ext): + texture = texture.substr(0, texture.length() - ext.length()) + break for ext in extensions_priority: var texture_path = VMFUtils.normalize_path(VMFConfig.materials.target_folder + "/" + texture + ext); diff --git a/addons/godotvmf/godotvpk/vpk_directroy_entry.gd b/addons/godotvmf/godotvpk/vpk_directroy_entry.gd new file mode 100644 index 0000000..e755da3 --- /dev/null +++ b/addons/godotvmf/godotvpk/vpk_directroy_entry.gd @@ -0,0 +1,30 @@ +class_name VPKDirectoryEntry extends RefCounted + +static func _schema(): return { + "crc32": VMFStruct.Type.UINT_32, + "preload_bytes": VMFStruct.Type.UINT_16, + "archive_index": VMFStruct.Type.UINT_16, + "offset": VMFStruct.Type.UINT_32, + "length": VMFStruct.Type.UINT_32, + "terminator": VMFStruct.Type.UINT_16, +} + +var crc32: int; +var preload_bytes: int; +var archive_index: int; +var offset: int; +var length: int; +var terminator: int; + +func _to_string() -> String: + return "VPKDirectoryEntry(crc32=0x%X, preload_bytes=%d, archive_index=%d, entry_offset=%d, entry_length=%d, terminator=0x%X)" % [crc32, preload_bytes, archive_index, offset, length, terminator]; + +func as_dict() -> Dictionary: + return { + "crc32": crc32, + "preload_bytes": preload_bytes, + "archive_index": archive_index, + "offset": offset, + "length": length, + "terminator": terminator, + } diff --git a/addons/godotvmf/godotvpk/vpk_header.gd b/addons/godotvmf/godotvpk/vpk_header.gd new file mode 100644 index 0000000..3db6987 --- /dev/null +++ b/addons/godotvmf/godotvpk/vpk_header.gd @@ -0,0 +1,20 @@ +class_name VPKHeader extends RefCounted + +const CORRECT_SIGNATURE: int = 0x55AA1234; + +static func _schema(): return { + "signature": VMFStruct.Type.UINT_32, + "version": VMFStruct.Type.UINT_32, + "tree_size": VMFStruct.Type.UINT_32, +} + +var signature: int; +var version: int; +var tree_size: int; + +var is_valid: bool: + get: return signature == CORRECT_SIGNATURE; + +func _to_string() -> String: + # Signature should be provided in hex format + return "VPKHeader(signature=0x%X, version=%d, tree_size=%d)" % [signature, version, tree_size]; diff --git a/addons/godotvmf/godotvpk/vpk_header_2.gd b/addons/godotvmf/godotvpk/vpk_header_2.gd new file mode 100644 index 0000000..40d3742 --- /dev/null +++ b/addons/godotvmf/godotvpk/vpk_header_2.gd @@ -0,0 +1,19 @@ +class_name VPKHeaderV2 extends VPKHeader + +static func _schema(): return { + "signature": VMFStruct.Type.UINT_32, + "version": VMFStruct.Type.UINT_32, + "tree_size": VMFStruct.Type.UINT_32, + "file_data_section_size": VMFStruct.Type.UINT_32, + "archive_md5_section_size": VMFStruct.Type.UINT_32, + "other_md5_section_size": VMFStruct.Type.UINT_32, + "signature_section_size": VMFStruct.Type.UINT_32, +} + +var file_data_section_size: int; +var archive_md5_section_size: int; +var other_md5_section_size: int; +var signature_section_size: int; + +func _to_string() -> String: + return "VPKHeaderV2(signature=0x%X, version=%d, tree_size=%d, file_data_section_size=%d, archive_md5_section_size=%d, other_md5_section_size=%d, signature_section_size=%d)" % [signature, version, tree_size, file_data_section_size, archive_md5_section_size, other_md5_section_size, signature_section_size]; diff --git a/addons/godotvmf/godotvpk/vpk_reader.gd b/addons/godotvmf/godotvpk/vpk_reader.gd new file mode 100644 index 0000000..c1d917f --- /dev/null +++ b/addons/godotvmf/godotvpk/vpk_reader.gd @@ -0,0 +1,131 @@ +class_name VPKReader extends RefCounted + +var vpk_content: Dictionary; +var vpk_file: FileAccess; +var archives: Array[FileAccess] = []; +var debug_mode: bool = false; +var header: VPKHeader; + +## developer.valvesoftware.com - If ArchiveIndex is 0x7fff, the offset of the file data relative to the end of the directory (see the header for more details). +const ZERO_ARCHIVE: int = 0x7fff; + +static func open(file_path: String) -> VPKReader: + if not FileAccess.file_exists(file_path): return null; + + return VPKReader.new(file_path); + +func _init(file_path: String = ""): + vpk_file = FileAccess.open(file_path, FileAccess.READ); + read_vpk_header(); + read_directory(); + +func free_vpk(): + if vpk_file: vpk_file.close(); + + if archives.size() > 0: + for archive in archives: + if archive: archive.close(); + + if debug_mode: print("Closed VPK file."); + +func is_file_exists(file_path: String) -> bool: + return vpk_content.has(file_path); + +## Returns the file data as a PackedByteArray, or null if the file is not found or an error occurs +func get_file_data(file_path: String) -> Variant: + if not is_file_exists(file_path): + if debug_mode: print("File not found in VPK: %s" % file_path); + return null; + + var entry: VPKDirectoryEntry = vpk_content.get(file_path, null); + var dataset: PackedByteArray; + + if entry.archive_index == ZERO_ARCHIVE: + vpk_file.seek(header.tree_size + entry.offset); + dataset = vpk_file.get_buffer(entry.length); + else: + var archive_path = "%s_%03d.vpk" % [vpk_file.get_path().get_basename().replace("_dir", ""), entry.archive_index]; + var archive_file: FileAccess; + + if debug_mode: print("Reading from archive: %s (index: %d)" % [archive_path, entry.archive_index]); + + if not FileAccess.file_exists(archive_path): + if debug_mode: print("Archive file not found: %s" % archive_path); + return null; + + # Check if the archive is already open + for archive in archives: + if archive and archive.get_path() == archive_path: + archive_file = archive; + break; + + if not archive_file: + archive_file = FileAccess.open(archive_path, FileAccess.READ); + if not archive_file: + if debug_mode: print("Failed to open archive file: %s" % archive_path); + return null; + archives.append(archive_file); + + archive_file.seek(entry.offset); + dataset = archive_file.get_buffer(entry.length); + + return dataset; + +func read_vpk_header() -> void: + var header := VMFStruct.transform_struct(vpk_file, VPKHeader, VPKHeader._schema()); + + if header.version == 2: + vpk_file.seek(0); + header = VMFStruct.transform_struct(vpk_file, VPKHeaderV2, VPKHeaderV2._schema()); + + if not header.is_valid: + if debug_mode: + print("Invalid VPK file: signature mismatch (0x%X)" % header.signature); + print("Expected signature: 0x55AA1234"); + return; + +func read_directory() -> void: + while not vpk_file.eof_reached(): + var extension := VMFStruct.get_null_terminated_string(vpk_file); + if extension == "": break; + + while not vpk_file.eof_reached(): + var path := VMFStruct.get_null_terminated_string(vpk_file); + if path == "": break; + + while not vpk_file.eof_reached(): + var filename := VMFStruct.get_null_terminated_string(vpk_file); + if filename == "": break; + + var full_path = VMFUtils.normalize_path("%s/%s.%s" % [path, filename, extension]); + + if debug_mode: + print("Reading directory entry for file: %s" % full_path); + + vpk_content[full_path] = read_directory_entry(vpk_file); + + +func read_directory_entry(file: FileAccess) -> VPKDirectoryEntry: + return VMFStruct.transform_struct(file, VPKDirectoryEntry, VPKDirectoryEntry._schema()); + +func extract_file(file_path: String, output_path: String) -> bool: + var file_data = get_file_data(file_path); + if file_data == null: + VMFLogger.error("Failed to extract file: %s (file not found or error occurred)" % file_path); + return false; + + if DirAccess.dir_exists_absolute(output_path.get_base_dir()) == false: + if DirAccess.make_dir_recursive_absolute(output_path.get_base_dir()): + VMFLogger.error("Failed to create output directory: %s" % output_path.get_base_dir()); + return false; + + var output_file = FileAccess.open(output_path, FileAccess.WRITE); + if not output_file: + VMFLogger.error("Failed to open output file for writing: %s" % output_path); + return false; + + output_file.store_buffer(file_data); + output_file.close(); + + if debug_mode: print("Successfully extracted file '%s' to '%s'" % [file_path, output_path]); + return true; diff --git a/addons/godotvmf/godotvpk/vpk_stack.gd b/addons/godotvmf/godotvpk/vpk_stack.gd new file mode 100644 index 0000000..1c2ab93 --- /dev/null +++ b/addons/godotvmf/godotvpk/vpk_stack.gd @@ -0,0 +1,56 @@ +class_name VPKStack extends RefCounted + +var vpks: Array[VPKReader] = []; + +## Creates a new VPKStack instance and initializes it by loading all VPK files from the specified gameinfo directory. +static func create(gameinfo_dir: String) -> VPKStack: + return VPKStack.new(gameinfo_dir); + +func _init(gameinfo_dir: String = ""): + if gameinfo_dir != "": + append(gameinfo_dir); + +## Appends all VPK files from the specified gameinfo directory to the stack. +func append(gameinfo_dir: String): + var vpk_files := Array(DirAccess.open(gameinfo_dir) \ + .get_files()) \ + .filter(func(file_name: String): return file_name.ends_with("_dir.vpk")); + + for vpk_file in vpk_files: + var vpk_path = "%s/%s" % [gameinfo_dir, vpk_file]; + var vpk_reader = VPKReader.open(vpk_path); + if vpk_reader: + vpks.append(vpk_reader); + else: + VMFLogger.error("Failed to open VPK file: %s" % vpk_path); + + +## Frees all VPK readers in the stack and clears the list. +func free_vpks(): + for vpk in vpks: + if vpk: vpk.free_vpk(); + + vpks.clear(); + +## Checks if the specified file exists in any of the VPKs in the stack. +func exists(file_path: String) -> bool: + for vpk in vpks: + if vpk.is_file_exists(file_path): return true; + + return false; + +## Extracts the specified file from the VPK stack and saves it to the output path. +## Returns true on success, or false if the file is not found or an error occurs. +func extract(file_path: String, output_path: String) -> bool: + for vpk in vpks: + if vpk and vpk.is_file_exists(file_path): + return vpk.extract_file(file_path, output_path); + + return false; + +func get_file_data(file_path: String) -> Variant: + for vpk in vpks: + if vpk and vpk.is_file_exists(file_path): + return vpk.get_file_data(file_path); + + return null; diff --git a/addons/godotvmf/plugin.cfg b/addons/godotvmf/plugin.cfg index 07ed3f6..3780491 100644 --- a/addons/godotvmf/plugin.cfg +++ b/addons/godotvmf/plugin.cfg @@ -3,5 +3,5 @@ name="GodotVMF" description="Allows use VMF files in Godot" author="H2xDev" -version="2.2.8" +version="2.3.0" script="godotvmf.gd" diff --git a/addons/godotvmf/src/structs/vmf_entity.gd b/addons/godotvmf/src/structs/vmf_entity.gd index 89a148a..6b15b85 100644 --- a/addons/godotvmf/src/structs/vmf_entity.gd +++ b/addons/godotvmf/src/structs/vmf_entity.gd @@ -15,9 +15,15 @@ func _init(raw: Dictionary) -> void: id = int(raw.get("id", -1)); classname = raw.get("classname", ""); data = raw; - has_solid = "solid" in raw and raw.solid is Dictionary; + has_solid = "solid" in raw and (raw.solid is Array or raw.solid is Dictionary); angles = raw.get("angles", Vector3.ZERO); - origin = raw.get("origin", Vector3.ZERO); + + ## NOTE: In some entities there might be more than one "origin" key (d1_town_01.vmf) + var origin_value = raw.get("origin", Vector3.ZERO); + if origin_value is Array: + origin = origin_value[0] + else: + origin = origin_value; if has_solid: var raw_solids: Variant = raw.get("solid", []); diff --git a/addons/godotvmf/src/structs/vmf_side.gd b/addons/godotvmf/src/structs/vmf_side.gd index 9c3ac96..2fdfb71 100644 --- a/addons/godotvmf/src/structs/vmf_side.gd +++ b/addons/godotvmf/src/structs/vmf_side.gd @@ -21,7 +21,7 @@ func _to_string() -> String: func _init(raw: Dictionary, _solid: VMFSolid) -> void: id = raw.get("id", -1); - material = raw.get("material", ""); + material = raw.get("material", "").to_lower(); is_displacement = "dispinfo" in raw; smoothing_groups = raw.get("smoothing_groups", 0); solid = _solid; diff --git a/addons/godotvmf/src/vmf_cache.gd b/addons/godotvmf/src/vmf_cache.gd index 085745d..ff39396 100644 --- a/addons/godotvmf/src/vmf_cache.gd +++ b/addons/godotvmf/src/vmf_cache.gd @@ -22,4 +22,3 @@ static func add_logged_file(path: String) -> void: static func clear() -> void: cache.clear() logs.clear() - diff --git a/addons/godotvmf/src/vmf_config.gd b/addons/godotvmf/src/vmf_config.gd index e51dd3f..9a0d17e 100644 --- a/addons/godotvmf/src/vmf_config.gd +++ b/addons/godotvmf/src/vmf_config.gd @@ -97,7 +97,11 @@ class ImportConfig: ## 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", ""); -static var gameinfo_path: String = ProjectSettings.get_setting("godot_vmf/import/gameinfo_path", "res://"); + var gameinfo_path: String = ProjectSettings.get_setting("godot_vmf/import/gameinfo_path", "res://"); + +## 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://"); static var models: ModelsConfig: get: @@ -153,84 +157,100 @@ static func define_project_settings(): if not ProjectSettings.has_setting("godot_vmf/import/gameinfo_path"): ProjectSettings.set_setting("godot_vmf/import/gameinfo_path", "res://"); ProjectSettings.set_initial_value("godot_vmf/import/gameinfo_path", "res://"); - + ProjectSettings.set_as_basic("godot_vmf/import/gameinfo_path", true); ## Import if not ProjectSettings.has_setting("godot_vmf/import/scale"): ProjectSettings.set_setting("godot_vmf/import/scale", 0.02); ProjectSettings.set_initial_value("godot_vmf/import/scale", 0.02); + ProjectSettings.set_as_basic("godot_vmf/import/scale", true); if not ProjectSettings.has_setting("godot_vmf/import/generate_lightmap_uv2"): ProjectSettings.set_setting("godot_vmf/import/generate_lightmap_uv2", true); ProjectSettings.set_initial_value("godot_vmf/import/generate_lightmap_uv2", true); + ProjectSettings.set_as_basic("godot_vmf/import/generate_lightmap_uv2", true); if not ProjectSettings.has_setting("godot_vmf/import/generate_collision"): ProjectSettings.set_setting("godot_vmf/import/generate_collision", true); ProjectSettings.set_initial_value("godot_vmf/import/generate_collision", true); + ProjectSettings.set_as_basic("godot_vmf/import/generate_collision", true); if not ProjectSettings.has_setting("godot_vmf/import/generate_navigation_mesh"): ProjectSettings.set_setting("godot_vmf/import/generate_navigation_mesh", false); ProjectSettings.set_initial_value("godot_vmf/import/generate_navigation_mesh", false); + ProjectSettings.set_as_basic("godot_vmf/import/generate_navigation_mesh", true); if not ProjectSettings.has_setting("godot_vmf/import/navigation_mesh_preset"): ProjectSettings.set_setting("godot_vmf/import/navigation_mesh_preset", ""); ProjectSettings.set_initial_value("godot_vmf/import/navigation_mesh_preset", ""); + ProjectSettings.set_as_basic("godot_vmf/import/navigation_mesh_preset", true); if not ProjectSettings.has_setting("godot_vmf/import/lightmap_texel_size"): ProjectSettings.set_setting("godot_vmf/import/lightmap_texel_size", 0.2); ProjectSettings.set_initial_value("godot_vmf/import/lightmap_texel_size", 0.2); + ProjectSettings.set_as_basic("godot_vmf/import/lightmap_texel_size", true); if not ProjectSettings.has_setting("godot_vmf/import/instances_folder"): ProjectSettings.set_setting("godot_vmf/import/instances_folder", "res://instances"); ProjectSettings.set_initial_value("godot_vmf/import/instances_folder", "res://instances"); + ProjectSettings.set_as_basic("godot_vmf/import/instances_folder", true); if not ProjectSettings.has_setting("godot_vmf/import/entities_folder"): ProjectSettings.set_setting("godot_vmf/import/entities_folder", "res://entities"); ProjectSettings.set_initial_value("godot_vmf/import/entities_folder", "res://entities"); + ProjectSettings.set_as_basic("godot_vmf/import/entities_folder", true); if not ProjectSettings.has_setting("godot_vmf/import/geometry_folder"): ProjectSettings.set_setting("godot_vmf/import/geometry_folder", "res://geometry"); ProjectSettings.set_initial_value("godot_vmf/import/geometry_folder", "res://geometry"); + ProjectSettings.set_as_basic("godot_vmf/import/geometry_folder", true); if not ProjectSettings.has_setting("godot_vmf/import/entity_aliases"): ProjectSettings.set_setting("godot_vmf/import/entity_aliases", {}); ProjectSettings.set_initial_value("godot_vmf/import/entity_aliases", {}); - + ProjectSettings.set_as_basic("godot_vmf/import/entity_aliases", true); ## Models if not ProjectSettings.has_setting("godot_vmf/models/import"): ProjectSettings.set_setting("godot_vmf/models/import", false); ProjectSettings.set_initial_value("godot_vmf/models/import", false); + ProjectSettings.set_as_basic("godot_vmf/models/import", true); if not ProjectSettings.has_setting("godot_vmf/models/target_folder"): ProjectSettings.set_setting("godot_vmf/models/target_folder", "res://"); ProjectSettings.set_initial_value("godot_vmf/models/target_folder", "res://"); + ProjectSettings.set_as_basic("godot_vmf/models/target_folder", true); if not ProjectSettings.has_setting("godot_vmf/models/lightmap_texel_size"): ProjectSettings.set_setting("godot_vmf/models/lightmap_texel_size", 0.4); ProjectSettings.set_initial_value("godot_vmf/models/lightmap_texel_size", 0.4); - + ProjectSettings.set_as_basic("godot_vmf/models/lightmap_texel_size", true); ## Materials if not ProjectSettings.has_setting("godot_vmf/materials/import_mode"): ProjectSettings.set_setting("godot_vmf/materials/import_mode", 0); ProjectSettings.set_initial_value("godot_vmf/materials/import_mode", 0); + ProjectSettings.set_as_basic("godot_vmf/materials/import_mode", true); if not ProjectSettings.has_setting("godot_vmf/materials/target_folder"): ProjectSettings.set_setting("godot_vmf/materials/target_folder", "res://materials"); ProjectSettings.set_initial_value("godot_vmf/materials/target_folder", "res://materials"); + ProjectSettings.set_as_basic("godot_vmf/materials/target_folder", true); if not ProjectSettings.has_setting("godot_vmf/materials/ignore"): ProjectSettings.set_setting("godot_vmf/materials/ignore", ["tools/toolsnodraw", "tools/toolsskybox", "tools/toolsinvisible"]); ProjectSettings.set_initial_value("godot_vmf/materials/ignore", ["tools/toolsnodraw", "tools/toolsskybox", "tools/toolsinvisible"]); + ProjectSettings.set_as_basic("godot_vmf/materials/ignore", true); if not ProjectSettings.has_setting("godot_vmf/materials/fallback_material"): ProjectSettings.set_setting("godot_vmf/materials/fallback_material", ""); ProjectSettings.set_initial_value("godot_vmf/materials/fallback_material", ""); + ProjectSettings.set_as_basic("godot_vmf/materials/fallback_material", true); if not ProjectSettings.has_setting("godot_vmf/materials/default_texture_size"): ProjectSettings.set_setting("godot_vmf/materials/default_texture_size", 512); ProjectSettings.set_initial_value("godot_vmf/materials/default_texture_size", 512); + ProjectSettings.set_as_basic("godot_vmf/materials/default_texture_size", true); ProjectSettings.add_property_info({ "name": "godot_vmf/import/gameinfo_path", @@ -244,7 +264,8 @@ static func define_project_settings(): ProjectSettings.add_property_info({ "name": "godot_vmf/import/scale", "type": TYPE_FLOAT, - "hint": PROPERTY_HINT_NONE, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,100.0,0.000001", "default_value": 0.02, }) @@ -280,7 +301,8 @@ static func define_project_settings(): ProjectSettings.add_property_info({ "name": "godot_vmf/import/lightmap_texel_size", "type": TYPE_FLOAT, - "hint": PROPERTY_HINT_NONE, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,100.0,0.000001", "default_value": 0.2, }) @@ -331,7 +353,8 @@ static func define_project_settings(): ProjectSettings.add_property_info({ "name": "godot_vmf/models/lightmap_texel_size", "type": TYPE_FLOAT, - "hint": PROPERTY_HINT_NONE, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,100.0,0.000001", "default_value": 0.4, }) @@ -384,3 +407,4 @@ static func load_config(): file.close(); assign(VMFConfig, json); + update_config_field(); diff --git a/addons/godotvmf/src/vmf_detail_props.gd b/addons/godotvmf/src/vmf_detail_props.gd new file mode 100644 index 0000000..2f0a951 --- /dev/null +++ b/addons/godotvmf/src/vmf_detail_props.gd @@ -0,0 +1,127 @@ +class_name VMFDetailProps extends RefCounted + +## Generates detail props from mesh based on material metadata +static func generate(original_mesh: ArrayMesh) -> Array[MultiMesh]: + const CHUNK_SIZE := 32.0 + const DENSITY := 1.0 + var result: Array[MultiMesh] = [] + + for surface_idx in range(original_mesh.get_surface_count()): + var material := original_mesh.surface_get_material(surface_idx) + if not material: + continue + + for prop_index in range(2): + var index_postfix = "" if not prop_index else str(prop_index + 1) + var material_details := material.get_meta("details", {}) + + var detail_prop_path := material_details.get("$detailprop" + index_postfix, "") as String + if detail_prop_path.is_empty(): + continue + + var density: float = material_details.get("$detailpropdensity" + index_postfix, 1.0); + var scale_range: Vector2 = material_details.get("$detailpropscale" + index_postfix, Vector2(1.0, 1.0)) as Vector2 + 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 arrays := original_mesh.surface_get_arrays(surface_idx) + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array + var colors := arrays[Mesh.ARRAY_COLOR] as PackedColorArray + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + + if indices.is_empty(): continue + + var has_colors := not colors.is_empty() + var chunks: Dictionary = {} + + var num_triangles := indices.size() / 3 + + for i in range(num_triangles): + var i1 := indices[i * 3]; + 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 edge1 := v2 - v1; + var edge2 := v3 - v1; + var area := 0.5 * edge1.cross(edge2).length(); + var count := int(round(area * density)); + + if count <= 0: continue; + + 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; + + for j in range(count): + var r1 := randf(); + var r2 := randf(); + var sqrt_r1 := sqrt(r1); + + var w := 1.0 - sqrt_r1; + var u := sqrt_r1 * (1.0 - r2); + var v := sqrt_r1 * r2; + + var alpha := abs(prop_index - (c1r * w + c2r * u + c3r * v)); + if alpha <= 0.01: continue; + + var point := v1 * w + v2 * u + v3 * v; + var normal := (n1 * w + n2 * u + n3 * v).normalized(); + + var chunk_key := Vector3i(floor(point.x / CHUNK_SIZE), floor(point.y / CHUNK_SIZE), floor(point.z / CHUNK_SIZE)); + + if not chunks.has(chunk_key): + chunks[chunk_key] = []; + + chunks[chunk_key].append([point, normal, alpha]); + + for chunk in chunks.values(): + var mmi := MultiMesh.new(); + mmi.transform_format = MultiMesh.TRANSFORM_3D; + mmi.mesh = prop_mesh; + mmi.instance_count = chunk.size(); + + for k in range(chunk.size()): + var data = chunk[k]; + var t := Transform3D(); + var scale := randf_range(scale_range.x, scale_range.y) * (data[2] as float); + var rotation := randf_range(rotation_range.x, rotation_range.y) / 180.0 * PI; + var normal: Vector3 = data[1]; + + t.basis = Basis.IDENTITY.rotated(normal, rotation) \ + * Basis.looking_at(normal, Vector3.UP) \ + * Basis.IDENTITY.rotated(Vector3.RIGHT, -PI / 2) \ + .scaled(Vector3.ONE * scale); + t.origin = data[0]; + + mmi.set_instance_transform(k, t) + + result.append(mmi) + + return result diff --git a/addons/godotvmf/src/vmf_entity_node.gd b/addons/godotvmf/src/vmf_entity_node.gd index 625bfeb..47d7a80 100644 --- a/addons/godotvmf/src/vmf_entity_node.gd +++ b/addons/godotvmf/src/vmf_entity_node.gd @@ -74,7 +74,8 @@ func _ready(): if Engine.is_editor_hint(): return; - if entity.get("targetname", null): add_named_entity(entity.targetname, self); + var _tn = entity.get("targetname", null); + if _tn != null: add_named_entity(str(_tn), self); parse_connections(); @@ -97,8 +98,9 @@ func _entity_pre_setup(ent: VMFEntity) -> void: self.transform = get_entity_transform(ent); self.enabled = ent.data.get("StartDisabled", 0) == 0; - if ent.data.get("targetname", null): - add_named_entity(ent.data.targetname, self); + var _tn = ent.data.get("targetname", null); + if _tn != null: + add_named_entity(str(_tn), self); assign_name(); @@ -109,7 +111,7 @@ func assign_name() -> void: self.name = entity.classname + '_' + str(entity.get("id", "no-id")); return; - self.name = entity.targetname + '_' + str(entity.get("id", "no-id")); + self.name = str(entity.targetname) + '_' + str(entity.get("id", "no-id")); static func add_named_entity(_name: String, node: Node): var group_name := GROUP_PREFIX + _name; diff --git a/addons/godotvmf/src/vmf_geometry_corrector.gd b/addons/godotvmf/src/vmf_geometry_corrector.gd index b8b5d08..a5f1789 100644 --- a/addons/godotvmf/src/vmf_geometry_corrector.gd +++ b/addons/godotvmf/src/vmf_geometry_corrector.gd @@ -1,17 +1,26 @@ class_name VMFGeometryCorrector extends RefCounted -const norender = [ +const SHADOW_MESH_KEYS = [ + 'compilenodraw', +] + +const NO_COLLISION_KEYS = [ + 'compilesky', + 'compilenonsolid' +] + +const NO_RENDER_KEYS = [ 'compileclip', 'compilenodraw', 'compilesky', 'npcclip', 'compileplayerclip', 'compilenpcclip', -]; +] -const nocollision = [ - 'compilesky', -]; +func _get_norender_keys(): return NO_RENDER_KEYS; +func _get_nocollision_keys(): return NO_COLLISION_KEYS; +func _get_shadowmesh_keys(): return SHADOW_MESH_KEYS; func compileplayerclip(solid: StaticBody3D): solid.collision_layer = 1 << 1 diff --git a/addons/godotvmf/src/vmf_node.gd b/addons/godotvmf/src/vmf_node.gd index 88b1359..d5aa5bd 100644 --- a/addons/godotvmf/src/vmf_node.gd +++ b/addons/godotvmf/src/vmf_node.gd @@ -2,6 +2,13 @@ @icon("res://addons/godotvmf/icon.svg") class_name VMFNode extends Node3D; +## Emitted during batched import to report progress. Connect to update UI. +signal import_progress(phase: String, current: int, total: int) + + +## Keys preserved when wiping entity data after _entity_setup +const RUNTIME_KEYS = ["targetname", "connections", "parentname", "spawnflags", "classname", "id", "StartDisabled"] + enum MaterialImportMode { USE_EXISTING, IMPORT_FROM_MOD_FOLDER, @@ -25,7 +32,8 @@ var vmf: String = ''; @export var import: bool = false: set(value): if not value: return; - import_map(); + # Property setters cannot be async, so use a wrapper + _do_import(); import = false; @export var double_sided_shadow_cast: bool = false; @@ -44,6 +52,11 @@ var vmf: String = ''; @export_flags_3d_physics var default_physics_mask: int = 1; +@export_category("Navigation") + +## Bake navigation mesh (temporarily creates collision for baking, then removes it) +@export var bake_navigation: bool = false + ## Set this to true before import if you're goint to use this node in runtime var is_runtime: bool = false; @@ -67,18 +80,395 @@ var geometry: Node3D: var entities: Node3D: get: return get_node_or_null("Entities"); -var navmesh: NavigationRegion3D: +var navmesh: Node3D: get: return get_node_or_null("NavigationMesh"); var has_imported_resources = false; +## Async wrapper for property setter (which cannot use await) +func _do_import() -> void: + await import_map(); + + func _validate_property(property: Dictionary) -> void: if property.name == "vmf": property.hint = PROPERTY_HINT_GLOBAL_FILE if use_external_file else PROPERTY_HINT_FILE +var _physics_bodies: Array[RID] = []; +var _visual_instances: Array[RID] = []; +var _visual_resources: Array = []; # prevent GC of loaded MultiMesh resources + func _ready() -> void: add_to_group("vmfnode_group"); + # Create prop_static visuals via RenderingServer (no nodes needed, works in editor + runtime) + _create_runtime_visuals(); + + # Create collision bodies at runtime via PhysicsServer3D (no nodes needed) + if not Engine.is_editor_hint(): + _create_runtime_collisions(); + +func _exit_tree() -> void: + _cleanup_runtime_visuals(); + _cleanup_runtime_collisions(); + +## Creates physics bodies from stored collision data using PhysicsServer3D directly. +## No StaticBody3D/CollisionShape3D nodes are created — just server-side RIDs. +func _create_runtime_collisions() -> void: + var collision_data: Array = _load_collision_data(); + if collision_data.is_empty(): return; + + var ps := PhysicsServer3D; + var space := get_world_3d().space; + + for entry in collision_data: + var shape: Shape3D = entry.shape; + if not shape: continue; + var body := ps.body_create(); + ps.body_set_mode(body, PhysicsServer3D.BODY_MODE_STATIC); + ps.body_set_space(body, space); + ps.body_add_shape(body, shape.get_rid()); + ps.body_set_state(body, PhysicsServer3D.BODY_STATE_TRANSFORM, entry.transform); + ps.body_set_collision_layer(body, default_physics_mask); + _physics_bodies.append(body); + +## Loads collision data from the external .res file referenced in metadata. +## Falls back to inline metadata for backwards compatibility. +func _load_collision_data() -> Array: + var path: String = get_meta("prop_collision_path", ""); + if not path.is_empty() and ResourceLoader.exists(path): + var res = ResourceLoader.load(path); + if res and res is Resource: + return res.get_meta("collision_data", []); + # Fallback: inline metadata (backwards compatibility) + return get_meta("prop_collision_data", []); + +## Saves collected collision data to an external .res file and stores the path in metadata. +## Call after all entities have been imported. +func _save_collision_data_external() -> void: + var collision_data: Array = get_meta("prop_collision_data", []); + if collision_data.is_empty(): return; + + var save_path := "%s/%s_collision_data.res" % [VMFConfig.import.geometry_folder, _vmf_identifer()]; + + if not DirAccess.dir_exists_absolute(save_path.get_base_dir()): + DirAccess.make_dir_recursive_absolute(save_path.get_base_dir()); + + # Store collision data as metadata on a lightweight Resource + var container := Resource.new(); + container.set_meta("collision_data", collision_data); + + var err := ResourceSaver.save(container, save_path, ResourceSaver.FLAG_COMPRESS); + if err: + VMFLogger.error("Failed to save collision data: %s" % err); + return; + + # Replace inline metadata with a path reference + remove_meta("prop_collision_data"); + set_meta("prop_collision_path", save_path); + +## Saves the prop_static visual manifest to an external .res file. +func _save_visual_manifest(manifest: Array) -> void: + var save_path := "%s/%s_visuals.res" % [VMFConfig.import.geometry_folder, _vmf_identifer()]; + + if not DirAccess.dir_exists_absolute(save_path.get_base_dir()): + DirAccess.make_dir_recursive_absolute(save_path.get_base_dir()); + + var container := Resource.new(); + container.set_meta("entries", manifest); + + var err := ResourceSaver.save(container, save_path, ResourceSaver.FLAG_COMPRESS); + if err: + VMFLogger.error("Failed to save visual manifest: %s" % err); + return; + + set_meta("prop_visuals_path", save_path); + +func _cleanup_runtime_collisions() -> void: + var ps := PhysicsServer3D; + for body in _physics_bodies: + ps.free_rid(body); + _physics_bodies.clear(); + +## Creates prop_static visuals + collision from the saved manifest. +## Uses individual RenderingServer instances per prop (godotwind pattern). +## No MultiMesh, no chunking — Godot handles per-instance frustum culling via AABB. +## Runs in both editor (@tool) and runtime. +func _create_runtime_visuals() -> void: + var manifest_path: String = get_meta("prop_visuals_path", ""); + if manifest_path.is_empty(): + return; + if not ResourceLoader.exists(manifest_path): + print("[VMF] Visuals manifest not found: ", manifest_path); + return; + + var manifest_res = ResourceLoader.load(manifest_path); + if not manifest_res or not (manifest_res is Resource): + print("[VMF] Failed to load visuals manifest"); + return; + + var entries: Array = manifest_res.get_meta("entries", []); + if entries.is_empty(): + print("[VMF] Visuals manifest is empty"); + return; + + print("[VMF] Loading ", entries.size(), " prop_static entries via RenderingServer"); + + var rs := RenderingServer; + var scenario := get_world_3d().scenario; + var ps := PhysicsServer3D; + var space: RID = get_world_3d().space if not Engine.is_editor_hint() else RID(); + var do_collision := not Engine.is_editor_hint(); + + # Step 1: Collect unique model paths + var unique_models := {}; # model_key -> model_path + for entry in entries: + var model_key: String = entry.model; + if unique_models.has(model_key): continue; + unique_models[model_key] = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model_key); + + print("[VMF] Loading ", unique_models.size(), " unique models"); + + # Step 2: Load models and build mesh cache + var mesh_cache := {}; # group_key -> { mesh_rid, mesh_resource, collision_shapes } + var loaded_count := 0; + + for model_key in unique_models: + var model_path: String = unique_models[model_key]; + if not ResourceLoader.exists(model_path): continue; + var model_scene: PackedScene = ResourceLoader.load(model_path) as PackedScene; + if not model_scene: continue; + + var temp = model_scene.instantiate(); + if not temp or not (temp is MeshInstance3D): + if temp: temp.free() + continue; + + var mesh: ArrayMesh = temp.mesh; + var skin_meta := {}; + for meta_key in temp.get_meta_list(): + if meta_key.begins_with("skin_"): + skin_meta[meta_key] = temp.get_meta(meta_key); + + var collision_shapes: Array[Shape3D] = []; + var stack = [temp]; + while stack.size() > 0: + var node = stack.pop_back(); + if node is CollisionShape3D and node.shape: + collision_shapes.append(node.shape.duplicate()); + for child in node.get_children(): + stack.append(child); + + temp.free(); + + if not mesh: + continue; + + loaded_count += 1; + + # Pre-cache all skin variants for this model + # Default (no skin) entry + var base_key: String = model_key + "::0"; + if not mesh_cache.has(base_key): + _visual_resources.append(mesh); + mesh_cache[base_key] = { + "mesh_rid": mesh.get_rid(), + "mesh_resource": mesh, + "collision_shapes": collision_shapes, + }; + + for skin_key_name in skin_meta: + var skin_id_str: String = skin_key_name.replace("skin_", ""); + var group_key: String = model_key + "::" + skin_id_str; + if mesh_cache.has(group_key): continue; + + var skinned_mesh: ArrayMesh = mesh.duplicate() as ArrayMesh; + var materials = skin_meta[skin_key_name]; + for s in range(skinned_mesh.get_surface_count()): + if s < materials.size(): + skinned_mesh.surface_set_material(s, materials[s]); + _visual_resources.append(skinned_mesh); + mesh_cache[group_key] = { + "mesh_rid": skinned_mesh.get_rid(), + "mesh_resource": skinned_mesh, + "collision_shapes": collision_shapes, + }; + + print("[VMF] Loaded ", loaded_count, "/", unique_models.size(), " model variants, creating instances..."); + + # Step 3: Create RenderingServer + PhysicsServer instances for all entries + for entry in entries: + var group_key: String = entry.model + "::" + str(entry.skin); + var cached = mesh_cache.get(group_key); + if cached == null: continue; + + # Build transform with model_scale applied + var t: Transform3D = entry.transform; + var ms: float = entry.model_scale; + t.basis = t.basis.scaled(Vector3.ONE * ms * ms); + + # Create RenderingServer visual instance + var instance := rs.instance_create(); + rs.instance_set_scenario(instance, scenario); + rs.instance_set_base(instance, cached.mesh_rid); + rs.instance_set_transform(instance, t); + rs.instance_geometry_set_flag(instance, RenderingServer.INSTANCE_FLAG_USE_BAKED_LIGHT, true); + rs.instance_geometry_set_cast_shadows_setting(instance, RenderingServer.SHADOW_CASTING_SETTING_ON); + + # Visibility range (fade) + var fade_max: float = entry.fade_max; + var fade_min: float = entry.fade_min; + var ssf: bool = entry.ssf; + if fade_max > 0.0: + var fade_mode = RenderingServer.VISIBILITY_RANGE_FADE_SELF \ + if ssf else RenderingServer.VISIBILITY_RANGE_FADE_DISABLED; + var end_margin: float = (fade_max - fade_min) if ssf else 0.0; + rs.instance_geometry_set_visibility_range(instance, 0.0, fade_max, 0.0, end_margin, fade_mode); + + _visual_instances.append(instance); + + # Collision (runtime only) + if do_collision and not cached.collision_shapes.is_empty(): + for shape in cached.collision_shapes: + var body := ps.body_create(); + ps.body_set_mode(body, PhysicsServer3D.BODY_MODE_STATIC); + ps.body_set_space(body, space); + ps.body_add_shape(body, shape.get_rid()); + ps.body_set_state(body, PhysicsServer3D.BODY_STATE_TRANSFORM, t); + ps.body_set_collision_layer(body, default_physics_mask); + _physics_bodies.append(body); + + print("[VMF] Created ", _visual_instances.size(), " RenderingServer instances"); + +func _cleanup_runtime_visuals() -> void: + var rs := RenderingServer; + for instance in _visual_instances: + if instance.is_valid(): + rs.free_rid(instance); + _visual_instances.clear(); + _visual_resources.clear(); + +## Size of each navmesh tile edge in Godot units. Smaller = more tiles = less geometry per bake. +const NAVMESH_TILE_SIZE := 128.0 + +## Bakes navigation mesh using tiled approach to avoid Recast crashes on large maps. +## Splits the map into NxN tiles, bakes each separately. Multiple NavigationRegion3D nodes +## on the same map merge automatically for seamless pathfinding. +func bake_navmesh_with_collisions() -> void: + # Clean up any existing navmesh tiles + var nav_parent = get_node_or_null("NavigationMesh"); + if nav_parent: + nav_parent.free(); + + # Collect all source geometry + var source_geo := NavigationMeshSourceGeometryData3D.new(); + + # Add world geometry meshes + var geom = geometry; + if geom: + if geom is MeshInstance3D and geom.mesh: + source_geo.add_mesh(geom.mesh, geom.global_transform); + else: + for child in geom.get_children(): + if child is MeshInstance3D and child.mesh: + source_geo.add_mesh(child.mesh, child.global_transform); + + # Add stored collision data + var collision_data: Array = _load_collision_data(); + for entry in collision_data: + var shape: Shape3D = entry.shape; + if shape is ConcavePolygonShape3D: + source_geo.add_faces(shape.get_faces(), entry.transform); + + # Add prop_static meshes from manifest + var manifest_path: String = get_meta("prop_visuals_path", ""); + if not manifest_path.is_empty() and ResourceLoader.exists(manifest_path): + var manifest_res = ResourceLoader.load(manifest_path); + if manifest_res and manifest_res is Resource: + var entries: Array = manifest_res.get_meta("entries", []); + var nav_mesh_cache := {}; + for entry in entries: + var model_key: String = entry.model; + if not nav_mesh_cache.has(model_key): + var model_path = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model_key); + if ResourceLoader.exists(model_path): + var model_scene: PackedScene = ResourceLoader.load(model_path) as PackedScene; + if model_scene: + var temp = model_scene.instantiate(); + if temp and temp is MeshInstance3D: + nav_mesh_cache[model_key] = temp.mesh; + temp.free(); + else: + if temp: temp.free() + nav_mesh_cache[model_key] = null; + else: + nav_mesh_cache[model_key] = null; + else: + nav_mesh_cache[model_key] = null; + + var mesh: Mesh = nav_mesh_cache[model_key]; + if not mesh: continue; + + var t: Transform3D = entry.transform; + var ms: float = entry.model_scale; + t.basis = t.basis.scaled(Vector3.ONE * ms * ms); + source_geo.add_mesh(mesh, t); + + if not source_geo.has_data(): + print("[VMF] Navmesh bake: no source geometry found"); + return; + + # Get total bounds and compute dynamic tile count + var bounds: AABB = source_geo.get_bounds(); + var tiles_x: int = max(1, int(ceil(bounds.size.x / NAVMESH_TILE_SIZE))); + var tiles_z: int = max(1, int(ceil(bounds.size.z / NAVMESH_TILE_SIZE))); + var tile_size_x: float = bounds.size.x / tiles_x; + var tile_size_z: float = bounds.size.z / tiles_z; + var total_tiles: int = tiles_x * tiles_z; + + print("[VMF] Navmesh bake: bounds=", bounds, " tiles=", tiles_x, "x", tiles_z, " (", total_tiles, " total)"); + + # Create parent node for all navmesh tiles + var nav_container := Node3D.new(); + nav_container.name = "NavigationMesh"; + add_child(nav_container); + nav_container.set_owner(_owner); + + # Bake each tile + var baked := 0; + + for tx in range(tiles_x): + for tz in range(tiles_z): + var tile_pos := Vector3( + bounds.position.x + tx * tile_size_x, + bounds.position.y, + bounds.position.z + tz * tile_size_z + ); + var nav := NavigationMesh.new(); + nav.cell_size = 0.25; + nav.cell_height = 0.25; + nav.agent_radius = 0.1; + nav.agent_height = 1.44; + nav.agent_max_climb = 0.36; + nav.border_size = 0.5; + nav.edge_max_error = 1.0; + + # Expand tile AABB by border_size so adjacent tiles overlap and connect + var tile_aabb := AABB(tile_pos, Vector3(tile_size_x, bounds.size.y, tile_size_z)); + tile_aabb = tile_aabb.grow(nav.border_size); + nav.filter_baking_aabb = tile_aabb; + + var navreg := NavigationRegion3D.new(); + navreg.name = "tile_%d_%d" % [tx, tz]; + navreg.navigation_mesh = nav; + + nav_container.add_child(navreg); + navreg.set_owner(_owner); + + NavigationServer3D.bake_from_source_geometry_data(nav, source_geo); + baked += 1; + print("[VMF] Navmesh bake: tile %d/%d done" % [baked, total_tiles]); + func clear_scene_groups(): var tree = get_tree(); var groups := get_tree().edited_scene_root.get_groups() if tree else self.get_groups(); @@ -90,49 +480,110 @@ func clear_scene_groups(): func reimport_geometry() -> void: VMFConfig.load_config(); read_vmf(); - VMFResourceManager.import_materials(vmf_structure, is_runtime); + VMFResourceManager.init_vpk_stack(); + VMFResourceManager.import_materials(vmf_structure, is_runtime); + VMFResourceManager.free_vpk_stack(); await VMFResourceManager.for_resource_import(); - import_geometry(); +func generate_detail_props(geometry_mesh: MeshInstance3D) -> void: + var detail_props := VMFDetailProps.generate(geometry_mesh.mesh); + if detail_props.size() == 0: return; + + var detail_node := Node3D.new(); + + detail_node.name = "DetailProps"; + detail_node.set_display_folded(true); + geometry_mesh.add_child(detail_node); + detail_node.set_owner(_owner); + + for prop in detail_props: + var mmi := MultiMeshInstance3D.new(); + mmi.multimesh = prop; + mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if prop.get_meta("cast_shadows", true) \ + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF; + + detail_node.add_child(mmi); + mmi.set_owner(_owner); + +func generate_shadow_mesh(raw_geometry_mesh: ArrayMesh) -> void: + var shadow_mesh := VMFTool.generate_shadow_mesh(raw_geometry_mesh); + var shadow_mesh_instance := MeshInstance3D.new(); + shadow_mesh_instance.name = "ShadowMesh"; + shadow_mesh_instance.mesh = shadow_mesh; + shadow_mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY; + geometry.add_child(shadow_mesh_instance); + shadow_mesh_instance.set_owner(_owner); + +func unwrap_lightmap(geometry_mesh: MeshInstance3D) -> void: + var texel_size = VMFConfig.import.lightmap_texel_size; + if VMFConfig.import.generate_lightmap_uv2 and not is_runtime: + geometry_mesh.mesh.lightmap_unwrap(geometry_mesh.global_transform, texel_size); + +## Merges func_detail brush solids into the world geometry solids array. +## func_detail is just detailed world geometry — no runtime behavior, safe to merge. +func _merge_func_detail_solids() -> void: + for ent in vmf_structure.entities: + if ent.classname != "func_detail": continue; + if not "solid" in ent.data: continue; + var raw_solids = ent.data.solid if ent.data.solid is Array else [ent.data.solid]; + for raw_solid in raw_solids: + vmf_structure.solids.append(VMFSolid.new(raw_solid)); + func import_geometry() -> void: - 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; + # Merge func_detail solids into world geometry (same mesh + collision) + _merge_func_detail_solids(); - var geometry_mesh := MeshInstance3D.new() - geometry_mesh.name = "Geometry"; - geometry_mesh.set_display_folded(true); - - 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 result = VMFTool.create_mesh(vmf_structure, Vector3.ZERO, remove_merged_faces); + if not result: return; - var transform = geometry_mesh.global_transform; - var texel_size = VMFConfig.import.lightmap_texel_size; + # Handle single mesh or array of meshes (split at 256 surface limit) + var mesh_list: Array[ArrayMesh] = []; + if result is ArrayMesh: + mesh_list.append(result); + elif result is Array: + mesh_list.assign(result); - geometry_mesh.mesh = mesh; + # Create parent Geometry node + var geom_parent := Node3D.new(); + geom_parent.name = "Geometry"; + geom_parent.set_display_folded(true); + add_child(geom_parent); + geom_parent.set_owner(_owner); - VMFTool.generate_collisions(geometry_mesh, default_physics_mask); - save_collision_file(); + var first_mesh_instance: MeshInstance3D = null; - if not get_meta("instance", false): - generate_navmesh(geometry_mesh); + for i in range(mesh_list.size()): + var geometry_mesh := MeshInstance3D.new(); + geometry_mesh.name = "mesh_%d" % i if mesh_list.size() > 1 else "mesh"; + geometry_mesh.mesh = mesh_list[i]; - geometry_mesh.mesh = VMFTool.cleanup_mesh(geometry_mesh.mesh); + if double_sided_shadow_cast: + geometry_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_DOUBLE_SIDED; - if VMFConfig.import.generate_lightmap_uv2 and not is_runtime: - geometry_mesh.mesh.lightmap_unwrap(geometry_mesh.global_transform, texel_size); + geom_parent.add_child(geometry_mesh); + geometry_mesh.set_owner(_owner); + + VMFTool.generate_collisions(geometry_mesh, default_physics_mask); + cleanup_geometry(geometry_mesh); + unwrap_lightmap(geometry_mesh); - geometry_mesh.mesh = save_geometry_file(geometry_mesh.mesh); + if i == 0: + first_mesh_instance = geometry_mesh; -func generate_navmesh(geometry_mesh: MeshInstance3D): + if first_mesh_instance: + save_collision_file(); + generate_navmesh(geom_parent); + generate_shadow_mesh(first_mesh_instance.mesh); + generate_detail_props(first_mesh_instance); + save_geometry_file(first_mesh_instance); + +func generate_navmesh(geometry_node: Node3D): if not VMFConfig.import.use_navigation_mesh: return; + if get_meta("instance", false): return; var navreg := NavigationRegion3D.new(); @@ -157,12 +608,16 @@ func generate_navmesh(geometry_mesh: MeshInstance3D): add_child(navreg); navreg.set_owner(_owner); - geometry_mesh.reparent(navreg); + geometry_node.reparent(navreg); navreg.bake_navigation_mesh.call_deferred(); -func save_geometry_file(target_mesh: Mesh): - if not save_geometry: return target_mesh; +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: + 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()]; if not DirAccess.dir_exists_absolute(resource_path.get_base_dir()): @@ -174,22 +629,33 @@ func save_geometry_file(target_mesh: Mesh): return; target_mesh.take_over_path(resource_path); - return target_mesh; + target_mesh_instance.mesh = load(resource_path); func save_collision_file() -> void: if save_collision == false: return; - - var collisions = $Geometry.get_children() as Array[StaticBody3D]; - - for body in collisions: - var collision := body.get_node('collision'); + var geom = geometry; + if not geom: return; + + # Find all StaticBody3D nodes under Geometry (may be nested under MeshInstance3D children) + var bodies: Array[StaticBody3D] = []; + var stack: Array[Node] = [geom]; + while not stack.is_empty(): + var node = stack.pop_back(); + if node is StaticBody3D: + bodies.append(node); + for child in node.get_children(): + stack.append(child); + + for body in bodies: + var collision = body.get_node_or_null("collision"); + if not collision or not collision.shape: continue; var shape = collision.shape; var save_path := "%s/%s_collision_%s.res" % [VMFConfig.import.geometry_folder, _vmf_identifer(), body.name]; if not DirAccess.dir_exists_absolute(save_path.get_base_dir()): DirAccess.make_dir_recursive_absolute(save_path.get_base_dir()); - var error := ResourceSaver.save(collision.shape, save_path, ResourceSaver.FLAG_COMPRESS); + var error := ResourceSaver.save(shape, save_path, ResourceSaver.FLAG_COMPRESS); if error: VMFLogger.error("Failed to save collision resource: %s" % error); @@ -253,12 +719,90 @@ func reset_entities_node(): func reimport_entities(): read_vmf(); + + VMFResourceManager.init_vpk_stack(); + VMFResourceManager.import_materials(vmf_structure, is_runtime); + VMFResourceManager.import_models(vmf_structure); + VMFResourceManager.free_vpk_stack(); + await VMFResourceManager.for_resource_import(); import_entities(); +## Wipes import-time entity data, preserving only runtime-essential keys +static func _wipe_entity_import_data(node: VMFEntityNode) -> void: + var preserved := {}; + for key in RUNTIME_KEYS: + if key in node.entity: + preserved[key] = node.entity[key]; + node.entity = preserved; + node.reference = null; + +## Ensures model resources are loaded and cached. Returns resources dict or null. +## NOTE: No longer used during import — model loading is deferred to _create_runtime_visuals(). +## Kept for potential use by other code paths. +#func _ensure_model_resources(model_key: String) -> Variant: +# var resources = VMFCache.get_model_resources(model_key); +# if resources: return resources; +# var model_path = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model_key); +# var model_scene: PackedScene = VMFCache.get_cached(model_key); +# if not model_scene: +# if not ResourceLoader.exists(model_path): +# if not VMFCache.is_file_logged(model_key): +# VMFLogger.warn("Model not found: " + model_path); +# VMFCache.add_logged_file(model_key); +# return null; +# model_scene = ResourceLoader.load(model_path) as PackedScene; +# VMFCache.add_cached(model_key, model_scene); +# if not model_scene: return null; +# resources = prop_studio._extract_and_cache_resources(model_key, model_scene); +# return resources if resources and not resources.is_empty() else null; + +## Collects prop_static entity data and saves it as a lightweight manifest. +## No model loading, no MultiMesh building — all deferred to _ready() via RenderingServer. +## Returns a set of entity IDs that should be skipped in the normal entity loop. +func _preprocess_prop_statics() -> Dictionary: + var processed_ids := {}; + var geom = geometry; + if not geom: return processed_ids; + + var import_scale = VMFConfig.import.scale; + var entries: Array = []; + + for ent in vmf_structure.entities: + if ent.classname != "prop_static": continue; + var model_path_val = ent.data.get("model", ""); + if model_path_val.is_empty(): continue; + + entries.append({ + "model": model_path_val, + "skin": int(ent.data.get("skin", 0)), + "transform": VMFEntityNode.get_entity_transform(ent), + "model_scale": float(ent.data.get("modelscale", 1.0)), + "fade_min": float(ent.data.get("fademindist", 0.0)) * import_scale, + "fade_max": float(ent.data.get("fademaxdist", 0.0)) * import_scale, + "ssf": int(ent.data.get("screenspacefade", 0)) == 1, + }); + + processed_ids[ent.id] = true; + + # Save raw entity data to external file — no model loading, no mesh building + if not entries.is_empty(): + _save_visual_manifest(entries); + + return processed_ids; + +## NOTE: _chunk_instances, _create_multimesh_prop_static, _collect_prop_collision_data +## are no longer used — MultiMesh building + collision is deferred to _create_runtime_visuals(). + func import_entities() -> void: reset_entities_node(); + ## Pre-process prop_statics into MultiMesh groups (avoids instantiating individual nodes) + var processed_prop_statics := await _preprocess_prop_statics(); + for ent in vmf_structure.entities: + if ent.id in processed_prop_statics: continue; + if ent.classname == "func_detail": continue; # merged into world geometry + ent.data.vmf = vmf; var tscn = get_entity_scene(ent.classname); @@ -283,6 +827,10 @@ func import_entities() -> void: if node._apply_entity(ent.data) == -1: node._entity_setup(ent); + ## Wipe import-time entity data, preserving runtime-essential keys + if node is VMFEntityNode: + _wipe_entity_import_data(node); + func import_map() -> void: if not vmf: return; @@ -291,12 +839,37 @@ func import_map() -> void: clear_structure(); clear_scene_groups(); + + import_progress.emit("Parsing VMF", 0, 6); + if get_tree(): await get_tree().process_frame; read_vmf(); + VMFResourceManager.init_vpk_stack(); + + import_progress.emit("Importing materials", 1, 6); VMFResourceManager.import_materials(vmf_structure, is_runtime); - VMFResourceManager.import_models(vmf_structure); + import_progress.emit("Importing models", 2, 6); + VMFResourceManager.import_models(vmf_structure); + VMFResourceManager.free_vpk_stack(); await VMFResourceManager.for_resource_import(); + import_progress.emit("Building geometry", 3, 6); + if get_tree(): await get_tree().process_frame; import_geometry(); + + import_progress.emit("Importing entities", 4, 6); + if get_tree(): await get_tree().process_frame; import_entities(); + + import_progress.emit("Loading prop_statics", 5, 6); + if get_tree(): await get_tree().process_frame; + _save_collision_data_external(); + _create_runtime_visuals(); + + import_progress.emit("Baking navigation", 5, 6); + if get_tree(): await get_tree().process_frame; + if bake_navigation: + bake_navmesh_with_collisions(); + + import_progress.emit("Done", 6, 6); diff --git a/addons/godotvmf/src/vmf_resource_manager.gd b/addons/godotvmf/src/vmf_resource_manager.gd index 893cc73..80473cf 100644 --- a/addons/godotvmf/src/vmf_resource_manager.gd +++ b/addons/godotvmf/src/vmf_resource_manager.gd @@ -10,10 +10,23 @@ const MATERIAL_KEYS_TO_IMPORT = [ ]; static var has_imported_resources: bool = false; +static var vpk_stack: VPKStack; static func get_editor_interface(): return Engine.get_singleton("EditorInterface") if Engine.has_singleton("EditorInterface") else null; +## Initializes the VPK stack by loading all VPK files from the specified gameinfo directory. +## This should be called before attempting to import any resources, as it allows the manager +## to check for the existence of files within the VPKs and extract them if necessary. +static func init_vpk_stack() -> void: + if not VMFConfig.models.import and VMFConfig.materials.import_mode == VMFConfig.MaterialsConfig.ImportMode.USE_EXISTING: return; + vpk_stack = VPKStack.create(VMFConfig.gameinfo_path); + +static func free_vpk_stack() -> void: + if vpk_stack: + vpk_stack.free_vpks(); + vpk_stack = null; + static func for_resource_import() -> void: if not has_imported_resources: return; @@ -28,94 +41,159 @@ static func for_resource_import() -> void: has_imported_resources = false; -## Returns true if any resources were imported -static func import_models(vmf_structure: VMFStructure) -> bool: - if not VMFConfig.models.import: return false; - if not "entity" in vmf_structure: return false; +## Imports a single entity's model files and associated materials. +## Returns true if a model was actually imported. +static func import_model_for_entity(entity: VMFEntity) -> bool: + if not "model" in entity.data: return false; + if entity.classname != "prop_static": return false; + + var model_path = entity.data.get("model", "").to_lower().get_basename(); + if not model_path: return false; - for entity in vmf_structure.entity: - if not "model" in entity: continue; - if entity.classname != "prop_static": continue; + # Game directory paths + var gamedir_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path); + var mdl_path = VMFUtils.normalize_path(gamedir_path + ".mdl"); + var vtx_path = VMFUtils.normalize_path(gamedir_path + ".vtx"); + var vtx_dx90_path = VMFUtils.normalize_path(gamedir_path + ".dx90.vtx"); + var vvd_path = VMFUtils.normalize_path(gamedir_path + ".vvd"); + var phy_path = VMFUtils.normalize_path(gamedir_path + ".phy"); - var model_path = entity.get("model", "").to_lower().get_basename(); - if not model_path: continue; + # VPK paths + var vpk_mdl_path = VMFUtils.normalize_path(model_path + ".mdl"); + var vpk_vtx_path = VMFUtils.normalize_path(model_path + ".dx90.vtx"); + var vpk_vvd_path = VMFUtils.normalize_path(model_path + ".vvd"); + var vpk_phy_path = VMFUtils.normalize_path(model_path + ".phy"); - var mdl_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path + ".mdl"); - var vtx_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path + ".vtx"); - var vtx_dx90_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path + ".dx90.vtx"); - var vvd_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path + ".vvd"); - var phy_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/" + model_path + ".phy"); - var target_path = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model_path); + var target_path = VMFUtils.normalize_path(VMFConfig.models.target_folder + "/" + model_path); - if ResourceLoader.exists(target_path + ".mdl"): continue; + if ResourceLoader.exists(target_path + ".mdl"): return false; - if not FileAccess.file_exists(mdl_path): continue; - if not FileAccess.file_exists(vtx_path): vtx_path = vtx_dx90_path; - if not FileAccess.file_exists(vtx_path): continue; - if not FileAccess.file_exists(vvd_path): continue; + var found_in_game_dir := FileAccess.file_exists(mdl_path) \ + and FileAccess.file_exists(vtx_path) \ + and FileAccess.file_exists(vvd_path); - var model_materials = MDLReader.new(mdl_path).get_possible_material_paths(); + var found_in_vpk := file_exists_in_vpk(vpk_mdl_path) \ + and file_exists_in_vpk(vpk_vtx_path) \ + and file_exists_in_vpk(vpk_vvd_path); - for material_path in model_materials: - import_textures(material_path); - import_material(material_path); + if not found_in_game_dir and not found_in_vpk: + VMFLogger.error("Model files not found for: " + vpk_mdl_path); + return false; + if found_in_game_dir: DirAccess.make_dir_recursive_absolute(target_path.get_base_dir()); DirAccess.copy_absolute(vtx_path, target_path + '.dx90.vtx'); DirAccess.copy_absolute(vvd_path, target_path + ".vvd"); if FileAccess.file_exists(phy_path): DirAccess.copy_absolute(phy_path, target_path + ".phy"); DirAccess.copy_absolute(mdl_path, target_path + ".mdl"); - has_imported_resources = true; + elif found_in_vpk: + if not vpk_stack.extract(vpk_vtx_path, target_path + '.dx90.vtx'): + VMFLogger.error("Failed to extract VTX from VPK: " + vpk_vtx_path); + return false; + + if not vpk_stack.extract(vpk_vvd_path, target_path + ".vvd"): + VMFLogger.error("Failed to extract VVD from VPK: " + vpk_vvd_path); + return false; + + if file_exists_in_vpk(vpk_phy_path): + if not vpk_stack.extract(vpk_phy_path, target_path + ".phy"): + VMFLogger.error("Failed to extract PHY from VPK: " + vpk_phy_path); + + if not vpk_stack.extract(vpk_mdl_path, target_path + ".mdl"): + VMFLogger.error("Failed to extract MDL from VPK: " + vpk_mdl_path); + return false; + + var model_materials = MDLReader.new(target_path + ".mdl").get_possible_material_paths(); + + for material_path in model_materials: + import_textures(material_path); + import_material(material_path); + + has_imported_resources = true; + return true; + +## Imports all models from the VMF structure (non-batched, suitable for runtime). +## Returns true if any resources were imported. +static func import_models(vmf_structure: VMFStructure) -> bool: + if not VMFConfig.models.import: return false; + if vmf_structure.entities.size() == 0: return false; + + for entity in vmf_structure.entities: + import_model_for_entity(entity); return has_imported_resources; +static func file_exists_in_vpk(vpk_file_path: String) -> bool: + if not vpk_stack: return false; + return vpk_stack.exists(vpk_file_path); + static func import_material(material: String) -> bool: material = material.to_lower(); + var vpk_path = "materials/" + material + ".vmt"; var vmt_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/materials/" + material + ".vmt"); var target_path = VMFUtils.normalize_path(VMFConfig.materials.target_folder + "/" + material + ".vmt"); if ResourceLoader.exists(target_path): return false; - if not FileAccess.file_exists(vmt_path): return false; - DirAccess.make_dir_recursive_absolute(target_path.get_base_dir()); - var has_error = DirAccess.copy_absolute(vmt_path, target_path); + # Trying to find material in game directory first, as it can be overridden by mods and thus differ from the one in VPKs + if FileAccess.file_exists(vmt_path): + DirAccess.make_dir_recursive_absolute(target_path.get_base_dir()); + if DirAccess.copy_absolute(vmt_path, target_path): return false; - if has_error: return false; + has_imported_resources = true; - return true; + # Trying to find material in VPKs + elif file_exists_in_vpk(vpk_path): + if not vpk_stack.extract(vpk_path, target_path): + VMFLogger.error("Failed to extract material from VPK: " + vpk_path); + return false; -static func import_materials(vmf_structure: VMFStructure, is_runtime := false) -> void: - var editor_interface = get_editor_interface(); + has_imported_resources = true; - if VMFConfig.materials.import_mode == VMFConfig.MaterialsConfig.ImportMode.USE_EXISTING: - return; + else: + VMFLogger.error("Material not found: " + vpk_path); + return false; + return has_imported_resources; + +## Collects unique material names from the VMF structure, excluding ignored materials. +static func collect_material_list(vmf_structure: VMFStructure) -> Array[String]: var list: Array[String] = []; var ignore_list: Array[String]; ignore_list.assign(VMFConfig.materials.ignore); for solid in vmf_structure.solids: for side in solid.sides: - var isIgnored = ignore_list.any(func(rx: String) -> bool: return side.material.match(rx)); - if isIgnored: continue; + var is_ignored = ignore_list.any(func(rx: String) -> bool: return side.material.match(rx)); + if is_ignored: continue; if not list.has(side.material): list.append(side.material); for entity in vmf_structure.entities: - if not entity.has_solid: - continue; + if not entity.has_solid: continue; for solid in entity.solids: for side in solid.sides: - var isIgnored = ignore_list.any(func(rx): return side.material.match(rx)); - if isIgnored: continue; + var is_ignored = ignore_list.any(func(rx): return side.material.match(rx)); + if is_ignored: continue; if not list.has(side.material): list.append(side.material); + return list; + +## Imports all materials from the VMF structure (non-batched, suitable for runtime). +static func import_materials(vmf_structure: VMFStructure, is_runtime := false) -> void: + var editor_interface = get_editor_interface(); + + if VMFConfig.materials.import_mode == VMFConfig.MaterialsConfig.ImportMode.USE_EXISTING: + return; + + var list := collect_material_list(vmf_structure); + if not is_runtime and editor_interface: for material in list: import_textures(material); @@ -127,11 +205,24 @@ static func import_textures(material: String) -> bool: material = material.to_lower(); var target_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/materials/" + material + ".vmt"); - if not FileAccess.file_exists(target_path): - VMFLogger.error("Material not found: " + target_path); + var vmt_vpk_path = "materials/" + material + ".vmt"; + + var details: Dictionary = {}; + + if FileAccess.file_exists(target_path): + details = VDFParser.parse(target_path, true).values()[0]; + + elif file_exists_in_vpk(vmt_vpk_path): + var vmt_data = vpk_stack.get_file_data(vmt_vpk_path); + if not vmt_data: + VMFLogger.error("Failed to read material data from VPK: " + vmt_vpk_path); + return false; + details = VDFParser.parse_from_string(vmt_data.get_string_from_utf8(), true).values()[0]; + + else: + VMFLogger.error("Failed to find material for texture import: " + material); return false; - var details = VDFParser.parse(target_path, true).values()[0]; # NOTE: CS:GO/L4D if "insert" in details: @@ -139,18 +230,29 @@ static func import_textures(material: String) -> bool: for key in MATERIAL_KEYS_TO_IMPORT: if key not in details: continue; + var vpk_path = VMFUtils.normalize_path("materials/" + details[key].to_lower() + ".vtf"); var vtf_path = VMFUtils.normalize_path(VMFConfig.gameinfo_path + "/materials/" + details[key].to_lower() + ".vtf"); var target_vtf_path = VMFUtils.normalize_path(VMFConfig.materials.target_folder + "/" + details[key].to_lower() + ".vtf"); - if not FileAccess.file_exists(vtf_path): continue; if ResourceLoader.exists(target_vtf_path): continue; - DirAccess.make_dir_recursive_absolute(target_vtf_path.get_base_dir()); - var has_error = DirAccess.copy_absolute(vtf_path, target_vtf_path); - - if not has_error: - has_imported_resources = true; - continue; - VMFLogger.error("Failed to copy texture: " + str(has_error)); + # Trying to find texture in game directory first, as it can be overridden by mods and thus differ from the one in VPKs + if FileAccess.file_exists(vtf_path): + DirAccess.make_dir_recursive_absolute(target_vtf_path.get_base_dir()); + + if DirAccess.copy_absolute(vtf_path, target_vtf_path): + VMFLogger.error("Failed to copy texture: " + vtf_path); + else: + has_imported_resources = true; + + # Trying to find texture in VPKs + elif file_exists_in_vpk(vpk_path): + if not vpk_stack.extract(vpk_path, target_vtf_path): + VMFLogger.error("Failed to extract texture from VPK: " + vpk_path); + else: + has_imported_resources = true; + + else: + VMFLogger.error("Failed to copy texture: " + vpk_path); - return has_imported_resources; + return true; diff --git a/addons/godotvmf/src/vmf_tool.gd b/addons/godotvmf/src/vmf_tool.gd index 0595e4b..dff3653 100644 --- a/addons/godotvmf/src/vmf_tool.gd +++ b/addons/godotvmf/src/vmf_tool.gd @@ -1,6 +1,36 @@ @static_unload class_name VMFTool extends RefCounted +static func generate_shadow_mesh(source_mesh: ArrayMesh) -> ArrayMesh: + var shadow_mesh := ArrayMesh.new(); + var extend_corrector := Engine.get_main_loop().root.get_node_or_null("VMFExtendGeometryCorrector") as VMFGeometryCorrector; + var corrector := VMFGeometryCorrector.new() if not extend_corrector else extend_corrector; + + 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; + + var material_compile_keys: Array = material.get_meta("compile_keys", []); + var is_shadowmesh := false; + + for key in material_compile_keys: + if extend_corrector: + is_shadowmesh = extend_corrector._get_shadowmesh_keys().has(key); + if is_shadowmesh: break; + continue; + + is_shadowmesh = corrector._get_shadowmesh_keys().has(key); + if is_shadowmesh: break; + + if not is_shadowmesh: continue; + + shadow_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, source_mesh.surface_get_arrays(surface_idx)); + + return VMFUtils.merge_surfaces(shadow_mesh); + ## Generates collisions from mesh for each surface. It adds ability to use sufraceprop values static func generate_collisions(mesh_instance: MeshInstance3D, physics_mask: int): var bodies: Array[StaticBody3D] = []; @@ -26,18 +56,16 @@ static func generate_collisions(mesh_instance: MeshInstance3D, physics_mask: int if compilekeys.size() > 0: surface_prop = "tool_" + compilekeys[0]; - var has_nocollision_extender = extend_corrector and "nocollision" in extend_corrector; - var is_no_collision = false; + var is_no_collision := false; for key in compilekeys: - if has_nocollision_extender: - if extend_corrector.nocollision.has(key): - is_no_collision = true; - break; + if extend_corrector and extend_corrector._get_nocollision_keys().has(key): + is_no_collision = true; + break; if is_no_collision: break; - if corrector.nocollision.has(key): + if corrector._get_nocollision_keys().has(key): is_no_collision = true; break; @@ -81,13 +109,13 @@ static func generate_collisions(mesh_instance: MeshInstance3D, physics_mask: int VMFUtils.set_owner_recursive(static_body, mesh_instance.get_owner()); ## Clear mesh from ignored textures and materials -static func cleanup_mesh(original_mesh: ArrayMesh): +static func cleanup_mesh(original_mesh: ArrayMesh) -> ArrayMesh: var is_44 = Engine.get_version_info().minor >= 4; var ignored_textures = VMFConfig.materials.ignore; var duplicated_mesh = ArrayMesh.new() if not is_44 else null; var corrector := VMFGeometryCorrector.new(); - var extend_corrector = Engine.get_main_loop().root.get_node_or_null("VMFExtendGeometryCorrector"); + var extend_corrector := Engine.get_main_loop().root.get_node_or_null("VMFExtendGeometryCorrector") as VMFGeometryCorrector; var mt = MeshDataTool.new() if not is_44 else null; var surface_removed = 0; @@ -100,8 +128,8 @@ static func cleanup_mesh(original_mesh: ArrayMesh): var material = original_mesh.surface_get_material(surface_idx); var compilekeys = material.get_meta("compile_keys", []) if material else []; - var is_ignored = ignored_textures.any(func(rx: String) -> bool: return material_name.match(rx.to_lower())); - if is_ignored and is_44: + var is_material_ignored = VMTLoader.is_material_ignored(material_name); + if is_material_ignored and is_44: original_mesh.surface_remove(surface_idx); surface_removed += 1; continue; @@ -109,12 +137,12 @@ static func cleanup_mesh(original_mesh: ArrayMesh): var is_norender = false; for key in compilekeys: - if is_ignored: break; - if extend_corrector and "norender" in extend_corrector.norender: - is_norender = extend_corrector.norender.has(key); + if is_material_ignored: break; + if extend_corrector: + is_norender = extend_corrector._get_norender_keys().has(key); break; - is_norender = corrector.norender.has(key); + is_norender = corrector._get_norender_keys().has(key); if is_norender: break; if is_norender and is_44: @@ -167,9 +195,221 @@ static func remove_merged_faces(brush_a: VMFSolid, brushes: Array[VMFSolid]) -> if a_removed: break; +## Optimized version using spatial hashing — only compares nearby brushes. +## Reduces O(N²) to roughly O(N×K) where K = average brushes per cell. +static func remove_merged_faces_spatial(brushes: Array[VMFSolid], cell_size: float = 128.0) -> void: + if brushes.size() == 0: return; + + # Step 1: Compute AABB for each brush from side vertices + var brush_aabbs: Array[AABB] = []; + for brush in brushes: + var bb_min := Vector3(INF, INF, INF); + var bb_max := Vector3(-INF, -INF, -INF); + for side in brush.sides: + for v in side.vertices: + bb_min = Vector3(min(bb_min.x, v.x), min(bb_min.y, v.y), min(bb_min.z, v.z)); + bb_max = Vector3(max(bb_max.x, v.x), max(bb_max.y, v.y), max(bb_max.z, v.z)); + brush_aabbs.append(AABB(bb_min, bb_max - bb_min)); + + # Step 2: Build spatial hash grid + var grid := {}; # "cx,cy,cz" -> Array of brush indices + for i in range(brushes.size()): + var aabb = brush_aabbs[i]; + if aabb.size == Vector3.ZERO: continue; + var min_cell := Vector3i( + int(floor(aabb.position.x / cell_size)), + int(floor(aabb.position.y / cell_size)), + int(floor(aabb.position.z / cell_size)) + ); + var max_cell := Vector3i( + int(floor(aabb.end.x / cell_size)), + int(floor(aabb.end.y / cell_size)), + int(floor(aabb.end.z / cell_size)) + ); + for cx in range(min_cell.x, max_cell.x + 1): + for cy in range(min_cell.y, max_cell.y + 1): + for cz in range(min_cell.z, max_cell.z + 1): + var key := Vector3i(cx, cy, cz); + if not grid.has(key): + grid[key] = []; + grid[key].append(i); + + # Step 3: For each brush, compare only against brushes in same cells + var checked := {}; # "i,j" -> true (avoid checking same pair twice) + for i in range(brushes.size()): + var aabb = brush_aabbs[i]; + if aabb.size == Vector3.ZERO: continue; + var brush_a = brushes[i]; + + # Collect unique neighbor indices from all cells this brush overlaps + var neighbors := {}; + var min_cell := Vector3i( + int(floor(aabb.position.x / cell_size)), + int(floor(aabb.position.y / cell_size)), + int(floor(aabb.position.z / cell_size)) + ); + var max_cell := Vector3i( + int(floor(aabb.end.x / cell_size)), + int(floor(aabb.end.y / cell_size)), + int(floor(aabb.end.z / cell_size)) + ); + for cx in range(min_cell.x, max_cell.x + 1): + for cy in range(min_cell.y, max_cell.y + 1): + for cz in range(min_cell.z, max_cell.z + 1): + var key := Vector3i(cx, cy, cz); + if grid.has(key): + for j in grid[key]: + if j != i: + neighbors[j] = true; + + # Compare against neighbors only + for j in neighbors: + var pair_key := mini(i, j) * 100000 + maxi(i, j); + if pair_key in checked: continue; + checked[pair_key] = true; + + var brush_b = brushes[j]; + + for side_a in brush_a.sides: + var a_removed = false; + + for side_b in brush_b.sides: + if side_a.plane.normal.dot(side_b.plane.normal) > -0.99: continue; + if side_a.plane.get_center().distance_to(side_b.plane.get_center()) > 0.01: continue; + + var material_a: Material = VMTLoader.get_material(side_a.material); + var material_b: Material = VMTLoader.get_material(side_b.material); + if is_material_transparent(material_a): continue; + if is_material_transparent(material_b): continue; + + if side_a.is_equal_to(side_b): + brush_b.sides.erase(side_b); + brush_a.sides.erase(side_a); + a_removed = true; + break; + + if side_a.is_inside_of_face(side_b): + brush_a.sides.erase(side_a); + a_removed = true; + break; + + if a_removed: break; + + +## Builds a single material surface and commits it to the given mesh. +## Extracted from create_mesh() to allow batched/chunked imports with yield points. +static func build_surface(mesh: ArrayMesh, sides: Array, import_scale: float, offset: Vector3, optimized: bool) -> void: + var sf := SurfaceTool.new(); + sf.begin(Mesh.PRIMITIVE_TRIANGLES); + + var index: int = 0; + for side: VMFSide in sides: + var base_index := index; + + if not side.is_displacement and side.solid.has_displacement: + continue; + + if side.vertices.size() < 3: + VMFLogger.error("Side corrupted: " + str(side.id)); + continue; + + if not side.plane: continue; + + if not side.is_displacement: + var normal = side.plane.normal; + var sg := -1 if side.smoothing_groups == 0 else side.smoothing_groups; + + sf.set_normal(Vector3(normal.x, normal.z, -normal.y)); + sf.set_color(Color(1, 1, 1)); + sf.set_smooth_group(sg); + + for v: Vector3 in side.vertices: + sf.set_uv(side.get_uv(v)); + sf.add_vertex(Vector3(v.x, v.z, -v.y) * import_scale - offset); + index += 1; + + for i: int in range(1, side.vertices.size() - 1): + sf.add_index(base_index); + sf.add_index(base_index + i); + sf.add_index(base_index + i + 1); + else: + var disp: VMFDisplacementInfo = side.dispinfo; + var edges_count := int(disp.edges_count); + var verts_count := int(disp.verts_count); + sf.set_smooth_group(1); + + for i: int in range(0, side.dispinfo.vertices.size()): + var x := i / verts_count; + var y := i % verts_count; + var v := side.dispinfo.vertices[i]; + var normal := disp.get_normal(x, y); + var dist := disp.get_distance(x, y); + var voffset := disp.get_offset(x, y); + var uv := side.get_uv(v - dist - voffset); + + sf.set_uv(uv); + sf.set_color(disp.get_color(x, y)); + sf.set_normal(Vector3(normal.x, normal.z, -normal.y)); + sf.add_vertex(Vector3(v.x, v.z, -v.y) * import_scale - offset); + index += 1; + + for i: int in range(0, pow(edges_count, 2)): + var x := i / edges_count; + var y := i % edges_count; + var is_odd := (x + y) % 2 == 1; + + if is_odd: + sf.add_index(base_index + x + 1 + y * verts_count); + sf.add_index(base_index + x + (y + 1) * verts_count); + sf.add_index(base_index + x + 1 + (y + 1) * verts_count); -## Returns MeshInstance3D from parsed VMF structure -static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.ZERO, optimized: bool = true) -> ArrayMesh: + sf.add_index(base_index + x + y * verts_count); + sf.add_index(base_index + x + (y + 1) * verts_count); + sf.add_index(base_index + x + 1 + y * verts_count); + else: + sf.add_index(base_index + x + y * verts_count); + sf.add_index(base_index + x + (y + 1) * verts_count); + sf.add_index(base_index + x + 1 + (y + 1) * verts_count); + + sf.add_index(base_index + x + y * verts_count); + sf.add_index(base_index + x + 1 + (y + 1) * verts_count); + sf.add_index(base_index + x + 1 + y * verts_count); + + # NOTE: In case no mesh were generated just skip commiting + if index == 0: return; + + var material = VMTLoader.get_material(sides[0].material); + if material: sf.set_material(material); + + if optimized: sf.optimize_indices_for_cache(); + sf.generate_normals(); + sf.generate_tangents(); + sf.commit(mesh); + + mesh.set_meta("surface_material_" + str(mesh.get_surface_count() - 1), sides[0].material); + +## Collects brush sides grouped by material name. Used by both create_mesh() and batched import. +static func collect_material_sides(brushes: Array[VMFSolid], optimized: bool) -> Dictionary: + if optimized: + remove_merged_faces_spatial(brushes); + + var material_sides: Dictionary = {}; + + for brush in brushes: + for side: VMFSide in brush.sides: + var material: String = side.material.to_upper(); + + if not material in material_sides: + material_sides[material] = []; + + material_sides[material].append(side); + + return material_sides; + +## Returns ArrayMesh(es) from parsed VMF structure. +## Auto-splits at 256 surfaces (Godot's MAX_MESH_SURFACES limit). +## Returns an Array of ArrayMesh. Single-entity callers can just use [0]. +static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.ZERO, optimized: bool = true) -> Variant: var import_scale := VMFConfig.import.scale; if vmf_structure.solids.size() == 0: @@ -177,11 +417,14 @@ static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.Z var brushes := vmf_structure.solids; var material_sides: Dictionary = {}; + var meshes: Array[ArrayMesh] = []; var mesh := ArrayMesh.new(); + var surface_count := 0; - for brush in brushes: - if optimized: remove_merged_faces(brush, brushes); + if optimized: + remove_merged_faces_spatial(brushes); + for brush in brushes: for side: VMFSide in brush.sides: var material: String = side.material.to_upper(); @@ -212,9 +455,9 @@ static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.Z var sg := -1 if side.smoothing_groups == 0 else side.smoothing_groups; sf.set_normal(Vector3(normal.x, normal.z, -normal.y)); - sf.set_color(Color(1, 1, 1)); + sf.set_color(Color(0, 0, 0)); sf.set_smooth_group(sg); - + for v: Vector3 in side.vertices: sf.set_uv(side.get_uv(v)); sf.add_vertex(Vector3(v.x, v.z, -v.y) * import_scale - offset); @@ -272,15 +515,27 @@ static func create_mesh(vmf_structure: VMFStructure, offset: Vector3 = Vector3.Z var material = VMTLoader.get_material(sides[0].material); if material: sf.set_material(material); - + if optimized: sf.optimize_indices_for_cache(); sf.generate_normals(); sf.generate_tangents(); sf.commit(mesh); mesh.set_meta("surface_material_" + str(mesh.get_surface_count() - 1), sides[0].material); + surface_count += 1; + + # Split at Godot's 256 surface limit + if surface_count >= 256: + meshes.append(mesh); + mesh = ArrayMesh.new(); + surface_count = 0; + + if mesh.get_surface_count() > 0: + meshes.append(mesh); - return mesh; + if meshes.is_empty(): return null; + if meshes.size() == 1: return meshes[0]; + return meshes; static func generate_lods(mesh: ArrayMesh) -> ArrayMesh: if not mesh.get_surface_count(): return mesh; diff --git a/addons/godotvmf/src/vmf_utils.gd b/addons/godotvmf/src/vmf_utils.gd index 29e1991..7080e97 100644 --- a/addons/godotvmf/src/vmf_utils.gd +++ b/addons/godotvmf/src/vmf_utils.gd @@ -23,3 +23,39 @@ static func object_assign(target: Object, source: Dictionary) -> void: for key in source.keys(): if key in target: target[key] = source[key]; + +static func merge_surfaces(mesh: ArrayMesh) -> ArrayMesh: + var result := ArrayMesh.new(); + var surface_count := mesh.get_surface_count(); + if surface_count == 0: + return result; + + var combined_vertices := PackedVector3Array(); + var combined_indices := PackedInt32Array(); + var vertex_offset := 0; + var primitive_type := mesh.surface_get_primitive_type(0); + + for surface_index in range(surface_count): + var arrays := mesh.surface_get_arrays(surface_index); + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]; + if vertices.is_empty(): + continue; + + combined_vertices.append_array(vertices); + + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]; + if indices.is_empty(): + for i in range(vertices.size()): + combined_indices.append(vertex_offset + i); + else: + for index in indices: + combined_indices.append(index + vertex_offset); + + vertex_offset += vertices.size(); + + var surface_arrays := []; + surface_arrays.resize(Mesh.ARRAY_MAX); + surface_arrays[Mesh.ARRAY_VERTEX] = combined_vertices; + surface_arrays[Mesh.ARRAY_INDEX] = combined_indices; + result.add_surface_from_arrays(primitive_type, surface_arrays); + return result; diff --git a/addons/godotvmf/godotvdf/vdf_parser.gd b/addons/godotvmf/utils/vdf_parser.gd similarity index 84% rename from addons/godotvmf/godotvdf/vdf_parser.gd rename to addons/godotvmf/utils/vdf_parser.gd index eadfd6b..226c172 100644 --- a/addons/godotvmf/godotvdf/vdf_parser.gd +++ b/addons/godotvmf/utils/vdf_parser.gd @@ -5,6 +5,7 @@ class_name VDFParser extends RefCounted; # Precompile the regular expressions only once static var _propRegex := RegEx.create_from_string('^"?(.*?)?"?\\s+"?(.*?)?"?(?:$|(\\s\\[.+\\]))$'); static var _vectorRegex := RegEx.create_from_string('^([-\\d\\.e]+)\\s([-\\d\\.e]+)\\s([-\\d\\.e]+)$'); +static var _vector2Regex := RegEx.create_from_string('^([-\\d\\.e]+)\\s([-\\d\\.e]+)$'); static var _colorRegex := RegEx.create_from_string('^([-\\d\\.e]+)\\s([-\\d\\.e]+)\\s([-\\d\\.e]+)\\s([-\\d\\.e]+)$'); static var _uvRegex := RegEx.create_from_string('\\[([-\\d\\.e]+)\\s([-\\d\\.e]+)\\s([-\\d\\.e]+)\\s([-\\d\\.e]+)\\]\\s([-\\d\\.e]+)'); static var _planeRegex := RegEx.create_from_string('\\(([\\d\\-\\.e]+\\s[\\d\\-\\.e]+\\s[\\d\\-\\.e]+)\\)\\s?\\(([\\d\\-\\.e]+\\s[\\d\\-\\.e]+\\s[\\d\\-\\.e]+)\\)\\s?\\(([\\d\\-\\.e]+\\s[\\d\\-\\.e]+\\s[\\d\\-\\.e]+)\\)'); @@ -31,6 +32,9 @@ static func parse_value(line: String) -> Variant: var m = _vectorRegex.search(line) if m: return Vector3(m.get_string(1).to_float(), m.get_string(2).to_float(), m.get_string(3).to_float()); + m = _vector2Regex.search(line) + if m: + return Vector2(m.get_string(1).to_float(), m.get_string(2).to_float()); m = _colorRegex.search(line) if m: return Color( @@ -107,6 +111,26 @@ static func finish_structure(hierarchy: Array): static func parse_from_string(source: String, keys_to_lower := false): var out := {}; var hierarchy: Array = [out]; + + # Normalize line endings (some VMT files use \r only) + source = source.replace("\r\n", "\n").replace("\r", "\n") + + # Normalize braces onto their own lines (handles single-line VMTs like tileroof002a.vmt) + # Only for small files to avoid performance hit on large VMF files + if source.length() < 4096: + var normalized := "" + var in_quotes := false + for ch in source: + if ch == '"': + in_quotes = not in_quotes + if not in_quotes and ch == '{': + normalized += "\n{\n" + elif not in_quotes and ch == '}': + normalized += "\n}\n" + else: + normalized += ch + source = normalized + var lines := source.split('\n'); var line: String = ''; var previous_line: String = line; diff --git a/addons/godotvmf/utils/vmf_struct.gd b/addons/godotvmf/utils/vmf_struct.gd new file mode 100644 index 0000000..0223cce --- /dev/null +++ b/addons/godotvmf/utils/vmf_struct.gd @@ -0,0 +1,51 @@ +class_name VMFStruct extends RefCounted + +enum Type { + BYTE, + INT_16, + INT_32, + UBYTE, + UINT_16, + UINT_32, + FLOAT, + STRING, + ARRAY, +} + +static func in_case(lambda: Callable) -> Callable: + return lambda; + +static func transform_struct(file: FileAccess, clazz: GDScript, struct_map: Dictionary) -> Variant: + var result = clazz.new(); + + for key in struct_map.keys(): + var type = struct_map[key]; + + match type: + Type.BYTE: + result.set(key, file.get_8()); + Type.INT_16: + result.set(key, file.get_buffer(2).decode_s16(0)); + Type.INT_32: + result.set(key, file.get_buffer(4).decode_s32(0)); + Type.UBYTE: + result.set(key, file.get_8()); + Type.UINT_16: + result.set(key, file.get_16()); + Type.UINT_32: + result.set(key, file.get_32()); + Type.FLOAT: + result.set(key, file.get_float()); + Type.STRING: + result.set(key, get_null_terminated_string(file)); + Type.ARRAY: + result.set(key, []); + return result; + +static func get_null_terminated_string(file: FileAccess) -> String: + var result = ""; + while true: + var c = file.get_8(); + if c == 0: break; + result += char(c); + return result; diff --git a/readme.md b/readme.md index 0a9fce0..a1ee5db 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,10 @@ Highly recommended to use [Hammer++](https://ficool2.github.io/HammerPlusPlus-We - Instances support - Native MDL support - Native VMT support -- Native VTF support (only DXT1, DXT3, DXT5 supported) +- Native VTF support + - DXT1, DXT3, DXT5 + - RGB888, RGBA8888, BGR888, ABGR8888 + - I8, IA88 - Displacements import (with vertex data) - WorldVertexTransition materials (blend textures) will be imported as [`WorldVertexTransitionMaterial`](/addons/godotvmf/shaders/WorldVertexTransitionMaterial.gd) - Entities support @@ -33,6 +36,7 @@ Highly recommended to use [Hammer++](https://ficool2.github.io/HammerPlusPlus-We - Surface props support - Material's compile properties support - FGD generator that compiles a FGD file based on source code of implemented entities in GDScript (see [here](https://github.com/H2xDev/GodotVMF/wiki/FGD-Generation)) +- Import from VPK support @@ -57,7 +61,6 @@ Or for those who just want to port their map from Source Engine to Godot and see - [Team Fortress Jumper](https://github.com/Mickeon/team-fortress-jumper) by [Mickeon](https://github.com/Mickeon) ## Known issues -- Extraction of materials and models from VPKs is not supported - Some of imported models may have wrong orientation - Use `Additional Rotation` property in the MDL import options - Avoid importing a big bunch of models/materials at once it may cause the engine crash or import freeze. There's some issue with threaded import in the engine. @@ -89,6 +92,8 @@ If you have some ideas, suggestions regarding to quality or solutions of the pro [ckaiser](https://github.com/ckaiser) [jamop4](https://github.com/jamop4) [Catperson6](https://github.com/catperson6real-dev) +[Withaust](https://github.com/Withaust) +[Asrael](https://github.com/asraeldragon) ## License MIT