extends Node
## Deterministic path-traced frame-time benchmark for the DC-terrain streaming scene.
##
## Inert unless `--pt-bench` appears in the user args (everything after a bare `--`),
## so the scene keeps working as a manual flycam when the flag is absent.
##
## When enabled it takes the camera off the flycam, parks it at the start of a fixed
## path until BOTH the terrain streamer and the frame time have gone quiet, then flies
## the path on a wall-clock parameterisation and reports p50/p95/p99 frame times.
##
## Usage — drive it through the runner, which also validates the binary:
## bash scripts/pt-bench.sh --label=baseline
## or by hand against the godot-rtx build (RT work does not go through the shipped
## launcher — see the RTX exception in the canonical-launcher rule):
## <godot-rtx> --path . res://scenes/test/dc_terrain_streaming_pt_noplayer.tscn \
## -- --pt-bench --label=baseline --spp=1 --bounces=1 --dlss=off
##
## Why the settle gate exists: hit-group shaders compile asynchronously (nvidia-rtx-godot
## 3c50f6017b) and terrain chunks stream in on worker threads, so the first seconds
## measure the build-up, not the steady state. If the gate times out the run still
## reports, stamped UNSETTLED — a check that cannot express its failure is not a check.
##
## Flags (all optional):
## --label=NAME tag for the output filenames and the report header
## --spp=N path-tracing samples per pixel (default: whatever the scene sets)
## --bounces=N path-tracing max bounces (default: whatever the scene sets)
## --resolution=WxH resize the window before measuring (the WM may refuse; the
## report always prints the render target it actually got)
## --vis=N path-tracing debug vis mode (default 0 = full PT)
## --dlss=MODE off | sr_perf | sr_quality | rr_perf | rr_quality | rr_dlaa
## --raster measure the rasterizer instead (path tracing off)
## --unshaded raster: draw unshaded albedo (Viewport.DEBUG_DRAW_UNSHADED).
## The raster counterpart of PT's --vis=8, so a raster and a
## path-traced shot of the same viewpoint can be compared for
## texture LOD without lighting or Monte Carlo grain in the way.
## --lod-heatmap dc_level only: render the selected texture LOD as an emitted
## colour ramp instead of the texture. Ground truth is the same
## capture under --raster, whose derivatives are real.
## --static hold the camera at waypoint 0 instead of flying the path
## --freeze-lod freeze terrain LOD after settling (isolates render from streaming)
## --measure=SEC measurement window, default 30
## --warmup-max=SEC settle-gate timeout, default 60
## --shots=N screenshots spread along the path, default 3 (0 disables)
## --radius=N orbit radius, default 140
## --height=N orbit height, default 70 (raised further if inside the terrain)
## --pitch=DEG fixed look-down angle instead of aiming at the ground
## --out=DIR report directory, default res://benchmarks/pt
# --- path ------------------------------------------------------------------
# A closed orbit around the origin: fixed, closed (so start and end framing match),
# and it moves far enough in world space to force octree re-centres, which a
# stationary camera never exercises.
const ORBIT_SEGMENTS := 4 # quadrants, reported separately
const CLEARANCE := 12.0 # keep the camera this far outside the SDF surface
const CLEARANCE_STEPS := 8
const LOOK_AHEAD := 90.0 # aim this far along the direction of travel
const SURFACE_MARCH_STEP := 6.0 # SDF march resolution when locating the ground
const SURFACE_MARCH_MAX := 80 # steps before giving up on finding ground
# --- settle gate -----------------------------------------------------------
const MIN_WARMUP := 2.0 # never trust a settle before this
const QUIET_FRAMES := 30 # consecutive frames both conditions must hold
const MIN_ACTIVE_CHUNKS := 10 # terrain has actually produced geometry
const STABILITY_WINDOW := 30 # frames per half of the median-vs-median test
const STABILITY_TOLERANCE := 0.05 # 5% median drift between halves
const SPIKE_FACTOR := 3.0 # no frame in the recent half above 3x its median
const RTX_INIT_MAX_FRAMES := 300 # give RTXTest this long to resolve the Environment
# A window the WM never gave real geometry to (an i3 tab that is not the visible one, a
# minimised window) leaves Godot rendering into a clamped 2x2 target. The run still
# produces frame times — ~7ms of nothing — so this is a hard abort, not a warning.
const MIN_RENDER_PX := 256
# A tiling WM trims a border off a requested window size (1920x1080 -> 1914x1053, 97% of
# the pixels). That is not a refused resize; 1274x693 instead of 1920x1080 is.
const RESOLUTION_TOLERANCE := 0.10
const DLSS_MODES := {
"off": 0, "sr_perf": 1, "sr_quality": 2,
"rr_perf": 3, "rr_quality": 4, "rr_dlaa": 5,
}
enum Phase { SETTLE, MEASURE, DONE }
@export var terrain_path: NodePath = ^"../DCTerrain"
@export var level_path: NodePath = ^"../MapDCLevel/DCLevel"
@export var camera_path: NodePath = ^"../Camera3D"
@export var rtx_path: NodePath = ^"../RTXTest"
@export var stats_label_path: NodePath = ^"../CanvasLayer/StatsLabel"
var enabled: bool = false
var phase: int = Phase.SETTLE
# config (from user args)
var cfg_label: String = ""
var cfg_spp: int = -1 # -1 = whatever the scene authored
var cfg_bounces: int = -1
var authored_spp: int = 1
var authored_bounces: int = 1
var cfg_resolution: Vector2i = Vector2i.ZERO
var cfg_vis: int = 0
var cfg_dlss: String = "off"
var cfg_raster: bool = false
var cfg_unshaded: bool = false
var cfg_lod_heatmap: bool = false
var cfg_lod_mode: int = 0
var cfg_no_pbr: bool = false
var cfg_static: bool = false
var cfg_freeze_lod: bool = false
var cfg_measure: float = 30.0
var cfg_warmup_max: float = 60.0
var cfg_shots: int = 3
var cfg_out: String = "res://benchmarks/pt"
var cfg_radius: float = 140.0
var cfg_height: float = 70.0
var cfg_pitch: float = NAN # explicit look-down degrees; NaN = aim at the ground
var terrain: Node3D = null
var level: Node3D = null # DCLevel (Quake brush map), when there is no DCTerrain
var level_tris: int = -1
var level_stable_frames: int = 0
var cam: Camera3D = null
var rtx: Node = null
var stats_label: Label = null
# settle state
var settle_elapsed: float = 0.0
var quiet_run: int = 0
var settled: bool = false
var settle_seconds: float = 0.0
var recent_ms: Array[float] = []
# measure state
var measure_elapsed: float = 0.0
var frame_ms: Array[float] = []
var seg_ms: Array = [] # Array[Array[float]] — one bucket per orbit segment
var pending_samples: Array[int] = []
var active_min: int = 0x7FFFFFFF
var active_max: int = 0
var lifted_frames: int = 0
var min_clearance: float = INF
var lifted_this_frame: bool = false
var prim_samples: Array[int] = []
var obj_samples: Array[int] = []
var shots_taken: int = 0
var shot_paths: Array[String] = []
var skip_frames: int = 0
var run_basename: String = ""
var skip_first: bool = true
var _last_usec: int = 0
func _ready() -> void:
var args := OS.get_cmdline_user_args()
enabled = "--pt-bench" in args
if not enabled:
set_process(false)
return
_parse_args(args)
terrain = get_node_or_null(terrain_path) as Node3D
cam = get_node_or_null(camera_path) as Camera3D
rtx = get_node_or_null(rtx_path)
stats_label = get_node_or_null(stats_label_path) as Label
level = get_node_or_null(level_path) as Node3D
# A scene that builds its camera in code cannot be reached by a fixed NodePath:
# Camera3D.new() + add_child() without force_readable_name auto-names the node
# "@Camera3D@23", so map_dc_terrain.gd:128-133's fly camera has no stable path.
# Fall back to whichever camera is CURRENT, which is the one whose view is being
# measured — the property that actually matters. Driving a non-current camera is
# the silent-wrong-number failure this whole harness exists to prevent.
if cam == null:
cam = get_viewport().get_camera_3d()
if cam != null:
print("[PT-BENCH] no camera at %s; bound the current camera instead: %s" % [
camera_path, cam.get_path()])
if cam == null:
push_error("[PT-BENCH] no Camera3D at %s and no current camera — aborting" % camera_path)
get_tree().quit(1)
return
# Bound a camera by path that is NOT the one rendering: every frame time would be
# real, and every one of them would describe a view nobody sees.
if not cam.current:
push_error("[PT-BENCH] camera %s is not current — it is not the camera being" % cam.get_path())
push_error("[PT-BENCH] rendered, so these frame times would describe another view. Aborting.")
get_tree().quit(1)
return
if terrain == null and level == null:
push_warning("[PT-BENCH] neither DCTerrain (%s) nor DCLevel (%s) found; the settle gate falls back to frame-time stability alone" % [terrain_path, level_path])
# Read the PT config the scene actually authors, BEFORE RTXTest._init_rt_params()
# overwrites it with 1spp/1bounce two frames from now. Benchmarking the engine's
# cheapest settings while the scene specifies something heavier is how you end up
# comparing against a number nobody plays at.
var authored := _find_environment()
if authored != null:
authored_spp = int(authored.get("pathtracing_samples_per_pixel"))
authored_bounces = int(authored.get("pathtracing_max_bounces"))
if cfg_resolution != Vector2i.ZERO:
DisplayServer.window_set_size(cfg_resolution)
# Take the camera off the flycam: it reads live input and would make the path
# non-deterministic. It also captures the mouse in _ready; give that back.
cam.set_process(false)
cam.set_process_unhandled_input(false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if cfg_unshaded:
get_viewport().debug_draw = Viewport.DEBUG_DRAW_UNSHADED
# Uncapped, unthrottled — a vsync'd frame time measures the monitor, not the renderer.
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
Engine.max_fps = 0
for i in ORBIT_SEGMENTS:
seg_ms.append([] as Array[float])
run_basename = "pt-bench_%s%s" % [
Time.get_datetime_string_from_system().replace(":", "-"),
("_" + cfg_label) if cfg_label != "" else ""]
_place_camera(0.0)
# RTXTest resolves the WorldEnvironment over the first frames and stamps its own
# defaults on it; apply ours after, or they get overwritten. Hold off _process
# until then so the settle window never sees frames from the wrong config.
set_process(false)
await _await_rtx_ready()
_apply_config()
set_process(true)
print("[PT-BENCH] %s" % _config_line())
print("[PT-BENCH] settling (max %.0fs): waiting for terrain quiescence + stable frame time" % cfg_warmup_max)
_last_usec = Time.get_ticks_usec()
func _parse_args(args: PackedStringArray) -> void:
for a in args:
if a == "--raster": cfg_raster = true
elif a == "--unshaded": cfg_unshaded = true
elif a == "--lod-heatmap": cfg_lod_heatmap = true
elif a.begins_with("--lod-mode="):
cfg_lod_mode = int(a.substr(11))
cfg_lod_heatmap = true
elif a == "--no-pbr": cfg_no_pbr = true
elif a == "--static": cfg_static = true
elif a == "--freeze-lod": cfg_freeze_lod = true
elif a.begins_with("--label="): cfg_label = a.substr(8)
elif a.begins_with("--spp="): cfg_spp = int(a.substr(6))
elif a.begins_with("--bounces="): cfg_bounces = int(a.substr(10))
elif a.begins_with("--vis="): cfg_vis = int(a.substr(6))
elif a.begins_with("--dlss="): cfg_dlss = a.substr(7).to_lower()
elif a.begins_with("--measure="): cfg_measure = float(a.substr(10))
elif a.begins_with("--warmup-max="): cfg_warmup_max = float(a.substr(13))
elif a.begins_with("--shots="): cfg_shots = int(a.substr(8))
elif a.begins_with("--radius="): cfg_radius = float(a.substr(9))
elif a.begins_with("--height="): cfg_height = float(a.substr(9))
elif a.begins_with("--pitch="): cfg_pitch = float(a.substr(8))
elif a.begins_with("--resolution="):
var wh := a.substr(13).split("x")
if wh.size() == 2:
cfg_resolution = Vector2i(int(wh[0]), int(wh[1]))
else:
push_error("[PT-BENCH] --resolution wants WxH, e.g. 1920x1080")
elif a.begins_with("--out="): cfg_out = a.substr(6)
if not DLSS_MODES.has(cfg_dlss):
push_error("[PT-BENCH] unknown --dlss=%s, falling back to off" % cfg_dlss)
cfg_dlss = "off"
func _await_rtx_ready() -> void:
if rtx == null:
return
for _i in RTX_INIT_MAX_FRAMES:
if rtx.get("env") != null:
return
await get_tree().process_frame
push_warning("[PT-BENCH] RTXTest never resolved a WorldEnvironment — PT config may be the scene default, not the requested one")
func _apply_config() -> void:
if rtx != null and rtx.get("env") != null:
# Reuse RTXTest's setters: they handle the legacy raytracing_* API too.
rtx.call("_set_rt_enabled", not cfg_raster)
if not cfg_raster:
rtx.call("_set_spp", _eff_spp())
rtx.call("_set_bounces", _eff_bounces())
rtx.call("_set_debug_mode", cfg_vis)
rtx.call("_set_dlss_mode", DLSS_MODES[cfg_dlss])
return
# Fallback: no RTXTest in the scene, drive the Environment directly.
var env := _find_environment()
if env == null:
push_error("[PT-BENCH] no WorldEnvironment — cannot configure path tracing")
return
env.set("pathtracing_enabled", not cfg_raster)
if not cfg_raster:
env.set("pathtracing_samples_per_pixel", _eff_spp())
env.set("pathtracing_max_bounces", _eff_bounces())
env.set("pathtracing_debug_mode", cfg_vis)
func _eff_spp() -> int:
return cfg_spp if cfg_spp > 0 else authored_spp
func _eff_bounces() -> int:
return cfg_bounces if cfg_bounces > 0 else authored_bounces
## Flip dc_level.gdshader's debug_lod uniform. The level's material is built in
## GDScript by map_dc_level.gd and applied as material_override on its LevelMesh, so
## it has to be reached through the node rather than a preloaded resource.
func _enable_lod_heatmap() -> void:
var found := 0
for n in get_tree().get_root().find_children("*", "MeshInstance3D", true, false):
var mi := n as MeshInstance3D
var mat := mi.material_override as ShaderMaterial
if mat != null and mat.shader != null:
mat.set_shader_parameter("debug_lod", true)
mat.set_shader_parameter("debug_lod_mode", cfg_lod_mode)
found += 1
if found == 0:
push_error("[PT-BENCH] --lod-heatmap found no ShaderMaterial material_override; nothing to instrument")
get_tree().quit(1)
else:
print("[PT-BENCH] LOD heatmap enabled on %d material(s), mode %d" % [found, cfg_lod_mode])
## Force use_pbr off, so a capture with PBR can be differenced against one without it.
## Without this the only evidence that the normal/gloss/glow atlases DO anything is that
## the render "looks three-dimensional" — which the diffuse texture alone can also
## produce. Same material-poke route as _enable_lod_heatmap: the level's material is
## built in GDScript and applied as material_override, so it is reached through the node.
func _disable_pbr() -> void:
var found := 0
for n in get_tree().get_root().find_children("*", "MeshInstance3D", true, false):
var mi := n as MeshInstance3D
var mat := mi.material_override as ShaderMaterial
if mat != null and mat.shader != null:
mat.set_shader_parameter("use_pbr", false)
found += 1
print("[PT-BENCH] PBR DISABLED on %d material(s) — A/B reference capture" % found)
func _find_environment() -> Environment:
for n in get_tree().get_root().find_children("*", "WorldEnvironment", true, false):
var we := n as WorldEnvironment
if we != null and we.environment != null:
return we.environment
return null
func _process(_delta: float) -> void:
if not enabled or phase == Phase.DONE:
return
var now := Time.get_ticks_usec()
var ms := float(now - _last_usec) / 1000.0
_last_usec = now
match phase:
Phase.SETTLE: _process_settle(ms)
Phase.MEASURE: _process_measure(ms)
# --- settle ----------------------------------------------------------------
func _process_settle(ms: float) -> void:
settle_elapsed += ms / 1000.0
if settle_elapsed >= MIN_WARMUP and not _check_render_size():
return
recent_ms.append(ms)
if recent_ms.size() > STABILITY_WINDOW * 2:
recent_ms.remove_at(0)
var terrain_quiet := _terrain_quiet()
var time_quiet := _frametime_quiet()
if terrain_quiet and time_quiet and settle_elapsed >= MIN_WARMUP:
quiet_run += 1
else:
quiet_run = 0
_set_status("SETTLE %5.1fs terrain:%s frametime:%s quiet %d/%d" % [
settle_elapsed, "ok" if terrain_quiet else "..",
"ok" if time_quiet else "..", quiet_run, QUIET_FRAMES])
if quiet_run >= QUIET_FRAMES:
settled = true
elif settle_elapsed >= cfg_warmup_max:
settled = false
push_warning("[PT-BENCH] settle gate TIMED OUT after %.1fs (terrain_quiet=%s frametime_quiet=%s) — results are UNSETTLED" % [
settle_elapsed, terrain_quiet, time_quiet])
else:
return
settle_seconds = settle_elapsed
# Only now is the level guaranteed to exist: map_dc_level.gd imports the .MAP via
# call_deferred, so at _ready() time there is no LevelMesh and no material_override
# to instrument yet.
if cfg_lod_heatmap:
_enable_lod_heatmap()
if cfg_no_pbr:
_disable_pbr()
# Re-assert the requested size: the WM (or pt-bench.sh's own i3 nudge) may have
# resized the window out from under the startup request.
if cfg_resolution != Vector2i.ZERO and DisplayServer.window_get_size() != cfg_resolution:
DisplayServer.window_set_size(cfg_resolution)
print("[PT-BENCH] settled=%s after %.1fs (chunks active=%d pending=%d collision_q=%d) — measuring %.0fs" % [
settled, settle_seconds, _terrain_int("get_active_chunk_count"),
_terrain_int("get_pending_count"), _terrain_int("get_collision_queue_size"), cfg_measure])
if cfg_freeze_lod and terrain.has_method("set_lod_frozen"):
terrain.set_lod_frozen(true)
print("[PT-BENCH] terrain LOD frozen — measuring render cost without streaming churn")
phase = Phase.MEASURE
skip_first = true
## Returns false (and kills the run) if the window never got real geometry. Measuring a
## 2x2 render target is the same class of mistake as an A/B where neither run loaded a
## map: it produces numbers that mean nothing (hyper-games #11 §4.4).
func _check_render_size() -> bool:
var size := get_viewport().get_texture().get_size()
if size.x >= MIN_RENDER_PX and size.y >= MIN_RENDER_PX:
return true
push_error("[PT-BENCH] ABORT — render target is %s (window %s), below the %d px floor." % [
size, DisplayServer.window_get_size(), MIN_RENDER_PX])
push_error("[PT-BENCH] The window has no usable geometry, so this run would measure an")
push_error("[PT-BENCH] empty framebuffer. Under a tiling WM the window is probably a")
push_error("[PT-BENCH] background tab: make it visible (i3: float it and give it a size)")
push_error("[PT-BENCH] and re-run. scripts/pt-bench.sh does this automatically.")
phase = Phase.DONE
get_tree().quit(3)
return false
## Has the scene finished building itself? Streaming terrain and a Quake brush map
## become ready in completely different ways, and a scene with neither (a plain mesh
## level) is ready as soon as it is loaded -- so the frame-time stability half of the
## gate carries it.
func _terrain_quiet() -> bool:
if terrain != null:
return _terrain_int("get_pending_count") == 0 \
and _terrain_int("get_collision_queue_size") == 0 \
and _terrain_int("get_active_chunk_count") >= MIN_ACTIVE_CHUNKS
if level != null:
# The .MAP is imported deferred and asynchronously, so wait for the triangle
# count to become non-zero AND stop changing.
var tris := 0
if level.has_method("get_triangle_count"):
tris = int(level.call("get_triangle_count"))
if tris <= 0:
level_stable_frames = 0
level_tris = tris
return false
if tris == level_tris:
level_stable_frames += 1
else:
level_stable_frames = 0
level_tris = tris
return level_stable_frames >= QUIET_FRAMES
return true
## Median of the newest half vs the older half: a frame time still trending (shaders
## compiling, chunks uploading) moves the median; a settled one does not. The spike
## test catches a window that is stable on average but still hitching.
func _frametime_quiet() -> bool:
if recent_ms.size() < STABILITY_WINDOW * 2:
return false
var older := recent_ms.slice(0, STABILITY_WINDOW)
var newer := recent_ms.slice(STABILITY_WINDOW)
var m_old := _median(older)
var m_new := _median(newer)
if m_old <= 0.0 or m_new <= 0.0:
return false
if absf(m_new - m_old) / m_old > STABILITY_TOLERANCE:
return false
for v in newer:
if v > m_new * SPIKE_FACTOR:
return false
return true
# --- measure ---------------------------------------------------------------
func _process_measure(ms: float) -> void:
# The transition frame carries the cost of the phase switch (and the LOD freeze).
if skip_first:
skip_first = false
return
measure_elapsed += ms / 1000.0
var t := clampf(measure_elapsed / cfg_measure, 0.0, 0.999999)
# A screenshot forces a GPU->CPU readback; the stall lands on the frames after it.
if skip_frames > 0:
skip_frames -= 1
_place_camera(t)
return
if cfg_shots > 0 and shots_taken < cfg_shots and t >= (float(shots_taken) + 0.5) / cfg_shots:
_take_shot(t)
skip_frames = 2
_place_camera(t)
return
frame_ms.append(ms)
prim_samples.append(int(RenderingServer.get_rendering_info(
RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)))
obj_samples.append(int(RenderingServer.get_rendering_info(
RenderingServer.RENDERING_INFO_TOTAL_OBJECTS_IN_FRAME)))
var seg := clampi(int(t * ORBIT_SEGMENTS), 0, ORBIT_SEGMENTS - 1)
seg_ms[seg].append(ms)
var pending := _terrain_int("get_pending_count")
var active := _terrain_int("get_active_chunk_count")
pending_samples.append(pending)
active_min = mini(active_min, active)
active_max = maxi(active_max, active)
_place_camera(t)
if lifted_this_frame:
lifted_frames += 1
_set_status("MEASURE %5.1f/%.0fs %d frames pending %d" % [
measure_elapsed, cfg_measure, frame_ms.size(), pending])
if measure_elapsed >= cfg_measure:
phase = Phase.DONE
_report()
## Visual proof of what was measured. A frame-time number with no picture behind it is
## how you end up reporting 700fps for a camera buried in a hillside.
func _take_shot(t: float) -> void:
var dir := _out_dir()
DirAccess.make_dir_recursive_absolute(dir)
var path := "%s/%s_shot%d.png" % [dir, run_basename, shots_taken]
var img := get_viewport().get_texture().get_image()
if img != null and img.save_png(path) == OK:
shot_paths.append(path)
print("[PT-BENCH] shot %d at t=%.2f (cam %s): %s" % [shots_taken, t, cam.global_position, path])
else:
push_warning("[PT-BENCH] could not save screenshot to %s" % path)
shots_taken += 1
func _out_dir() -> String:
return ProjectSettings.globalize_path(cfg_out) if cfg_out.begins_with("res://") else cfg_out
# --- camera path -----------------------------------------------------------
## Position at path parameter t in [0,1). Wall-clock parameterised, so the camera is
## in the same place at the same second of the run no matter what the frame rate is —
## two runs are comparable even when one of them is half the speed.
func _place_camera(t: float) -> void:
var angle := 0.0 if cfg_static else t * TAU
var pos := Vector3(cos(angle) * cfg_radius, cfg_height, sin(angle) * cfg_radius)
pos = _lift_above_surface(pos)
var tangent := Vector3(-sin(angle), 0.0, cos(angle))
if cfg_static:
tangent = Vector3(-1.0, 0.0, 0.0)
# Aim at the ground ahead rather than at a fixed angle. A fixed pitch over floating
# islands spends most of the frame on sky and void, which path-traces for almost
# nothing — the first run read 712fps on a screen that was two-thirds empty.
var ahead := pos + tangent * LOOK_AHEAD
var target := ahead
if is_nan(cfg_pitch):
var ground_y := _surface_y(ahead.x, ahead.z, pos.y)
target = Vector3(ahead.x, ground_y, ahead.z) if is_finite(ground_y) \
else ahead + Vector3.DOWN * (LOOK_AHEAD * 0.4)
else:
target = ahead + Vector3.DOWN * (LOOK_AHEAD * tan(deg_to_rad(cfg_pitch)))
cam.global_position = pos
cam.look_at(target, Vector3.UP)
## March down the SDF to find the ground under (x, z). Returns INF when there is nothing
## below — over a floating island's edge that is the honest answer, and the caller falls
## back to a fixed downward angle.
func _surface_y(x: float, z: float, from_y: float) -> float:
if terrain == null or not terrain.has_method("get_distance"):
return INF
var y := from_y
for _i in SURFACE_MARCH_MAX:
y -= SURFACE_MARCH_STEP
if terrain.get_distance(Vector3(x, y, z)) < 0.0:
return y + SURFACE_MARCH_STEP * 0.5
return INF
## The orbit is a fixed geometric path but the terrain is procedural, so a waypoint can
## land inside rock — which would measure the inside of a chunk, not the scene. Push up
## along Y until the SDF says we are clear. Deterministic (same terrain, same path) and
## counted, so the report says when it happened.
func _lift_above_surface(pos: Vector3) -> Vector3:
lifted_this_frame = false
if terrain == null or not terrain.has_method("get_distance"):
return pos
for _i in CLEARANCE_STEPS:
var d: float = terrain.get_distance(pos)
if d >= CLEARANCE:
min_clearance = minf(min_clearance, d)
return pos
pos.y += maxf(CLEARANCE - d, 1.0)
lifted_this_frame = true
# Ran out of steps: the camera may still be inside the surface. Record the truth.
min_clearance = minf(min_clearance, terrain.get_distance(pos))
return pos
# --- reporting -------------------------------------------------------------
func _report() -> void:
if frame_ms.is_empty():
push_error("[PT-BENCH] measured zero frames — nothing to report")
get_tree().quit(1)
return
var lines := _build_report()
for l in lines:
print(l)
var base := run_basename
var dir := _out_dir()
DirAccess.make_dir_recursive_absolute(dir)
var txt := FileAccess.open("%s/%s.txt" % [dir, base], FileAccess.WRITE)
if txt != null:
for l in lines:
txt.store_line(l)
txt.close()
print("[PT-BENCH] report: %s/%s.txt" % [dir, base])
else:
push_error("[PT-BENCH] could not write report to %s" % dir)
var csv := FileAccess.open("%s/%s.csv" % [dir, base], FileAccess.WRITE)
if csv != null:
csv.store_line("scope,frames,avg_ms,p50_ms,p95_ms,p99_ms,min_ms,max_ms,fps_at_p50")
csv.store_line(_csv_row("overall", frame_ms))
for i in ORBIT_SEGMENTS:
csv.store_line(_csv_row("segment%d" % i, seg_ms[i]))
csv.close()
print("[PT-BENCH] csv: %s/%s.csv" % [dir, base])
get_tree().quit(0 if settled else 2)
func _build_report() -> Array[String]:
var sorted := frame_ms.duplicate()
sorted.sort()
var p50 := _pct(sorted, 0.50)
var p95 := _pct(sorted, 0.95)
var p99 := _pct(sorted, 0.99)
var avg := _mean(frame_ms)
var out: Array[String] = []
out.append("=".repeat(78))
out.append("[PT-BENCH] %s%s" % [
Time.get_datetime_string_from_system(),
(" label=" + cfg_label) if cfg_label != "" else ""])
out.append("=".repeat(78))
if not settled:
out.append("!! UNSETTLED — the settle gate timed out after %.1fs. Frame times below" % settle_seconds)
out.append("!! include streaming and/or async shader compilation. DO NOT COMPARE THEM.")
out.append("")
out.append("-- run ----------------------------------------------------------------")
out.append(" scene : %s" % get_tree().current_scene.scene_file_path)
out.append(" config : %s" % _config_line())
var aim := "aimed at the ground ahead" if is_nan(cfg_pitch) else "pitch %.0f deg" % cfg_pitch
var path_desc := "STATIC at waypoint 0, %s" % aim if cfg_static else \
"orbit r=%.0f h=%.0f, one revolution over %.0fs, %s" % [
cfg_radius, cfg_height, cfg_measure, aim]
out.append(" camera path : %s" % path_desc)
out.append(" settled : %s after %.1fs" % [settled, settle_seconds])
out.append(" camera lifted : %d/%d frames pushed up out of the SDF surface" % [
lifted_frames, frame_ms.size()])
out.append(" min clearance : %.1f %s" % [min_clearance,
"(negative = the camera was INSIDE terrain, numbers are not scene-representative)"
if min_clearance < 0.0 else ""])
out.append("")
out.append("-- build (record this: a config difference reads as a perf difference) --")
out.append(" executable : %s" % OS.get_executable_path())
out.append(" engine : %s" % Engine.get_version_info().get("string", "?"))
out.append(" gpu : %s" % RenderingServer.get_video_adapter_name())
out.append(" driver : %s" % RenderingServer.get_video_adapter_api_version())
out.append(" rendering : %s" % ProjectSettings.get_setting("rendering/renderer/rendering_method", "forward_plus"))
var rd := RenderingServer.get_rendering_device()
if rd != null:
out.append(" vk rt pipeline : %s" % rd.has_feature(RenderingDevice.SUPPORTS_RAYTRACING_PIPELINE))
out.append(" vk ray query : %s" % rd.has_feature(RenderingDevice.SUPPORTS_RAY_QUERY))
var vp := get_viewport()
# Compare pixel COUNT with a tolerance, not exact dimensions: a tiling WM shaves a
# few pixels of border off the request, which is not a refused resize. Only a real
# gap (the 1274x693-instead-of-1920x1080 case) is worth invalidating a run over.
if cfg_resolution != Vector2i.ZERO:
var got := get_viewport().get_texture().get_size()
var want_px := float(cfg_resolution.x) * float(cfg_resolution.y)
var got_px := got.x * got.y
if want_px > 0.0 and absf(got_px - want_px) / want_px > RESOLUTION_TOLERANCE:
out.append(" !! RESOLUTION MISMATCH — asked for %s, rendered at %s (%.0f%% of the" % [
cfg_resolution, got, 100.0 * got_px / want_px])
out.append(" !! requested pixels). The window manager refused the resize; do NOT")
out.append(" !! compare this run against one at a different size — frame time")
out.append(" !! scales with pixel count.")
out.append(" window size : %s%s" % [DisplayServer.window_get_size(),
" (requested %s)" % cfg_resolution if cfg_resolution != Vector2i.ZERO else ""])
out.append(" viewport rect : %s" % vp.get_visible_rect().size)
out.append(" render target : %s" % vp.get_texture().get_size())
out.append(" scaling : scaling_3d_mode=%d scale=%.2f" % [
vp.scaling_3d_mode, vp.scaling_3d_scale])
out.append(" NOTE: scaling_3d_mode reads back the DLSS mode even in a build compiled")
out.append(" WITHOUT DLSS (renderer_viewport.cpp only ERR_PRINTs). The log, not")
out.append(" this field, is the authority on whether DLSS actually ran.")
out.append("")
out.append("-- frame time ---------------------------------------------------------")
out.append(" frames : %d over %.1fs" % [frame_ms.size(), measure_elapsed])
out.append(" p50 : %8.2f ms (%.1f fps)" % [p50, 1000.0 / maxf(p50, 0.001)])
out.append(" p95 : %8.2f ms (%.1f fps)" % [p95, 1000.0 / maxf(p95, 0.001)])
out.append(" p99 : %8.2f ms (%.1f fps)" % [p99, 1000.0 / maxf(p99, 0.001)])
out.append(" avg : %8.2f ms (%.1f fps)" % [avg, 1000.0 / maxf(avg, 0.001)])
out.append(" min / max : %8.2f / %.2f ms" % [sorted[0], sorted[sorted.size() - 1]])
out.append("")
out.append(" %-10s %8s %8s %8s %8s %8s" % ["segment", "frames", "p50 ms", "p95 ms", "p99 ms", "fps@p50"])
for i in ORBIT_SEGMENTS:
var s: Array = seg_ms[i]
if s.is_empty():
continue
var ss := s.duplicate()
ss.sort()
var sp50 := _pct(ss, 0.50)
out.append(" %-10s %8d %8.2f %8.2f %8.2f %8.1f" % [
"seg%d" % i, s.size(), sp50, _pct(ss, 0.95), _pct(ss, 0.99), 1000.0 / maxf(sp50, 0.001)])
out.append("")
out.append("-- streaming during measurement ---------------------------------------")
var ps := pending_samples.duplicate()
ps.sort()
out.append(" premesh pending: p50 %d, max %d (non-zero = streaming was still working)" % [
int(_pct(ps, 0.50)), ps[ps.size() - 1]])
out.append(" active chunks : %d..%d" % [active_min, active_max])
out.append(" lod frozen : %s" % cfg_freeze_lod)
out.append("")
out.append("-- what was actually on screen ----------------------------------------")
var prims := prim_samples.duplicate()
prims.sort()
var objs := obj_samples.duplicate()
objs.sort()
out.append(" primitives : p50 %d (near zero = the camera was looking at nothing)" % int(_pct(prims, 0.50)))
out.append(" objects : p50 %d" % int(_pct(objs, 0.50)))
if shot_paths.is_empty():
out.append(" screenshots : none (--shots=0) — nothing corroborates these numbers")
else:
for p in shot_paths:
out.append(" screenshot : %s" % p)
out.append("=".repeat(78))
return out
func _config_line() -> String:
if cfg_raster:
return "RASTER (path tracing off)%s%s, dlss=%s" % [", unshaded" if cfg_unshaded else "", ", lod-heatmap" if cfg_lod_heatmap else "", cfg_dlss]
return "PT spp=%d%s bounces=%d%s vis=%d dlss=%s%s" % [
_eff_spp(), "" if cfg_spp > 0 else " (scene)",
_eff_bounces(), "" if cfg_bounces > 0 else " (scene)",
cfg_vis, cfg_dlss, ", lod-frozen" if cfg_freeze_lod else ""]
func _csv_row(scope: String, samples: Array) -> String:
if samples.is_empty():
return "%s,0,0,0,0,0,0,0,0" % scope
var s := samples.duplicate()
s.sort()
var p50 := _pct(s, 0.50)
return "%s,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%.2f" % [
scope, s.size(), _mean(samples), p50, _pct(s, 0.95), _pct(s, 0.99),
s[0], s[s.size() - 1], 1000.0 / maxf(p50, 0.001)]
func _set_status(text: String) -> void:
if stats_label != null:
stats_label.text = "[PT-BENCH]\n%s" % text
static func _pct(sorted_samples: Array, q: float) -> float:
if sorted_samples.is_empty():
return 0.0
var idx := int(ceil(q * sorted_samples.size())) - 1
return float(sorted_samples[clampi(idx, 0, sorted_samples.size() - 1)])
static func _mean(samples: Array) -> float:
if samples.is_empty():
return 0.0
var sum := 0.0
for v in samples:
sum += float(v)
return sum / samples.size()
static func _median(samples: Array) -> float:
var s := samples.duplicate()
s.sort()
return _pct(s, 0.50)
func _terrain_int(method: String) -> int:
if terrain != null and terrain.has_method(method):
return int(terrain.call(method))
return 0