@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()