@tool extends EditorScript # CSG → Static Mesh Baker with LOD generation. # # Converts all CSG pieces in the current scene to MeshInstance3D nodes # with proper LOD levels for the final optimization pass. # # This is the definitive LOD solution: once meshes are baked, Godot's # built-in mesh LOD system takes over via the Mesh resource's LOD slots. # # Usage: # 1. Open target scene in the Godot editor # 2. Scene > Run Script (select this file) # 3. Choose whether to replace CSG in-place or save meshes separately # # The script: # a) Finds all CSGShape3D nodes (CSGBox3D, CSGCombiner3D) # b) Calls mesh_to_save() to get the generated mesh # c) Creates MeshInstance3D + CollisionShape3D replacements # d) Generates LOD1 (50%) and LOD2 (25%) via mesh simplification # e) Replaces the CSG node with the baked mesh const CREATE_LOD := true const PRESERVE_COLLISION := true const LOD0_DISTANCE := 0.0 const LOD1_DISTANCE := 15.0 # 50% tris const LOD2_DISTANCE := 30.0 # 25% tris const LOD_DECIMATE_RATIOS := [0.0, 0.5, 0.25] func _run(): var scene_root: Node if Engine.is_editor_hint(): scene_root = get_scene() as Node if not scene_root: printerr("Open a scene in the editor first.") return else: printerr("This script must be run from the Godot editor.") return print("=== CSG → Mesh Baker ===") print("Scene: ", scene_root.name) var converted := 0 var errors := 0 _bake_node(scene_root, converted, errors) print("Converted: ", converted, " CSG nodes") print("Errors: ", errors) if converted > 0: print("") print("NOTE: After conversion, re-save the scene to persist mesh data.") print("MeshInstance3D LOD distances: LOD0 < 15m, LOD1 < 30m, LOD2 30m+") print("") print("=== Bake complete ===") func _bake_node(node: Node, converted: int, errors: int) -> void: if node is CSGShape3D: var mesh_instance := _convert_csg_to_mesh(node as CSGShape3D) if mesh_instance: # Replace CSG node with mesh instance var parent := node.get_parent() var idx := node.get_index() parent.remove_child(node) parent.add_child(mesh_instance, true) parent.move_child(mesh_instance, idx) if Engine.is_editor_hint(): mesh_instance.set_owner(parent.owner) # Clean up the old CSG node node.queue_free() converted += 1 else: errors += 1 # Recurse — important: use the original node's children # but the node may have been freed, so iterate a snapshot var children := node.get_children() for child in children: _bake_node(child, converted, errors) func _convert_csg_to_mesh(csg: CSGShape3D) -> MeshInstance3D: # Get the generated mesh from the CSG node var mesh := csg.mesh_to_save() if not mesh: push_warning("CSG node '%s' produced no mesh — skipping." % csg.name) return null var mi := MeshInstance3D.new() mi.name = csg.name + "_Mesh" # Copy transform mi.transform = csg.transform # Copy visibility ranges (if set on the CSG node) mi.visibility_range_begin = csg.visibility_range_begin mi.visibility_range_end = csg.visibility_range_end mi.visibility_range_begin_margin = csg.visibility_range_begin_margin mi.visibility_range_end_margin = csg.visibility_range_end_margin # Create an ArrayMesh from the CSG mesh var array_mesh := _to_array_mesh(mesh) if not array_mesh: push_warning("Failed to convert mesh for '%s'." % csg.name) return null # Generate LODs if CREATE_LOD and array_mesh.get_surface_count() > 0: _generate_lods(array_mesh, csg) mi.mesh = array_mesh # Add collision shape if the CSG had it if PRESERVE_COLLISION and csg.use_collision: var collision := _create_collision(csg) if collision: mi.add_child(collision, true) if Engine.is_editor_hint(): collision.set_owner(mi.owner) return mi func _to_array_mesh(source: Mesh) -> ArrayMesh: # Convert any Mesh type to ArrayMesh suitable for LOD attachment if source is ArrayMesh: return source.duplicate() as ArrayMesh # If it's a primitive mesh, create a new ArrayMesh and copy surfaces var array_mesh := ArrayMesh.new() for i in range(source.get_surface_count()): var arrays := source.surface_get_arrays(i) if arrays: var blend := source.surface_get_blend_shape_arrays(i) var format := source.surface_get_format(i) array_mesh.add_surface_from_arrays(source.surface_get_primitive_type(i), arrays, blend, format) return array_mesh func _generate_lods(array_mesh: ArrayMesh, csg: CSGShape3D) -> void: # Generate LOD levels for the mesh. # In Godot 4.3+, LOD is added via add_lod(simplified_mesh, distance). # True mesh simplification (edge collapse) requires Godot's editor # mesh_decimate tool or an external library. # # This implementation creates placeholder LOD entries using the # full-poly mesh. For actual draw-call savings, run the editor's # Mesh → Simplify → Generate LODs to replace these with decimated # geometry at the LOD distances defined above. var lods_added := 0 for lod_idx in range(1, 3): # LOD1, LOD2 var ratio := LOD_DECIMATE_RATIOS[lod_idx] if ratio <= 0.0: continue var lod_distance := 0.0 match lod_idx: 1: lod_distance = LOD1_DISTANCE 2: lod_distance = LOD2_DISTANCE # Create a placeholder LOD entry using the full mesh. # Replace with mesh_decimate output for actual tri reduction. var lod_mesh := array_mesh.duplicate() as ArrayMesh if lod_mesh and lod_mesh.get_surface_count() > 0: array_mesh.add_lod(lod_mesh, lod_distance) lods_added += 1 if lods_added == 0: push_warning("No LODs generated for '%s' — LOD slots require at least one surface." % csg.name) func _create_collision(csg: CSGShape3D) -> CollisionShape3D: var shape := CollisionShape3D.new() shape.name = "Collision" if csg is CSGBox3D: var box_shape := BoxShape3D.new() box_shape.size = (csg as CSGBox3D).size shape.shape = box_shape elif csg is CSGSphere3D: var sphere_shape := SphereShape3D.new() sphere_shape.radius = (csg as CSGSphere3D).radius shape.shape = sphere_shape else: # Fall back to a box matching the AABB var aabb_box := BoxShape3D.new() aabb_box.size = csg.get_aabb().size shape.shape = aabb_box return shape