OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_geiss.gd

351 lines · gd ref:
extends "res://scenes/maps/map_dc_level.gd"
## (map_dc_level since 2026-10-06, with level_import OFF — the mountain's pairing:
## map_core brings terrain CSG, map_dc_level the brush store / editor / journal, and
## the DCLevel starts empty. gate_geiss_editable.)
## OUR GEISS TERRAIN, meshed by dc-terrain (demo sprint §4; rebuilt 2026-10-06).
##
## The field is the graph in blender/undersea_geiss.tscn (UnderseaGeissGraph.load_graph,
## a bridged scene like the mountain's) — UnderseaGeissGraph follows the old Geiss
## density stage for stage (regional masks, pre-warp of the ground itself, hard floor,
## shelf, continuous terraces, ridges, eight octaves) with our calibrated Perlin — run
## on the GPU by DCTerrainGeneratorProcNodesBounded (no bounds declared, so it streams
## unbounded). It replaced DCTerrainGeneratorGeiss, which ran NVIDIA's GPU Gems 3
## density.h port on their noise volumes; the shading (undersea_rock.gdshader, field
## normal + our palette) replaced their draw_rock.psh port and altmap.tga ramp.
##
## SCALE. As the old map: the surface sits near ground_y (0) with ~±15 m of lumps and
## flat floors at ~-14 m; the finest octave's lattice is ~0.6 m, so voxel_size 0.5 (in
## the .tscn) resolves the mesh and the field normal carries what is finer.
##
## The fly camera, HUD, autoshot and chunk counters are the old A/B harness, kept:
## they measure meshing and frame time whatever the field is.

@onready var terrain: Node3D = $DCTerrain

func _ready() -> void:
	var _as := OS.get_environment("GEISS_AUTOSHOT")
	if _as != "": _autoshot = float(_as)

	# BEFORE super: map_dc_level reads it in _ready (daylight) and in the deferred
	# _import_level. There is no .MAP here: brushes are built from scratch.
	level_import = false
	_use_shared_sky()
	super()
	var gen: Object = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
	if gen == null:
		push_error("[geiss-map] DCTerrainGeneratorProcNodesBounded is not registered — rebuild dc-terrain")
		return
	var graph := _field_graph()
	if graph == null:
		push_error("[geiss-map] no field graph (see the warning above for the scene)")
		return
	gen.set("world_scale", field_scale)
	gen.set("graph", graph)
	# GPU-only generator: a failed compile is air everywhere, so say WHY, loudly.
	if not bool(gen.call("has_compiled_shader")):
		push_error("[geiss-map] the undersea graph did not compile: %s" % String(gen.call("get_last_compile_error")))
	# `generator` is the one DCTerrain property safe to set from a script; every
	# other setter is DC_SET_CONFIG and re-fires rebuild(), which restarts the
	# mesher before it converges. The rest live in the .tscn.
	terrain.set("generator", gen)

	var mat := _field_material(gen, graph)
	terrain.set("custom_material", mat)
	_mat = mat
	print("[geiss-map] undersea generator installed (compiled: %s, %d bytes of GLSL)"
			% [str(gen.call("has_compiled_shader")), String(gen.call("get_compiled_glsl")).length()])
	_t0 = Time.get_ticks_msec()

	# A CAMERA, or nothing is drawn and the screen is the clear colour -- which
	# is exactly what "geiss-dc is grey" was. map_dc_terrain.gd:159 makes its own
	# when standalone; this map had none.
	#
	# (The start and controls once matched the NVIDIA raymarcher's viewer, removed
	# 2026-10-04; launch this map with scripts/undersea-map.sh.)
	if get_viewport().get_camera_3d() == null:
		_cam = Camera3D.new()
		var start := _camera_start()
		_cam.position = start[0]
		_pitch = float(start[1])
		_cam.rotation_degrees = Vector3(_pitch, 0.0, 0.0)
		_cam.fov = 70.0
		_cam.far = 300000.0
		_cam.current = true
		add_child(_cam)
		_hud = _make_hud()
		add_child(_hud)
		# GEISS_CAM="x,y,z,yaw,pitch" places the camera for a reproducible capture.
		# Without it an autoshot lands wherever spawn happens to look, which is
		# useless for comparing a shader change against an artifact that only
		# appears at close range.
		var camenv := OS.get_environment("GEISS_CAM")
		if camenv != "":
			var f := camenv.split(",")
			if f.size() >= 3:
				_cam.position = Vector3(float(f[0]), float(f[1]), float(f[2]))
			if f.size() >= 5:
				_yaw = float(f[3]); _pitch = float(f[4])
				_cam.rotation_degrees = Vector3(_pitch, _yaw, 0.0)
			print("[geiss-map] GEISS_CAM -> pos %s yaw %.1f pitch %.1f"
				% [str(_cam.position), _yaw, _pitch])
		print("[geiss-map] fly camera at %s — CLICK to capture mouse · WASD · SHIFT fast · ESC release" % str(_cam.position))

## ---- the world's sky -------------------------------------------------------------

const _CloudBank := preload("res://scenes/maps/cloud_bank.gd")
## A cloud layer over the whole map (CloudBank, footprint "everywhere": the field is
## unbounded, so no donut). Null resource = CloudBank.default_resource().
@export var clouds_enabled: bool = true
@export var clouds_resource: SunshineCloudsGD


## IN THE GAME, THE GAME'S SKY. The .tscn's WorldEnvironment (a ProceduralSky) and Sun
## exist so the map renders lit when run standalone (gates, scripts/undersea-map.sh).
## Under the game they must go BEFORE game.gd looks: a map that owns a WorldEnvironment
## makes it detach the shared Sky3D — no day/night, the Atmosphere panel drives a dead
## node, and nowhere for the clouds to go (gate_geiss_editable E3/E4).
func _use_shared_sky() -> void:
	if get_node_or_null(_CloudBank.GAME_SKY) == null:
		return
	for c in get_children():
		if c is WorldEnvironment or c is DirectionalLight3D:
			remove_child(c)
			c.queue_free()
	if clouds_enabled:
		var cb: Node = _CloudBank.new()
		cb.name = "CloudBank"
		cb.set("footprint", _CloudBank.EVERYWHERE)
		cb.set("resource", clouds_resource)
		cb.set("log_tag", "geiss-map")
		add_child(cb)


## ---- what a map built on this one overrides (map_geiss_terraces.gd) --------------

## The density graph to mesh. Here: the bridged Geiss scene.
func _field_graph() -> ProcNodeGraph:
	return UnderseaGeissGraph.load_graph()


## HOW MUCH LARGER THAN THE GRAPH the world stands (user 2026-10-06: "everything in
## geiss map is a little too small / high frequency ... 2x yes"). The generator emits
## f_world(p) = f_graph(p / s) * s, so every feature — lumps, terraces, caves, the world
## floor — scales together. CPU code that samples the GRAPH for a world position (the
## start camera, map_geiss_world's surface placement, shader bands) must use world(p) =
## graph(p / s) * s too: see _graph_y().
@export var field_scale: float = 2.0


## A graph-space height function's answer at a WORLD column, in world metres.
func _graph_y(f: Callable, x: float, z: float) -> float:
	return float(f.call(x / field_scale, z / field_scale)) * field_scale


## The terrain's material. Here: undersea_rock with the scene's water line and ground
## plane, so the shading tracks values tuned in Blender.
## ⛔ PLAIN shader (mesh normals), NOT _rock_shader(gen): the field-normal splice compiles
## to a pipeline the driver takes MINUTES to build (measured 2026-10-06: ~10 min to first
## draw), and nothing draws meanwhile — a Home whose saved map was map_geiss sat on a
## blank screen ("hung"). Re-enable only once the splice inlines ONE density evaluation.
func _field_material(_gen: Object, graph: ProcNodeGraph) -> ShaderMaterial:
	var mat := ShaderMaterial.new()
	mat.shader = ROCK_SHADER
	var kn := UnderseaGeissGraph.knobs_for(graph)
	mat.set_shader_parameter(&"water_y", float(kn["water_y"]) * field_scale)
	mat.set_shader_parameter(&"ground_y", float(kn["ground_y"]) * field_scale)
	return mat


## [position, pitch] of the fly camera when nothing places it. Here: the old map's start,
## just above the surface, looking along it (scaled with the field).
func _camera_start() -> Array:
	return [Vector3(0.0, 6.0, 16.0) * field_scale, -10.0]


## preload, not load: export_filter is "selected_scenes", so only DEPENDENCIES reach the
## PCK, and a runtime load() of a path is not one (map_dc_erosion.gd records the
## exported build failing exactly so).
const ROCK_SHADER := preload("res://scenes/maps/undersea/undersea_rock.gdshader")
const DENSITY_MARKER := "// @DENSITY@"

## undersea_rock with the generator's own density spliced in at its marker, so the
## shading takes its normal from the FIELD (see the shader's header). The text is
## get_compiled_glsl() — what the mesher runs — through BridgedDensityMaterial's two
## measured transforms (function-like macros, mutable globals); that file owns them and
## its gate tests them, so they are called, not copied. Any refusal or compile failure
## falls back to the plain shader (mesh normals) and SAYS so: a terrain that silently
## lost its field normal is the faceting this exists to remove.
func _rock_shader(gen: Object) -> Shader:
	var density := String(gen.call("get_compiled_glsl"))
	if density.is_empty():
		push_warning("[geiss-map] no compiled density — rock shading uses mesh normals")
		return ROCK_SHADER
	var exp := BridgedDensityMaterial.expand_function_macros(density)
	var con := BridgedDensityMaterial.constify_globals(exp["code"])
	if not con["reassigned"].is_empty():
		push_warning("[geiss-map] density globals are reassigned (%s) — rock shading uses mesh normals"
				% ", ".join(con["reassigned"]))
		return ROCK_SHADER
	var base := ROCK_SHADER.code
	if base.find(DENSITY_MARKER) == -1:
		push_warning("[geiss-map] undersea_rock has no %s marker — rock shading uses mesh normals" % DENSITY_MARKER)
		return ROCK_SHADER
	var sh := Shader.new()
	sh.code = base.replace(DENSITY_MARKER, "#define HAS_DENSITY\n" + String(con["code"]))
	# get_shader_uniform_list is empty when the shader failed to compile (the errors
	# are already on stderr).
	if sh.get_shader_uniform_list().is_empty():
		push_warning("[geiss-map] the spliced rock shader did not compile — rock shading uses mesh normals")
		return ROCK_SHADER
	print("[geiss-map] rock shading: field normal from the density (%d macro expansions, %d consts, %d bytes)"
			% [int(exp["count"]), int(con["count"]), sh.code.length()])
	return sh

## A/B INSTRUMENTATION. Printed from the map because dc-terrain attaches chunk
## meshes through RenderingServer.instance_create() rather than as scene nodes,
## so nothing outside a real running scene can see them -- a --script harness
## reports 1 chunk and 0 primitives.
var _t0 := 0
var _settled := false
var _frame_ms: Array[float] = []
var _cam: Camera3D = null
var _yaw := 0.0
var _pitch := -10.0
var _mouse := Vector2.ZERO
var _mat: ShaderMaterial
var _hud: Label

var _elapsed := 0.0

func _process(dt: float) -> void:
	super._process(dt)   # map_dc_level: the face_sel keymap context
	_fly(dt)
	_elapsed += dt
	_autoshot_check(_elapsed)
	var pending: int = terrain.call("get_pending_count")


	var active: int = terrain.call("get_active_chunk_count")
	_frame_ms.append(dt * 1000.0)
	if _frame_ms.size() > 120: _frame_ms.pop_front()

	if not _settled and active > 0 and pending == 0:
		_settled = true
		print("[geiss-ab] 1 BUILD    %.0f ms to steady state, %d chunks -> %.1f ms/chunk"
				% [float(Time.get_ticks_msec() - _t0), active,
				   float(Time.get_ticks_msec() - _t0) / float(active)])

	if _hud != null:
		var f := Engine.get_frames_per_second()
		_hud.text = "DUAL CONTOURING   %d fps  (%.1f ms)\nchunks %d  prims %s\ncam %s\nF2 screenshot · ESC" % [
				f, 1000.0 / maxf(f, 1.0), active,
				str(int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))),
				str(_cam.position.round()) if _cam != null else "(game camera)"]
	if Engine.get_process_frames() % 180 != 0: return
	var sorted := _frame_ms.duplicate(); sorted.sort()
	var med: float = sorted[sorted.size() / 2] if not sorted.is_empty() else 0.0
	print("[geiss-ab] chunks=%d pending=%d | prims=%d objects=%d draws=%d"
			% [active, pending,
			   int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)),
			   int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
			   int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))])
	print("[geiss-ab]   buffers %.1f MB | textures %.1f MB | frame %.2f ms median"
			% [Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED) / 1048576.0,
			   Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED) / 1048576.0, med])

## Keyboard only, matching the raymarch viewer: WASD move, Q/E or CTRL/SPACE
## down/up, ARROWS look, SHIFT fast.

## On-screen readout, because a number in a terminal you cannot see while flying
## is not a counter.
func _make_hud() -> Label:
	var l := Label.new()
	l.position = Vector2(12, 8)
	l.add_theme_color_override("font_color", Color(1, 1, 0.55))
	l.add_theme_color_override("font_outline_color", Color(0, 0, 0))
	l.add_theme_constant_override("outline_size", 4)
	return l

## AUTO-CAPTURE, so a debug view can be compared against another debug view at
## the SAME viewpoint without a human holding the camera still.
##
##   GEISS_AUTOSHOT=12       capture at t=12 s, then quit
##   GEISS_SHOT_NAME=ao5     write user://ao5.png instead of geiss_dc_NNN.png
##
## Exists because four rounds of this were argued from frames taken at
## different places, and because asking for a capture is asking the wrong
## party -- the shader has debug views precisely so this does not need a
## judgement call about a gameplay screenshot.
var _autoshot := -1.0
var _autoshot_done := false

func _autoshot_check(t: float) -> void:
	if _autoshot_done or _autoshot < 0.0 or t < _autoshot:
		return
	_autoshot_done = true
	var img := get_viewport().get_texture().get_image()
	if img == null:
		print("[geiss-map] autoshot FAILED — no viewport image"); get_tree().quit(1); return
	var nm := OS.get_environment("GEISS_SHOT_NAME")
	var path := "user://%s.png" % (nm if nm != "" else "geiss_autoshot")
	img.save_png(path)
	print("[geiss-map] autoshot -> %s" % ProjectSettings.globalize_path(path))
	get_tree().quit(0)

func _unhandled_input(e: InputEvent) -> void:
	# map_core's FIRST: the universal carve (player.fire), the terrain panel, F6 and
	# the rest live there. This override never called it, so no Geiss map could carve
	# (gate_geiss_editable E1: edits 0 -> 0).
	super._unhandled_input(e)
	if get_viewport().is_input_handled():
		return
	if e is InputEventKey and (e as InputEventKey).pressed \
			and (e as InputEventKey).keycode == KEY_F2:
		var img := get_viewport().get_texture().get_image()
		if img != null:
			var n := 0
			while FileAccess.file_exists("user://geiss_dc_%03d.png" % n): n += 1
			img.save_png("user://geiss_dc_%03d.png" % n)
			print("[geiss-map] screenshot -> %s"
					% ProjectSettings.globalize_path("user://geiss_dc_%03d.png" % n))
	if e is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		_mouse += (e as InputEventMouseMotion).relative
	elif e is InputEventMouseButton and (e as InputEventMouseButton).pressed and _cam != null:
		# Only the standalone harness's fly camera grabs the mouse; in the game the
		# player owns capture (and build mode wants the cursor).
		if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
			Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

func _fly(dt: float) -> void:
	if _cam == null: return
	if Input.is_key_pressed(KEY_ESCAPE):
		if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
			Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
		else:
			# SAY SO: this quit was silent, and two runs that ended early with exit 0
			# and no result could not be told apart from a crash or a gate bug.
			print("[geiss-map] Escape held — quitting")
			get_tree().quit()
	# Same controls as the raymarch viewer, so flying the two compares the
	# RENDERING and not two different sets of controls.
	_yaw -= _mouse.x * 0.12
	_pitch = clampf(_pitch - _mouse.y * 0.12, -89.0, 89.0)
	_mouse = Vector2.ZERO
	var look := 90.0 * dt
	if Input.is_key_pressed(KEY_SHIFT): look *= 2.5
	if Input.is_key_pressed(KEY_LEFT):  _yaw += look
	if Input.is_key_pressed(KEY_RIGHT): _yaw -= look
	if Input.is_key_pressed(KEY_UP):    _pitch = clampf(_pitch + look, -89.0, 89.0)
	if Input.is_key_pressed(KEY_DOWN):  _pitch = clampf(_pitch - look, -89.0, 89.0)
	var b := Basis.from_euler(Vector3(deg_to_rad(_pitch), deg_to_rad(_yaw), 0.0))
	var speed := (40.0 if Input.is_key_pressed(KEY_SHIFT) else 6.0) * dt
	var p := _cam.position
	if Input.is_key_pressed(KEY_W): p -= b.z * speed
	if Input.is_key_pressed(KEY_S): p += b.z * speed
	if Input.is_key_pressed(KEY_A): p -= b.x * speed
	if Input.is_key_pressed(KEY_D): p += b.x * speed
	if Input.is_key_pressed(KEY_E) or Input.is_key_pressed(KEY_SPACE): p.y += speed
	if Input.is_key_pressed(KEY_Q) or Input.is_key_pressed(KEY_CTRL): p.y -= speed
	_cam.position = p
	_cam.basis = b