OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / _probe_spom.gd

268 lines · gd ref:
extends SceneTree
## PROBE (not a gate) — SPOM / fast-POM evaluation. One 4x2 m brick wall (Bricks105) in
## front of a grey backdrop; for each shader mode (spom_probe.gdshader 0..4) and camera
## pose, the viewport's MEASURED GPU time averaged over 90 frames, plus a screenshot.
##   DISPLAY=:0 <godot> --path . --rendering-driver vulkan --script res://scenes/test/_probe_spom.gd -- --out DIR [--i3-ws N|auto|off]
##
## ⚠ A HIDDEN WINDOW IS NOT DRAWN AND MEASURES 0.000 ms. Under i3 a new window opens behind
## a fullscreen one (Guake) and Godot reports itself minimized. --i3-ws N moves THIS window,
## floating at a fixed 1600x900, to workspace N. Default `auto` (when i3 is running): the
## visible workspace of the left-most OTHER display with no fullscreen window on it.
## Measuring waits until the window really has a size, and the run fails rather than
## print zeros.

const DIR := "res://texture_packs/ambientcg/Bricks105/"
const MODES := ["normal", "pom", "fpom", "spom", "spom_depth", "pom_off_unif"]
# name, camera position, look-at. The wall is the XY plane at z = 0, facing +Z.
const POSES := [
	["head_on", Vector3(0, 0, 1.6), Vector3(0, 0, 0)],
	["grazing", Vector3(-2.6, 0.2, 0.55), Vector3(0.6, 0, 0)],
	["far", Vector3(0, 0, 11.0), Vector3(0, 0, 0)],
	# looking along the wall at its FAR (x = +2) edge: the POM offset moves samples AWAY
	# from the eye, so rays can only leave the face through its far edge -> SPOM carves it
	["edge", Vector3(0.9, 0.0, 0.25), Vector3(2.0, 0.0, 0.0)],
]
const WARM := 30
const MEASURE := 90
const ROUNDS := 3                  # cases INTERLEAVED per round, median reported
const SUPERSAMPLE := 2.0           # 3D scale: 4x the shaded pixels -> shader cost >> noise

var _out := "/tmp/spom"
var _mat: ShaderMaterial
var _shaders: Array = []           # one compiled variant per mode (#define MODE n)
var _round := 0
var _samples: Dictionary = {}      # "pose/mode" -> [ms per round]
var _cam: Camera3D
var _vp: RID
var _case := 0
var _f := 0
var _acc := 0.0
var _rows: Array = []
var _i3_ws := ""
var _ready_frames := 0
var _next_i3 := 0


func _img(name: String) -> Image:
	var im := Image.load_from_file(ProjectSettings.globalize_path(DIR + name))
	im.generate_mipmaps()
	return im


## Min-depth pyramid: level k cell = 1 - MAX height of the 2^k x 2^k block it covers.
func _min_depth_pyramid(h: Image) -> ImageTexture:
	h.convert(Image.FORMAT_L8)
	var w := h.get_width()
	var ht := h.get_height()
	var level := PackedByteArray()
	level.resize(w * ht)
	var src := h.get_data()
	for i in w * ht:
		level[i] = 255 - src[i]
	var data := level.duplicate()
	while w > 1 or ht > 1:
		var nw := maxi(w / 2, 1)
		var nh := maxi(ht / 2, 1)
		var nxt := PackedByteArray()
		nxt.resize(nw * nh)
		for y in nh:
			for x in nw:
				var m := 255
				for dy in 2:
					for dx in 2:
						var sx := mini(x * 2 + dx, w - 1)
						var sy := mini(y * 2 + dy, ht - 1)
						m = mini(m, level[sy * w + sx])
				nxt[y * nw + x] = m
		data.append_array(nxt)
		level = nxt
		w = nw
		ht = nh
	var im := Image.create_from_data(h.get_width(), h.get_height(), true, Image.FORMAT_R8, data)
	return ImageTexture.create_from_image(im)


func _initialize() -> void:
	var args := OS.get_cmdline_user_args()
	var i := args.find("--out")
	if i >= 0 and i + 1 < args.size():
		_out = args[i + 1]
	DirAccess.make_dir_recursive_absolute(_out)
	var w := args.find("--i3-ws")
	var ws := args[w + 1] if w >= 0 and w + 1 < args.size() else "auto"
	_i3_ws = _i3_free_workspace() if ws == "auto" else ("" if ws == "off" else ws)
	print("PROBE i3 workspace: %s" % (_i3_ws if _i3_ws != "" else "(none — not moving the window)"))
	DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
	DisplayServer.window_set_size(Vector2i(1600, 900))
	DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)

	var src := FileAccess.get_file_as_string("res://scenes/test/spom_probe.gdshader")
	for m in MODES.size():
		var sh := Shader.new()
		sh.code = src.replace("shader_type spatial;", "shader_type spatial;\n#define MODE %d" % m)
		_shaders.append(sh)
	_mat = ShaderMaterial.new()
	_mat.shader = _shaders[1]
	_mat.set_shader_parameter("albedo_tex", ImageTexture.create_from_image(_img("albedo.jpg")))
	_mat.set_shader_parameter("normal_tex", ImageTexture.create_from_image(_img("normal.jpg")))
	var h := _img("height.png")
	_mat.set_shader_parameter("height_tex", ImageTexture.create_from_image(h))
	_mat.set_shader_parameter("mindepth_tex", _min_depth_pyramid(Image.load_from_file(ProjectSettings.globalize_path(DIR + "height.png"))))

	var wall := MeshInstance3D.new()
	var q := QuadMesh.new()
	q.size = Vector2(4, 2)
	wall.mesh = q
	wall.material_override = _mat
	root.add_child(wall)
	var back := MeshInstance3D.new()
	var bq := QuadMesh.new()
	bq.size = Vector2(40, 20)
	back.mesh = bq
	back.position = Vector3(0, 0, -0.5)
	var bm := StandardMaterial3D.new()
	bm.albedo_color = Color(0.35, 0.4, 0.45)
	back.material_override = bm
	root.add_child(back)

	var sun := DirectionalLight3D.new()
	sun.rotation_degrees = Vector3(-35, -40, 0)
	sun.light_energy = 1.4
	root.add_child(sun)
	var env := WorldEnvironment.new()
	env.environment = Environment.new()
	env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
	env.environment.ambient_light_color = Color(0.5, 0.5, 0.55)
	env.environment.background_mode = Environment.BG_COLOR
	env.environment.background_color = Color(0.2, 0.22, 0.25)
	root.add_child(env)

	_cam = Camera3D.new()
	_cam.fov = 70
	root.add_child(_cam)
	_vp = root.get_viewport_rid()
	RenderingServer.viewport_set_measure_render_time(_vp, true)
	_set_case()


## The visible workspace of the left-most display whose visible workspace holds NO
## fullscreen window ("" when i3 is not running or every display is covered).
func _i3_free_workspace() -> String:
	var o: Array = []
	if OS.execute("i3-msg", ["-t", "get_workspaces"], o) != 0 or o.is_empty():
		return ""
	var wss: Variant = JSON.parse_string(String(o[0]))
	var t: Array = []
	OS.execute("i3-msg", ["-t", "get_tree"], t)
	var tree: Variant = JSON.parse_string(String(t[0])) if not t.is_empty() else null
	if not (wss is Array) or not (tree is Dictionary):
		return ""
	var covered := {}            # workspace name -> true when a WINDOW on it is fullscreen
	_i3_fullscreen(tree, "", covered)
	var best := ""
	var best_x := INF
	for x: Dictionary in wss:
		if bool(x.get("visible")) and not covered.has(String(x.get("name"))):
			var rx := float((x.get("rect", {}) as Dictionary).get("x", 0))
			if rx < best_x:
				best_x = rx
				best = str(int(x.get("num")))
	return best


func _i3_fullscreen(n: Dictionary, ws: String, covered: Dictionary) -> void:
	if String(n.get("type")) == "workspace":
		ws = String(n.get("name"))
	elif int(n.get("fullscreen_mode", 0)) != 0 and n.get("window") != null and ws != "":
		covered[ws] = true      # workspace nodes themselves always report mode 1 — skip them
	for c: Variant in (n.get("nodes", []) as Array) + (n.get("floating_nodes", []) as Array):
		_i3_fullscreen(c, ws, covered)


## The Settings autoload may have put the root viewport on DLSS / a render scale. The
## probe measures the SHADER, so it pins native rendering (runtime only, nothing saved).
func _pin_native() -> void:
	if root.scaling_3d_mode != Viewport.SCALING_3D_MODE_BILINEAR or root.scaling_3d_scale != SUPERSAMPLE \
			or root.msaa_3d != Viewport.MSAA_DISABLED or root.use_taa:
		print("PROBE viewport was scaling_mode=%d scale=%.2f msaa=%d taa=%s -> native" % [
			root.scaling_3d_mode, root.scaling_3d_scale, root.msaa_3d, root.use_taa])
	root.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
	root.scaling_3d_scale = SUPERSAMPLE
	root.msaa_3d = Viewport.MSAA_DISABLED
	root.use_taa = false
	root.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED


func _set_case() -> void:
	_pin_native()
	var p: Array = POSES[_case / MODES.size()]
	_mat.shader = _shaders[_case % MODES.size()]
	_cam.look_at_from_position(p[1], p[2])
	_f = 0
	_acc = 0.0


func _process(_d: float) -> bool:
	if _ready_frames < 20:
		if _i3_ws != "" and _ready_frames == 0 and Time.get_ticks_msec() >= _next_i3:
			_next_i3 = Time.get_ticks_msec() + 1000
			# Match THIS window by its X id — a title match could grab the user's own game.
			var xid := DisplayServer.window_get_native_handle(DisplayServer.WINDOW_HANDLE)
			var crit := '[id="%d"]' % xid
			var o: Array = []
			OS.execute("i3-msg", ['%s move container to workspace number %s; %s floating enable, resize set 1600 900' % [crit, _i3_ws, crit]], o)
			var st: Array = []
			OS.execute("xprop", ["-id", str(xid), "WM_STATE", "_NET_WM_STATE"], st)
			print("PROBE i3 xid=%d %s | %s" % [xid, str(o).strip_edges(), str(st).replace("\\n", " ")])
		if root.size.x >= 1000 and DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_MINIMIZED:
			_ready_frames += 1
		elif Time.get_ticks_msec() > 20000:   # wall time: hidden frames run unthrottled
			print("PROBE FAIL window never drew (size %s mode %d) — hidden behind a fullscreen window? use --i3-ws" % [root.size, DisplayServer.window_get_mode()])
			quit(1)
			return true
		return false
	_f += 1
	if _f > WARM:
		_acc += RenderingServer.viewport_get_measured_render_time_gpu(_vp)
	if _f == WARM + MEASURE:
		var pose: String = POSES[_case / MODES.size()][0]
		var mode: String = MODES[_case % MODES.size()]
		var ms := _acc / MEASURE
		if ms <= 0.0:
			print("PROBE FAIL %s/%s measured 0 ms — the window is not being drawn" % [pose, mode])
			quit(1)
			return true
		var key := "%s/%s" % [pose, mode]
		if not _samples.has(key):
			_samples[key] = []
		_samples[key].append(ms)
		if _round == 0:
			root.get_texture().get_image().save_png("%s/%s_%s.png" % [_out, pose, mode])
		_case += 1
		if _case >= POSES.size() * MODES.size():
			_case = 0
			_round += 1
			print("PROBE round %d done" % _round)
			if _round >= ROUNDS:
				_report()
				quit(0)
				return true
		_set_case()
	return false


func _report() -> void:
	var px := Vector2(root.size) * SUPERSAMPLE
	print("PROBE GPU ms, median of %d interleaved rounds x %d frames, %dx%d shaded px" % [
		ROUNDS, MEASURE, int(px.x), int(px.y)])
	for p: Array in POSES:
		var base := 0.0
		for m: String in MODES:
			var v: Array = _samples["%s/%s" % [p[0], m]].duplicate()
			v.sort()
			var med: float = v[v.size() / 2]
			if m == "normal":
				base = med
			print("PROBE %-8s %-11s %.3f ms  (+%.3f vs normal)  rounds %s" % [
				p[0], m, med, med - base, str(v.map(func(x: float) -> String: return "%.3f" % x))])