@tool
extends Node3D
## Terrain workspace — test proc-nodes terrain generation.
## Presets build common terrain graphs. Save/load as .tres.
@export_file("*.tres") var graph_file: String = "res://graphs/terrain.tres":
set(v):
graph_file = v
if v != "" and ResourceLoader.exists(v):
_load_graph()
@export var save_graph: bool = false:
set(v):
if v: _save_graph()
save_graph = false
@export_enum("Mountain + Erosion", "Flat Perlin", "Ridged Peaks", "Phacelle Stripes") var preset: int = 0:
set(v):
preset = v
_build_preset(v)
@export var build_preset: bool = false:
set(v):
if v: _build_preset(preset)
build_preset = false
var _terrain: ProcGeometryNode
var _mat: StandardMaterial3D
func _ready() -> void:
_terrain = $Terrain as ProcGeometryNode
if not _terrain:
return
_mat = StandardMaterial3D.new()
_mat.albedo_color = Color(0.7, 0.7, 0.7)
_mat.roughness = 0.9
call_deferred("_apply_material")
if _terrain.get_graph() == null or _terrain.get_graph().get_node_count() == 0:
if graph_file != "" and ResourceLoader.exists(graph_file):
_load_graph()
else:
_build_preset(preset)
func _apply_material() -> void:
if not _terrain: return
var mesh_out := _terrain.get_node_or_null(NodePath("MeshOutput")) as MeshInstance3D
if mesh_out and _mat:
mesh_out.material_override = _mat
func _load_graph() -> void:
_terrain = $Terrain as ProcGeometryNode
if not _terrain or graph_file == "": return
if not ResourceLoader.exists(graph_file):
print("[Terrain] Not found: ", graph_file)
return
var g := ResourceLoader.load(graph_file) as ProcNodeGraph
if g:
_terrain.set_graph(g)
call_deferred("_apply_material")
print("[Terrain] Loaded: ", graph_file, " (", g.get_node_count(), " nodes)")
func _save_graph() -> void:
_terrain = $Terrain as ProcGeometryNode
if not _terrain or not _terrain.get_graph():
print("[Terrain] No graph to save")
return
var dir := graph_file.get_base_dir()
if not DirAccess.dir_exists_absolute(dir):
DirAccess.make_dir_recursive_absolute(dir)
var err := ResourceSaver.save(_terrain.get_graph(), graph_file)
if err == OK:
print("[Terrain] Saved to: ", graph_file)
func _build_preset(p: int) -> void:
_terrain = $Terrain as ProcGeometryNode
if not _terrain: return
match p:
0: _preset_mountain_erosion()
1: _preset_flat_perlin()
2: _preset_ridged_peaks()
3: _preset_phacelle_stripes()
call_deferred("_apply_material")
# --- Presets ---
func _preset_mountain_erosion() -> void:
## Big smooth mountain with Phacelle erosion gullies along slope
var g := ProcNodeGraph.new()
var grid := g.add_node(MeshGridNode.new())
g.get_node(grid).set_input_default(0, 20.0)
g.get_node(grid).set_input_default(1, 20.0)
g.get_node(grid).set_input_default(2, 200)
g.get_node(grid).set_input_default(3, 200)
var displace := g.add_node(DisplaceNode.new())
g.get_node(displace).set_input_default(1, 5.0) # Strength
g.get_node(displace).set_input_default(2, 1.5) # Scale
g.get_node(displace).set_input_default(3, 6) # Detail
g.get_node(displace).set_input_default(4, 0.6) # Roughness
g.get_node(displace).set_input_default(5, 2.0) # Lacunarity
var slope := g.add_node(SlopeGradientNode.new())
# ErosionNoiseNode sockets, from noise_nodes.cpp:
# 0 Geometry 1 HeightScale 2 ErosionStr 3 SlopePower 4 CellScale
# 5 Octaves(int) 6 Gain 7 Height(field) 8 Slope(field)
# The comments here used to read Scale/Frequency/Normalization/Strength for
# 1/3/5/6, which is a different node's layout. Socket 3 is SlopePower and is
# ACCEPTED AND IGNORED by the fade-approach filter, so setting it did
# nothing; socket 5 is an int octave count and was being handed 1.0.
var erosion := g.add_node(ErosionNoiseNode.new())
g.get_node(erosion).set_input_default(1, 3.0) # HeightScale
# 0.16 was tuned when Displace was being DISCARDED and the erosion was the
# entire landform. With the mountain wired in, the terrain span went 1.176 ->
# 4.382 and the same erosion read as 8.7% of relief instead of 31.1%.
# Measured: 0.8 restores 30.7%. It scales linearly.
g.get_node(erosion).set_input_default(2, 0.8) # ErosionStr
g.get_node(erosion).set_input_default(5, 5) # Octaves
g.get_node(erosion).set_input_default(6, 0.5) # Gain
# 9 PeakHeightDivisor: what counts as a peak. 0.6 is runevision's value for
# a field of ORDER 1, and is the default. Once a real terrain is wired into
# Height it must follow that field or fade_target clamps everywhere --
# measured at 63.8% of vertices on the saved graph before this was set.
# Rule of thumb: 0.6 * (peak height of the incoming field).
g.get_node(erosion).set_input_default(9, 1.6) # PeakHeightDivisor
g.connect_sockets(grid, 0, displace, 0)
g.connect_sockets(displace, 0, slope, 0)
g.connect_sockets(slope, 0, erosion, 0)
# Slope is a VECTOR and socket 8 is where it goes. This used to target
# socket 2, ErosionStr, a FLOAT -- SocketValue::to_float() takes vec.x from
# a non-field scalar, so it read as a plausible near-constant instead of
# erroring, and the strength silently became a slope component.
#
# BOTH of these are required, and that is the whole point. ErosionNoiseNode
# reads the external fields only when BOTH are fields:
# use_external = ext_height.is_field && ext_slope.is_field
# With Slope alone it was INERT, so the node generated its own terrain and
# assigned `pos[i].y = en.x` over the top -- which meant Displace and
# SlopeGradient had NO EFFECT ON THE RESULT AT ALL. Measured: perturbing
# every one of Displace's five parameters moved the mesh 0.000000.
# SlopeGradient gained the Height output so this graph can say what it
# always looked like it was saying.
g.connect_sockets(slope, 1, erosion, 8)
g.connect_sockets(slope, 2, erosion, 7)
g.get_node(grid).set_graph_position(Vector2(-400, 0))
g.get_node(displace).set_graph_position(Vector2(-200, 0))
g.get_node(slope).set_graph_position(Vector2(0, 0))
g.get_node(erosion).set_graph_position(Vector2(200, 0))
_terrain.set_graph(g)
print("[Terrain] Mountain + Erosion (4 nodes, 40K verts)")
func _preset_flat_perlin() -> void:
## Flat grid displaced by Perlin fBM — baseline noise test
var g := ProcNodeGraph.new()
var grid := g.add_node(MeshGridNode.new())
g.get_node(grid).set_input_default(0, 20.0)
g.get_node(grid).set_input_default(1, 20.0)
g.get_node(grid).set_input_default(2, 200)
g.get_node(grid).set_input_default(3, 200)
var displace := g.add_node(DisplaceNode.new())
g.get_node(displace).set_input_default(1, 3.0) # Strength
g.get_node(displace).set_input_default(2, 2.0) # Scale
g.get_node(displace).set_input_default(3, 4) # Detail
g.get_node(displace).set_input_default(4, 0.5) # Roughness
g.connect_sockets(grid, 0, displace, 0)
g.get_node(grid).set_graph_position(Vector2(-200, 0))
g.get_node(displace).set_graph_position(Vector2(0, 0))
_terrain.set_graph(g)
print("[Terrain] Flat Perlin (2 nodes)")
func _preset_ridged_peaks() -> void:
## Sharp ridged peaks using field pipeline:
## MeshGrid → Position → Musgrave(Ridged) → Math(Mul) → CombineXYZ → SetPosition
var g := ProcNodeGraph.new()
var grid := g.add_node(MeshGridNode.new())
g.get_node(grid).set_input_default(0, 20.0)
g.get_node(grid).set_input_default(1, 20.0)
g.get_node(grid).set_input_default(2, 200)
g.get_node(grid).set_input_default(3, 200)
var pos := g.add_node(PositionNode.new())
# MusgraveTextureNode was deleted from proc-nodes on 2026-09-04: Blender
# removed ShaderNodeTexMusgrave in 4.1 and folded it into Noise Texture's
# noise_type, so it cannot round-trip. PerlinNoiseNode ("Noise Texture")
# carries the same five variants -- but NOT in the same order, and NOT at
# the same socket indices:
#
# type Musgrave 0=FBM 1=MULTIFRACTAL 2=HETERO 3=HYBRID 4=RIDGED
# Noise 0=Multifractal 1=fBM 2=Hybrid 3=Ridged 4=Hetero
# socket Noise Texture inserts W at index 1, so Scale/Detail/Roughness/
# Lacunarity move from 1/2/3/4 to 2/3/4/5.
#
# So RIDGED is 3 here, not 4. A mechanical rename would have left this
# graph on Hetero with Scale in the W socket, and nothing would have said so.
var musgrave := g.add_node(PerlinNoiseNode.new())
(g.get_node(musgrave) as PerlinNoiseNode).set_noise_type(3) # Ridged
g.get_node(musgrave).set_input_default(2, 1.5) # Scale
g.get_node(musgrave).set_input_default(3, 5.0) # Detail
g.get_node(musgrave).set_input_default(4, 0.5) # Roughness
g.get_node(musgrave).set_input_default(5, 2.0) # Lacunarity
var mul := g.add_node(MathNode.new())
(g.get_node(mul) as MathNode).set_operation(2) # Multiply
g.get_node(mul).set_input_default(1, 4.0) # Height multiplier
var combine := g.add_node(CombineXYZNode.new())
var set_pos := g.add_node(SetPositionNode.new())
g.connect_sockets(grid, 0, pos, 0) # Grid geo → Position
g.connect_sockets(pos, 0, musgrave, 0) # Position field → Noise Texture
g.connect_sockets(musgrave, 0, mul, 0) # Noise Fac → Multiply
g.connect_sockets(mul, 0, combine, 1) # Scaled noise → CombineXYZ Y
g.connect_sockets(combine, 0, set_pos, 1) # Vec3(0, h, 0) → SetPos offset
g.connect_sockets(grid, 0, set_pos, 0) # Grid geo → SetPos geo
g.get_node(grid).set_graph_position(Vector2(-500, 0))
g.get_node(pos).set_graph_position(Vector2(-300, -80))
g.get_node(musgrave).set_graph_position(Vector2(-100, -80))
g.get_node(mul).set_graph_position(Vector2(100, -80))
g.get_node(combine).set_graph_position(Vector2(100, 40))
g.get_node(set_pos).set_graph_position(Vector2(300, 0))
_terrain.set_graph(g)
print("[Terrain] Ridged Peaks (6 nodes, field pipeline)")
func _preset_phacelle_stripes() -> void:
## Flat grid with Phacelle noise only — see directional stripe pattern
var g := ProcNodeGraph.new()
var grid := g.add_node(MeshGridNode.new())
g.get_node(grid).set_input_default(0, 20.0)
g.get_node(grid).set_input_default(1, 20.0)
g.get_node(grid).set_input_default(2, 200)
g.get_node(grid).set_input_default(3, 200)
var phacelle := g.add_node(ErosionNoiseNode.new())
g.get_node(phacelle).set_input_default(1, 3.0) # Scale
g.get_node(phacelle).set_input_default(2, Vector3(1, 0, 0)) # Direction (X = vertical stripes)
g.get_node(phacelle).set_input_default(3, 1.0) # Frequency
g.get_node(phacelle).set_input_default(5, 1.0) # Normalization
g.get_node(phacelle).set_input_default(6, 1.0) # Strength (positive = displace up)
g.connect_sockets(grid, 0, phacelle, 0)
g.get_node(grid).set_graph_position(Vector2(-200, 0))
g.get_node(phacelle).set_graph_position(Vector2(0, 0))
_terrain.set_graph(g)
print("[Terrain] Phacelle Stripes (2 nodes)")