wrote a brush-based scatter tool for Godot 4 — manually placing every tree was killing me

345 views 10 replies

Level design was becoming a nightmare. I had this outdoor area with rocks, shrubs, and a bunch of tree variants, and I was manually positioning every single one in the Godot editor. Click, drag, rotate, scale, repeat. Over a hundred objects. It looked too uniform and took forever.

So I wrote a small @tool EditorPlugin that adds a scatter brush to the 3D viewport. Drop a list of PackedScenes into a dock, set radius and density, then click on any surface to place randomized instances. Random Y rotation, optional scale jitter, raycasted to conform to whatever geometry is underneath.

@tool
extends EditorPlugin

var brush_radius := 3.0
var brush_density := 5
var scatter_items: Array[PackedScene] = []
var brush_active := false

func _forward_3d_gui_input(viewport_camera: Camera3D, event: InputEvent) -> int:
    if not brush_active or scatter_items.is_empty():
        return EditorPlugin.AFTER_GUI_INPUT_PASS
    if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
        _scatter_at_mouse(viewport_camera, event.position)
        return EditorPlugin.AFTER_GUI_INPUT_STOP
    return EditorPlugin.AFTER_GUI_INPUT_PASS

func _scatter_at_mouse(camera: Camera3D, mouse_pos: Vector2) -> void:
    var space_state := get_viewport().world_3d.direct_space_state
    var from := camera.project_ray_origin(mouse_pos)
    var to := from + camera.project_ray_normal(mouse_pos) * 1000.0
    var result := space_state.intersect_ray(PhysicsRayQueryParameters3D.create(from, to))
    if result.is_empty():
        return
    for i in brush_density:
        var offset := Vector3(randf_range(-brush_radius, brush_radius), 0.0,
                              randf_range(-brush_radius, brush_radius))
        var instance := scatter_items.pick_random().instantiate() as Node3D
        get_editor_interface().get_edited_scene_root().add_child(instance)
        instance.owner = get_editor_interface().get_edited_scene_root()
        instance.global_position = result["position"] + offset
        instance.rotate_y(randf() * TAU)
        instance.scale = Vector3.ONE * randf_range(0.8, 1.2)

The thing that tripped me up the longest: you have to set instance.owner to the edited scene root, otherwise all your placed objects silently vanish when you save. Godot only serializes nodes owned by the root. Cost me a good 45 minutes the first time.

What I still need: undo support via EditorUndoRedoManager. Right now a brush stroke is permanent the moment you lift the mouse button, which is painful. The API exists, I just haven't worked out how to batch a full stroke, potentially 30+ node additions, into a single undoable step cleanly. The docs are pretty sparse on bulk operations.

Also no slope-angle filtering yet, so it'll happily place trees on a vertical cliff face if your raycast hits one. Anyone done something similar and figured out the undo batching, or have a smarter approach to the collision mask so newly-placed objects don't immediately become raycast targets?

Replying to HexFern: density map input would be a great addition to this. instead of painting density...

density maps would be huge for this. you could go further with a multi-channel texture - R for tree density, G for rocks, B for ground cover - so one asset drives the whole biome composition. scatter tool samples the right channel per object type and you're done, no more layered brush passes.

the thing you'd absolutely need alongside it is an in-editor preview of the density map overlaid on the terrain. otherwise you're painting a texture outside the engine and importing it blind, which is just trading one kind of pain for another.

blindfolded painter chaos
Replying to AuroraBloom: yeah the degenerate case is real. when the surface normal is nearly parallel to ...

the dot product threshold is my approach too but i hit a weird edge case. terrain right at the threshold angle (~85°) causes flickering between modes as objects spawn near each other on the boundary. added a small deadband zone around the threshold, like ±3°, so it doesn't bounce back and forth between the two behaviors. minor thing but worth knowing if you're painting on steep-ish surfaces a lot.

flickering glitch loop
Replying to FluxArc: For terrain normal alignment in Godot, what's worked best for me is a downward r...

the degenerate normal case bit me hard on cliff faces too. my fix was a bit different — I blend between the surface normal and Vector3.UP based on the dot product, so steep surfaces get a gradual tilt without snapping fully perpendicular. honestly felt more game-y than physically correct anyway. trees sticking straight out the side of a cliff look wrong even when they're technically right

nice work. the thing i always miss in scatter tools is density falloff — like painting a forest edge where trees naturally thin out toward the brush boundary without needing multiple passes with different settings. does yours support a falloff curve on the brush or is it just uniform density within the radius? also curious whether placed objects check for overlap with each other or if that's just a 'paint carefully' situation lol

Replying to FluxArc: For terrain normal alignment in Godot, what's worked best for me is a downward r...

yeah the degenerate case is real. when the surface normal is nearly parallel to Vector3.UP you get a near-zero cross product and the Basis goes haywire. i handle it by checking the dot product first and falling back to Vector3.FORWARD as the secondary axis when it's above ~0.98. covers flat rooftops and near-vertical cliff faces without visibly affecting anything else.

Replying to FluxArc: For terrain normal alignment in Godot, what's worked best for me is a downward r...

Good call on the Basis-from-normal approach. One extra thing worth doing: normalize your up-hint vector explicitly before computing the cross product, even if it theoretically should already be normalized. Accumulated floating point drift from chained transforms can make it subtly non-unit, and then your constructed Basis comes out slightly skewed in ways that are invisible in the viewport but cause weird downstream issues. Collision shapes in particular tend to misbehave if the scale component is off by even a small amount.

A basis.orthonormalized() call after construction also catches this if you'd rather fix it post-hoc than pre-empt it at the input stage.

density map input would be a great addition to this. instead of painting density manually with the brush, you'd load a grayscale texture, something output from your biome or heightmap system, and it drives spawn probability across the whole terrain at once. brush painting on top for local overrides. would save a ton of passes on large outdoor areas where world gen already knows where trees should be sparse vs dense.

Replying to QuantumPulse: the dot product threshold is my approach too but i hit a weird edge case. terrai...

classic threshold flickering problem. the fix is hysteresis. use two separate cutoffs instead of one:

const ALIGN_ENTER_DOT = 0.5  # enter surface-normal mode below this
const ALIGN_EXIT_DOT  = 0.7  # only return to world-up above this

if current_dot < ALIGN_ENTER_DOT:
    use_surface_normal()
elif current_dot > ALIGN_EXIT_DOT:
    use_world_up()
# else: stay in whatever mode we're already in

objects near the boundary stop flickering because exiting the mode requires a stricter condition than entering it. the gap between the two thresholds is your dead band. same principle as a thermostat. you don't want it toggling every half second around the setpoint.

Been meaning to build something like this for my outdoor areas. Quick question about surface alignment: when you paint onto a slope, are scattered objects snapping perpendicular to the terrain normal, staying world-upright, or somewhere in between? That transition from "flat enough to stand upright" to "steep enough to conform to the surface" is always where these tools get fiddly. I've seen approaches that lerp between world-up and surface normal based on slope angle, but getting that threshold right per-object type feels like it'd want per-brush settings.

Also curious about storage — if placement data lives directly in the scene tree as child nodes, large scatter sets must get heavy in the .tscn format pretty fast. Are you planning a custom resource for the placement data, or is the scene tree approach holding up fine at the scale you're working at?

Replying to ObsidianArc: Been meaning to build something like this for my outdoor areas. Quick question a...

For terrain normal alignment in Godot, what's worked best for me is a downward raycast at placement time and building a Basis directly from the hit normal. Main gotcha is degenerate cases when the normal is nearly parallel to your cross product vector — need a fallback axis:

var up = ray_result.normal
var forward = up.cross(Vector3.RIGHT).normalized()
if forward.length_squared() < 0.001:
    forward = up.cross(Vector3.FORWARD).normalized()
var right = forward.cross(up).normalized()
node.transform.basis = Basis(right, up, -forward)

Handles typical terrain slopes well. Gets unstable on near-vertical surfaces but for a scatter tool that's usually a non-issue. Worth adding as an optional toggle if FrostWing's tool doesn't already do it.

Moonjump
Forum Search Shader Sandbox
Sign In Register