Last active
February 13, 2026 19:23
-
-
Save bitbutter/5b5368867bc3a8af9cf7df19cda07b7b to your computer and use it in GitHub Desktop.
Auto-generates collision polygons from tile pixel alpha. 1. Add to your project. 2. Tune the params at the top of the script. 3. Right click the script in FileSystem tab, choose "Run".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @tool | |
| extends EditorScript | |
| ## Auto-generate collision polygons from tile pixel alpha. | |
| ## Run from Script Editor: File > Run (Ctrl+Shift+X) | |
| ## Path to the TileSet resource to process. | |
| const TILESET_PATH := "res://game_specific/resources/tilesets/greentileset.tres" | |
| ## Physics layer index to assign collision polygons to. | |
| const PHYSICS_LAYER := 0 | |
| ## Alpha threshold for bitmap generation (0.0 - 1.0). | |
| ## Pixels with alpha > threshold are treated as solid. | |
| const ALPHA_THRESHOLD := 0.5 | |
| ## Epsilon for Ramer-Douglas-Peucker polygon simplification. | |
| ## Lower = more vertices, tighter fit. Higher = fewer vertices, looser fit. | |
| ## For 26x26 tiles, 1.0-2.0 is a good starting range. | |
| const POLYGON_EPSILON := 2.0 | |
| ## If true, replaces existing collision polygons. If false, skips tiles that already have them. | |
| const OVERWRITE_EXISTING := false | |
| ## How close (in pixels) a vertex must be to a tile edge to be snapped onto it. | |
| ## Should be >= POLYGON_EPSILON so RDP-displaced boundary vertices get caught. | |
| const EDGE_SNAP_DISTANCE := 2.5 | |
| func _run() -> void: | |
| var tile_set := load(TILESET_PATH) as TileSet | |
| assert(tile_set != null, "Failed to load TileSet at: %s" % TILESET_PATH) | |
| assert(tile_set.get_physics_layers_count() > 0, "TileSet has no physics layers. Add one in the editor first.") | |
| var total_tiles := 0 | |
| var processed_tiles := 0 | |
| var skipped_tiles := 0 | |
| var source_count := tile_set.get_source_count() | |
| for source_idx in source_count: | |
| var source_id := tile_set.get_source_id(source_idx) | |
| var source := tile_set.get_source(source_id) | |
| if not source is TileSetAtlasSource: | |
| continue | |
| var atlas_source := source as TileSetAtlasSource | |
| assert(atlas_source.texture != null, "Atlas source %d has no texture." % source_id) | |
| var image := atlas_source.texture.get_image() | |
| assert(image != null, "Could not get image from atlas source %d texture." % source_id) | |
| if image.is_compressed(): | |
| image.decompress() | |
| var tile_size := atlas_source.texture_region_size | |
| var margins := atlas_source.margins | |
| var separation := atlas_source.separation | |
| var half_size := Vector2(tile_size) / 2.0 | |
| var tiles_count := atlas_source.get_tiles_count() | |
| for tile_idx in tiles_count: | |
| var atlas_coords := atlas_source.get_tile_id(tile_idx) | |
| # Process base tile and all alternatives | |
| var alt_count := atlas_source.get_alternative_tiles_count(atlas_coords) | |
| for alt_idx in alt_count: | |
| var alt_id := atlas_source.get_alternative_tile_id(atlas_coords, alt_idx) | |
| var tile_data := atlas_source.get_tile_data(atlas_coords, alt_id) | |
| assert(tile_data != null, "Null TileData at %s alt %d" % [atlas_coords, alt_id]) | |
| total_tiles += 1 | |
| if not OVERWRITE_EXISTING and tile_data.get_collision_polygons_count(PHYSICS_LAYER) > 0: | |
| skipped_tiles += 1 | |
| continue | |
| # Calculate pixel region for this tile in the atlas | |
| var region_pos := Vector2i( | |
| margins.x + atlas_coords.x * (tile_size.x + separation.x), | |
| margins.y + atlas_coords.y * (tile_size.y + separation.y) | |
| ) | |
| var tile_image := image.get_region(Rect2i(region_pos, tile_size)) | |
| # Generate bitmap from alpha channel | |
| var bitmap := BitMap.new() | |
| bitmap.create_from_image_alpha(tile_image, ALPHA_THRESHOLD) | |
| # Convert opaque regions to polygons | |
| var rect := Rect2(Vector2.ZERO, Vector2(tile_size)) | |
| var polygons := bitmap.opaque_to_polygons(rect, POLYGON_EPSILON) | |
| if polygons.is_empty(): | |
| # Fully transparent tile — clear any existing collision | |
| tile_data.set_collision_polygons_count(PHYSICS_LAYER, 0) | |
| processed_tiles += 1 | |
| continue | |
| # Find opaque/transparent transition points on each tile border | |
| var edge_transitions := _find_edge_transitions(bitmap, tile_size, half_size) | |
| # Apply polygons, converting from top-left origin to tile-center origin | |
| tile_data.set_collision_polygons_count(PHYSICS_LAYER, polygons.size()) | |
| for poly_idx in polygons.size(): | |
| var polygon: PackedVector2Array = polygons[poly_idx] | |
| var centered := PackedVector2Array() | |
| centered.resize(polygon.size()) | |
| for pt_idx in polygon.size(): | |
| centered[pt_idx] = polygon[pt_idx] - half_size | |
| centered = _snap_and_insert_edge_vertices(centered, half_size, edge_transitions) | |
| tile_data.set_collision_polygon_points(PHYSICS_LAYER, poly_idx, centered) | |
| processed_tiles += 1 | |
| var err := ResourceSaver.save(tile_set, TILESET_PATH) | |
| assert(err == OK, "Failed to save TileSet: %s" % error_string(err)) | |
| print("=== Collision Polygon Generation Complete ===") | |
| print(" Total tiles: %d" % total_tiles) | |
| print(" Processed: %d" % processed_tiles) | |
| print(" Skipped: %d" % skipped_tiles) | |
| ## Scans the 4 borders of the bitmap for opaque↔transparent transitions. | |
| ## Returns transition points in centered tile coordinates, keyed by edge name. | |
| func _find_edge_transitions(bitmap: BitMap, tile_size: Vector2i, half_size: Vector2) -> Dictionary: | |
| var transitions := {} | |
| # Top edge (y=0) | |
| var top := PackedVector2Array() | |
| var prev := false | |
| for x in tile_size.x: | |
| var cur := bitmap.get_bit(x, 0) | |
| if cur != prev: | |
| top.append(Vector2(float(x) - half_size.x, -half_size.y)) | |
| prev = cur | |
| if prev: | |
| top.append(Vector2(half_size.x, -half_size.y)) | |
| transitions["top"] = top | |
| # Bottom edge (y=tile_size.y-1) | |
| var bottom := PackedVector2Array() | |
| prev = false | |
| for x in tile_size.x: | |
| var cur := bitmap.get_bit(x, tile_size.y - 1) | |
| if cur != prev: | |
| bottom.append(Vector2(float(x) - half_size.x, half_size.y)) | |
| prev = cur | |
| if prev: | |
| bottom.append(Vector2(half_size.x, half_size.y)) | |
| transitions["bottom"] = bottom | |
| # Left edge (x=0) | |
| var left := PackedVector2Array() | |
| prev = false | |
| for y in tile_size.y: | |
| var cur := bitmap.get_bit(0, y) | |
| if cur != prev: | |
| left.append(Vector2(-half_size.x, float(y) - half_size.y)) | |
| prev = cur | |
| if prev: | |
| left.append(Vector2(-half_size.x, half_size.y)) | |
| transitions["left"] = left | |
| # Right edge (x=tile_size.x-1) | |
| var right := PackedVector2Array() | |
| prev = false | |
| for y in tile_size.y: | |
| var cur := bitmap.get_bit(tile_size.x - 1, y) | |
| if cur != prev: | |
| right.append(Vector2(half_size.x, float(y) - half_size.y)) | |
| prev = cur | |
| if prev: | |
| right.append(Vector2(half_size.x, half_size.y)) | |
| transitions["right"] = right | |
| return transitions | |
| ## Snaps near-edge polygon vertices to tile edges, then inserts missing | |
| ## transition vertices on polygon edges that run along the tile border. | |
| func _snap_and_insert_edge_vertices(polygon: PackedVector2Array, half_size: Vector2, edge_transitions: Dictionary) -> PackedVector2Array: | |
| # Step 1: Snap near-edge vertices to the tile edge, but ONLY if the vertex | |
| # position falls within an opaque run on that edge. | |
| for i in polygon.size(): | |
| var pt := polygon[i] | |
| if pt.x < -half_size.x + EDGE_SNAP_DISTANCE: | |
| if _is_within_opaque_run(pt.y, edge_transitions["left"], false): | |
| pt.x = -half_size.x | |
| elif pt.x > half_size.x - EDGE_SNAP_DISTANCE: | |
| if _is_within_opaque_run(pt.y, edge_transitions["right"], false): | |
| pt.x = half_size.x | |
| if pt.y < -half_size.y + EDGE_SNAP_DISTANCE: | |
| if _is_within_opaque_run(pt.x, edge_transitions["top"], true): | |
| pt.y = -half_size.y | |
| elif pt.y > half_size.y - EDGE_SNAP_DISTANCE: | |
| if _is_within_opaque_run(pt.x, edge_transitions["bottom"], true): | |
| pt.y = half_size.y | |
| polygon[i] = pt | |
| # Step 1.5: Restore border vertices that RDP simplified away. | |
| # For narrow features at tile edges, RDP can remove the vertices that | |
| # reached the border, leaving no vertex to snap. Re-insert them. | |
| polygon = _add_missing_border_vertices(polygon, half_size, edge_transitions) | |
| # Step 2: Walk polygon edges; for edges lying on a tile border, insert | |
| # any transition vertices that RDP removed. | |
| var result := PackedVector2Array() | |
| for i in polygon.size(): | |
| var a := polygon[i] | |
| var b := polygon[(i + 1) % polygon.size()] | |
| result.append(a) | |
| if a.y == -half_size.y and b.y == -half_size.y: | |
| _insert_transitions_between(result, a.x, b.x, edge_transitions["top"], -half_size.y, true) | |
| elif a.y == half_size.y and b.y == half_size.y: | |
| _insert_transitions_between(result, a.x, b.x, edge_transitions["bottom"], half_size.y, true) | |
| elif a.x == -half_size.x and b.x == -half_size.x: | |
| _insert_transitions_between(result, a.y, b.y, edge_transitions["left"], -half_size.x, false) | |
| elif a.x == half_size.x and b.x == half_size.x: | |
| _insert_transitions_between(result, a.y, b.y, edge_transitions["right"], half_size.x, false) | |
| # Step 3: Remove consecutive duplicate vertices. | |
| var deduped := PackedVector2Array() | |
| for i in result.size(): | |
| if deduped.is_empty() or result[i].distance_to(deduped[deduped.size() - 1]) > 0.1: | |
| deduped.append(result[i]) | |
| if deduped.size() > 1 and deduped[deduped.size() - 1].distance_to(deduped[0]) < 0.1: | |
| deduped.resize(deduped.size() - 1) | |
| return deduped | |
| ## Inserts transition vertices between two consecutive polygon vertices that | |
| ## both lie on the same tile edge. | |
| ## axis_a/axis_b: the varying coordinate of the two endpoints. | |
| ## transitions: the PackedVector2Array of transition points for this edge. | |
| ## fixed_coord: the constant coordinate shared by both endpoints. | |
| ## is_horizontal: true for top/bottom (x varies), false for left/right (y varies). | |
| func _insert_transitions_between(result: PackedVector2Array, axis_a: float, axis_b: float, transitions: PackedVector2Array, fixed_coord: float, is_horizontal: bool) -> void: | |
| if transitions.is_empty(): | |
| return | |
| var going_positive := axis_b > axis_a | |
| var to_insert: Array[float] = [] | |
| for t in transitions: | |
| var t_axis: float = t.x if is_horizontal else t.y | |
| if going_positive: | |
| if t_axis > axis_a + 0.1 and t_axis < axis_b - 0.1: | |
| to_insert.append(t_axis) | |
| else: | |
| if t_axis < axis_a - 0.1 and t_axis > axis_b + 0.1: | |
| to_insert.append(t_axis) | |
| to_insert.sort() | |
| if not going_positive: | |
| to_insert.reverse() | |
| for t_axis in to_insert: | |
| if is_horizontal: | |
| result.append(Vector2(t_axis, fixed_coord)) | |
| else: | |
| result.append(Vector2(fixed_coord, t_axis)) | |
| ## Returns true if axis_val falls within any opaque run on this edge. | |
| ## Transitions come in pairs: [start_of_opaque, end_of_opaque, ...]. | |
| ## is_horizontal: true → read x from transition points, false → read y. | |
| func _is_within_opaque_run(axis_val: float, transitions: PackedVector2Array, is_horizontal: bool) -> bool: | |
| var i := 0 | |
| while i + 1 < transitions.size(): | |
| var run_start: float = transitions[i].x if is_horizontal else transitions[i].y | |
| var run_end: float = transitions[i + 1].x if is_horizontal else transitions[i + 1].y | |
| if axis_val >= run_start - POLYGON_EPSILON and axis_val <= run_end + POLYGON_EPSILON: | |
| return true | |
| i += 2 | |
| return false | |
| ## Ensures the polygon has vertices at all opaque run boundaries on tile edges. | |
| ## RDP simplification can remove vertices near edges for narrow features, | |
| ## leaving no vertex for the snap step to work with. | |
| func _add_missing_border_vertices(polygon: PackedVector2Array, _half_size: Vector2, edge_transitions: Dictionary) -> PackedVector2Array: | |
| for edge_key in ["top", "bottom", "left", "right"]: | |
| var transitions: PackedVector2Array = edge_transitions[edge_key] | |
| var i := 0 | |
| while i + 1 < transitions.size(): | |
| var start_pt: Vector2 = transitions[i] | |
| var end_pt: Vector2 = transitions[i + 1] | |
| if not _has_vertex_near(polygon, start_pt): | |
| polygon = _insert_border_vertex(polygon, start_pt) | |
| if not _has_vertex_near(polygon, end_pt): | |
| polygon = _insert_border_vertex(polygon, end_pt) | |
| i += 2 | |
| return polygon | |
| ## Returns true if the polygon has a vertex within 1 pixel of the target point. | |
| func _has_vertex_near(polygon: PackedVector2Array, target: Vector2) -> bool: | |
| for v in polygon: | |
| if v.distance_to(target) < 1.0: | |
| return true | |
| return false | |
| ## Inserts a vertex at target into the nearest polygon edge. | |
| ## Only inserts if the nearest edge is within EDGE_SNAP_DISTANCE + POLYGON_EPSILON. | |
| func _insert_border_vertex(polygon: PackedVector2Array, target: Vector2) -> PackedVector2Array: | |
| var best_idx := -1 | |
| var best_dist := INF | |
| for i in polygon.size(): | |
| var a := polygon[i] | |
| var b := polygon[(i + 1) % polygon.size()] | |
| var dist := _point_to_segment_distance(target, a, b) | |
| if dist < best_dist: | |
| best_dist = dist | |
| best_idx = i | |
| if best_idx == -1 or best_dist > EDGE_SNAP_DISTANCE + POLYGON_EPSILON: | |
| return polygon | |
| var result := PackedVector2Array() | |
| for i in polygon.size(): | |
| result.append(polygon[i]) | |
| if i == best_idx: | |
| result.append(target) | |
| return result | |
| ## Returns the minimum distance from point p to line segment a→b. | |
| func _point_to_segment_distance(p: Vector2, a: Vector2, b: Vector2) -> float: | |
| var ab := b - a | |
| var len_sq := ab.length_squared() | |
| if len_sq < 0.001: | |
| return p.distance_to(a) | |
| var t := clampf((p - a).dot(ab) / len_sq, 0.0, 1.0) | |
| return p.distance_to(a + t * ab) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment