OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / terrain_workspace.gd

263 lines · gd ref:
@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)")