OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / proc_bridge_test.gd

376 lines · gd ref:
@tool
## Test scene for proc-nodes + blender bridge integration.
## Runs in editor (@tool) — bridge starts automatically, builds a test graph,
## and exports to Blender when connected.
extends Node3D

@export var auto_start: bool = true
# Every one of these was a dead control. test_mode and the noise_* values were
# read exactly once, in _enter_tree, behind a _graph_built latch -- so changing
# one in the inspector rebuilt nothing and you had to close and reopen the scene
# to see any effect. _build_terrain_graph did not read the noise_* values at
# all; it hardcoded its own. Setters now rebuild, and the terrain graph uses
# them like the other two modes do.
@export_enum("Displace", "Noise Debug", "Terrain", "Mountain + Island") var test_mode: int = 0:
	set(v):
		test_mode = v
		_rebuild_graph()
@export var noise_scale: float = 5.0:
	set(v):
		noise_scale = v
		_rebuild_graph()
@export var noise_detail: int = 4:
	set(v):
		noise_detail = v
		_rebuild_graph()
@export var noise_roughness: float = 0.5:
	set(v):
		noise_roughness = v
		_rebuild_graph()
## ── MOUNTAIN + ISLAND (test_mode 3) ─────────────────────────────────────────
## The island is a SECOND ProcGeometryNode, created on demand and parented here,
## so both meshes cross the bridge as separate objects -- which is the thing
## worth validating: the bridge carries a scene, not one mesh.
@export var island_height: float = 9.0:
	set(v):
		island_height = v
		_rebuild_graph()
@export var island_radius: float = 3.0:
	set(v):
		island_radius = v
		_rebuild_graph()
@export var island_relief: float = 1.4:
	set(v):
		island_relief = v
		_rebuild_graph()
@export var noise_lacunarity: float = 2.0:
	set(v):
		noise_lacunarity = v
		_rebuild_graph()

var _bridge: BlenderBridge
var _proc_geo: ProcGeometryNode
var _started: bool = false
var _graph_built: bool = false
var _exported: bool = false

func _enter_tree() -> void:
	if not Engine.is_editor_hint():
		return
	_bridge = $BlenderBridge as BlenderBridge
	_proc_geo = $ProcGeoTest as ProcGeometryNode

	# Build test graph in editor so there's actual geometry
	if _proc_geo and not _graph_built:
		_build_test_graph()
		_graph_built = true

	if _bridge and auto_start and not _started:
		var err := _bridge.start_server("/tmp/blender_bridge.sock")
		if err != OK:
			printerr("[ProcBridgeTest] Failed to start bridge server: ", err)
			return
		_started = true
		_exported = false
		set_process(true)
		print("[ProcBridgeTest] Bridge started — connect Blender now")

var _last_mesh_hash: int = 0

func _process(_delta: float) -> void:
	if not Engine.is_editor_hint():
		return
	if not _started or not _bridge:
		return

	# Export on first connect
	if _bridge.is_blender_connected() and not _exported:
		var tree := get_tree()
		if tree and tree.edited_scene_root:
			_bridge.export_scene(tree.edited_scene_root)
			_exported = true
			print("[ProcBridgeTest] Scene exported to Blender")

	# Detect mesh changes and re-export (proc-nodes re-evaluation)
	if _bridge.is_blender_connected() and _exported and _proc_geo:
		var mesh_output: MeshInstance3D = _proc_geo.get_node_or_null(NodePath("MeshOutput"))
		if mesh_output and mesh_output.get_mesh():
			var mesh: Mesh = mesh_output.get_mesh()
			var h: int = mesh.get_surface_count()
			if mesh.get_surface_count() > 0:
				var arrays: Array = mesh.surface_get_arrays(0)
				if arrays.size() > 0 and arrays[0] != null:
					h = hash(arrays[0])  # hash vertex positions
			if h != _last_mesh_hash and _last_mesh_hash != 0:
				var tree := get_tree()
				if tree and tree.edited_scene_root:
					_bridge.export_scene(tree.edited_scene_root)
					print("[ProcBridgeTest] Mesh changed — re-exported")
			_last_mesh_hash = h

func _exit_tree() -> void:
	if _bridge and _started:
		_bridge.stop_server()
		_started = false
		print("[ProcBridgeTest] Bridge stopped")

func _ready() -> void:
	if Engine.is_editor_hint():
		return
	_bridge = $BlenderBridge as BlenderBridge
	_proc_geo = $ProcGeoTest as ProcGeometryNode
	_build_test_graph()
	_test_serialization()

## Rebuild from an inspector edit. Safe before _enter_tree has run: a setter
## fires during scene load, when $ProcGeoTest does not exist yet, and the
## _enter_tree build covers that case anyway.
func _rebuild_graph() -> void:
	if not Engine.is_editor_hint():
		return
	if _proc_geo == null:
		_proc_geo = get_node_or_null(NodePath("ProcGeoTest")) as ProcGeometryNode
	if _proc_geo == null:
		return
	_build_test_graph()
	_proc_geo.mark_dirty()


func _build_test_graph() -> void:
	match test_mode:
		0: _build_displace_graph()
		1: _build_noise_debug_graph()
		2: _build_terrain_graph()
		3: _build_mountain_island()

func _build_displace_graph() -> void:
	var graph := ProcNodeGraph.new()

	# Simple terrain: Grid → Displace (Displace has built-in noise)
	var grid_idx := graph.add_node(MeshGridNode.new())
	var displace_idx := graph.add_node(DisplaceNode.new())

	graph.connect_sockets(grid_idx, 0, displace_idx, 0)  # Grid geometry → Displace

	# Grid: 10x10 size, 100x100 subdivisions
	graph.get_node(grid_idx).set_input_default(0, 10.0)  # Size X
	graph.get_node(grid_idx).set_input_default(1, 10.0)  # Size Y
	graph.get_node(grid_idx).set_input_default(2, 100)   # Verts X
	graph.get_node(grid_idx).set_input_default(3, 100)   # Verts Y

	# Displace: Strength=1, Scale=5, Detail=4, Roughness=0.5
	graph.get_node(displace_idx).set_input_default(1, 1.0)   # Strength
	graph.get_node(displace_idx).set_input_default(2, noise_scale)
	graph.get_node(displace_idx).set_input_default(3, noise_detail)
	graph.get_node(displace_idx).set_input_default(4, noise_roughness)

	graph.get_node(grid_idx).set_graph_position(Vector2(-200, 40))
	graph.get_node(displace_idx).set_graph_position(Vector2(200, 40))

	_proc_geo.set_graph(graph)
	print("[ProcBridgeTest] Built displace graph: ", graph.get_node_count(), " nodes")

func _build_noise_debug_graph() -> void:
	# Field evaluation test:
	# MeshGrid → Position → PerlinNoise → Compare(>0.5) → StoreNamedAttribute("color")
	var graph := ProcNodeGraph.new()

	var grid_idx := graph.add_node(MeshGridNode.new())
	var pos_idx := graph.add_node(PositionNode.new())
	var noise_idx := graph.add_node(PerlinNoiseNode.new())
	var compare_idx := graph.add_node(CompareNode.new())
	var store_idx := graph.add_node(StoreNamedAttributeNode.new())

	# Grid → Position (geometry input)
	graph.connect_sockets(grid_idx, 0, pos_idx, 0)
	# Position → Noise.Vector
	graph.connect_sockets(pos_idx, 0, noise_idx, 0)
	# Noise.Fac → Compare.A
	graph.connect_sockets(noise_idx, 0, compare_idx, 0)
	# Compare.Result → StoreNamedAttribute.Value
	graph.connect_sockets(compare_idx, 0, store_idx, 1)
	# Grid → StoreNamedAttribute.Geometry
	graph.connect_sockets(grid_idx, 0, store_idx, 0)

	# Grid: 10x10 size, 100x100 subdivisions
	graph.get_node(grid_idx).set_input_default(0, 10.0)
	graph.get_node(grid_idx).set_input_default(1, 10.0)
	graph.get_node(grid_idx).set_input_default(2, 100)
	graph.get_node(grid_idx).set_input_default(3, 100)

	# Noise: Scale, Detail, Roughness
	graph.get_node(noise_idx).set_input_default(2, noise_scale)
	graph.get_node(noise_idx).set_input_default(3, float(noise_detail))
	graph.get_node(noise_idx).set_input_default(4, noise_roughness)

	# Compare: Greater Than 0.5
	graph.get_node(compare_idx).set_input_default(1, 0.5)

	# StoreNamedAttribute: write to "color" on Point domain
	var store_node := graph.get_node(store_idx) as StoreNamedAttributeNode
	store_node.set_attr_name("color")

	# Layout
	graph.get_node(grid_idx).set_graph_position(Vector2(-400, 0))
	graph.get_node(pos_idx).set_graph_position(Vector2(-200, -100))
	graph.get_node(noise_idx).set_graph_position(Vector2(0, -100))
	graph.get_node(compare_idx).set_graph_position(Vector2(200, -100))
	graph.get_node(store_idx).set_graph_position(Vector2(400, 0))

	_proc_geo.set_graph(graph)
	print("[ProcBridgeTest] Built noise debug graph: ", graph.get_node_count(), " nodes")

func _build_terrain_graph() -> void:
	# Volcanic island terrain with Phacelle erosion:
	# MeshGrid → Displace(base landmass) → SlopeGradient → PhacelleNoise(erosion 1)
	#          → SlopeGradient → PhacelleNoise(erosion 2, finer)
	var graph := ProcNodeGraph.new()

	# 0: MeshGrid — large terrain plane
	var grid := graph.add_node(MeshGridNode.new())
	graph.get_node(grid).set_input_default(0, 20.0)  # Size X
	graph.get_node(grid).set_input_default(1, 20.0)  # Size Y
	graph.get_node(grid).set_input_default(2, 200)    # Verts X
	graph.get_node(grid).set_input_default(3, 200)    # Verts Y

	# 1: Displace — base terrain shape (Perlin fBM)
	var base_displace := graph.add_node(DisplaceNode.new())
	graph.get_node(base_displace).set_input_default(1, 3.0)             # Strength
	graph.get_node(base_displace).set_input_default(2, noise_scale)      # Scale
	graph.get_node(base_displace).set_input_default(3, noise_detail)     # Detail
	graph.get_node(base_displace).set_input_default(4, noise_roughness)  # Roughness
	graph.get_node(base_displace).set_input_default(5, noise_lacunarity) # Lacunarity

	# 2: SlopeGradient — compute slope from base terrain
	var slope1 := graph.add_node(SlopeGradientNode.new())

	# 3: PhacelleNoise — erosion pass 1 (coarse gullies)
	# Socket indices below are ErosionNoiseNode's CURRENT layout:
	#   0 Geometry  1 HeightScale  2 ErosionStr  3 SlopePower(dead)
	#   4 CellScale 5 Octaves      6 Gain        7 Height  8 Slope
	# They used to be written against an older layout, and the names in the old
	# comments (Frequency, Normalization) no longer exist: 3 was landing on the
	# DEAD SlopePower socket, 5 was setting Octaves to 1 instead of 5, and 6 was
	# setting Gain to -0.3. Unset sockets now take the node's own defaults.
	var erosion1 := graph.add_node(ErosionNoiseNode.new())
	graph.get_node(erosion1).set_input_default(1, 8.0)    # HeightScale
	graph.get_node(erosion1).set_input_default(2, -0.3)   # ErosionStr (negative = carve)

	# 4: SlopeGradient — recompute slope after erosion
	var slope2 := graph.add_node(SlopeGradientNode.new())

	# 5: PhacelleNoise — erosion pass 2 (fine branches)
	var erosion2 := graph.add_node(ErosionNoiseNode.new())
	graph.get_node(erosion2).set_input_default(1, 15.0)   # HeightScale (higher = finer)
	graph.get_node(erosion2).set_input_default(2, -0.15)  # ErosionStr (subtler)

	# Wire: Grid → Displace → SlopeGradient1 → PhacelleNoise1 → SlopeGradient2 → PhacelleNoise2
	graph.connect_sockets(grid, 0, base_displace, 0)          # Grid geo → Displace geo
	graph.connect_sockets(base_displace, 0, slope1, 0)        # Displaced geo → Slope1
	graph.connect_sockets(slope1, 0, erosion1, 0)             # Geo → Phacelle1 geo
	graph.connect_sockets(slope1, 1, erosion1, 8)             # Slope field → Phacelle1 Slope
	graph.connect_sockets(erosion1, 0, slope2, 0)             # Eroded geo → Slope2
	graph.connect_sockets(slope2, 0, erosion2, 0)             # Geo → Phacelle2 geo
	graph.connect_sockets(slope2, 1, erosion2, 8)             # Slope field → Phacelle2 Slope

	# Layout
	graph.get_node(grid).set_graph_position(Vector2(-600, 0))
	graph.get_node(base_displace).set_graph_position(Vector2(-400, 0))
	graph.get_node(slope1).set_graph_position(Vector2(-200, 0))
	graph.get_node(erosion1).set_graph_position(Vector2(0, 0))
	graph.get_node(slope2).set_graph_position(Vector2(200, 0))
	graph.get_node(erosion2).set_graph_position(Vector2(400, 0))

	_proc_geo.set_graph(graph)
	print("[ProcBridgeTest] Built terrain graph: ", graph.get_node_count(), " nodes (200x200 = 40K verts)")

func _test_serialization() -> void:
	var graph := _proc_geo.get_graph()
	if graph == null:
		print("[Test] ERROR: No graph to serialize")
		return

	var save_path := "user://proc_bridge_test_graph.tres"
	var err := ResourceSaver.save(graph, save_path)
	if err != OK:
		print("[Test] ERROR saving graph: ", err)
		return
	print("[Test] Saved graph to ", save_path)

	var loaded := ResourceLoader.load(save_path) as ProcNodeGraph
	if loaded == null:
		print("[Test] ERROR: Failed to load saved graph")
		return

	var ok := true
	if loaded.get_node_count() != graph.get_node_count():
		print("[Test] FAIL: node count ", loaded.get_node_count(), " != ", graph.get_node_count())
		ok = false
	if loaded.get_connection_count() != graph.get_connection_count():
		print("[Test] FAIL: connection count ", loaded.get_connection_count(), " != ", graph.get_connection_count())
		ok = false

	if ok:
		print("[Test] PASS: Serialization round-trip (", loaded.get_node_count(), " nodes, ", loaded.get_connection_count(), " connections)")
	_proc_geo.set_graph(loaded)

func _input(event: InputEvent) -> void:
	if Engine.is_editor_hint():
		return
	if event is InputEventKey and event.pressed:
		match event.keycode:
			KEY_R:
				_proc_geo.mark_dirty()
				print("[Test] Re-evaluating")
			KEY_S:
				_test_serialization()
			KEY_G:
				var g := _proc_geo.get_graph()
				if g:
					print("[Test] Graph: ", g.get_node_count(), " nodes, ", g.get_connection_count(), " connections")


## MOUNTAIN + ISLAND. The mountain is the terrain graph unchanged; the island is
## a displaced sphere on its own ProcGeometryNode, floating above it.
##
## ⚠ NO GPU BAKE HERE, deliberately. bake_volume_mesh lives on DCTerrain and this
## scene has none -- it is a proc-nodes + bridge scene. An island built as a MESH
## graph (primitive + displace) is what this scene is for and is what crosses to
## Blender; an SDF volume baked on the GPU would produce a mesh the bridge never
## sees the graph for.
func _build_mountain_island() -> void:
	_build_terrain_graph()          # the mountain, on ProcGeoTest
	_ensure_island()


func _ensure_island() -> void:
	var island := get_node_or_null(NodePath("IslandGeo")) as ProcGeometryNode
	if island == null:
		island = ProcGeometryNode.new()
		island.name = "IslandGeo"
		add_child(island)
		# owner so it shows in the editor's scene tree rather than being a
		# ghost that exists only until the scene is reloaded.
		if Engine.is_editor_hint() and get_tree() != null:
			island.owner = get_tree().edited_scene_root
	island.position = Vector3(0.0, island_height, 0.0)

	var g := ProcNodeGraph.new()
	# A UV sphere, pushed around by the same Displace the terrain uses, so the
	# island reads as the same material as the mountain rather than a ball.
	var sphere := g.add_node(MeshUVSphereNode.new())
	g.get_node(sphere).set_input_default(0, 32)              # segments
	g.get_node(sphere).set_input_default(1, 16)              # rings
	g.get_node(sphere).set_input_default(2, island_radius)   # radius

	var disp := g.add_node(DisplaceNode.new())
	g.get_node(disp).set_input_default(1, island_relief)      # Strength
	g.get_node(disp).set_input_default(2, noise_scale)        # Scale
	g.get_node(disp).set_input_default(3, noise_detail)       # Detail
	g.get_node(disp).set_input_default(4, noise_roughness)    # Roughness
	g.get_node(disp).set_input_default(5, noise_lacunarity)   # Lacunarity
	g.connect_sockets(sphere, 0, disp, 0)

	island.graph = g
	island.mark_dirty()