spaceship/tests/_profile_surface_shader.gd

106 lines
4 KiB
GDScript3
Raw Permalink Normal View History

Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68) The crater shader ran on every pixel of a fullscreen rect every frame and was by far the most expensive thing in the surface scene. Nothing else came close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid per-frame queue_redraw() for transform-only changes, and LunarBackground.gd is a handful of scalar ops. Three reductions to the field itself: - 5x5 -> 3x3 neighbour scan. That width was never the geometric answer; it was insurance against craters seen clipping at cell boundaries on-device, i.e. a crater reaching past its nominal radius because a hash returned a value outside [0,1] and extrapolated mix(). Clamping the crater CENTRE into its own cell, alongside the radius clamp already there, bounds the reach whatever the hash returns - so a crater two cells out is >= 1.0 cells away, a normalized distance of >= 2.63, past every threshold the shading reacts to (the last saturates at 1.15). The outer ring provably cannot change a pixel. This removes the clipping bug's cause instead of hiding it behind 16 extra samples. - One hash42 per cell instead of hash21 twice plus hash22, decorrelating on output lanes rather than input offsets. - Squared distances in the loop, square-rooted twice at the end rather than once per sample. Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction. Then the larger change: the field is a fixed function of world position, so every frame recomputed an identical answer. It is now made periodic (the cell index is wrapped before hashing, which makes a Worley field tile exactly rather than merely blend), baked once at load into one tile, and scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch per pixel. Two honest costs: the field repeats every 7040 world units, and a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is forgiving of both, having no landmarks and being smooth gradients already. The bake waits a frame and asks the material whether it actually samples a field_texture, so a gas giant - which swaps in its own background shader (#51) - does not pay for a 2048-square tile it will never read. Headless runs skip it entirely, having nothing to rasterize. tests/test_lunar_field.gd guards the reasoning, since a fragment shader cannot be asserted on directly: the premises are still in the source, the worst-case ring-2 geometry stays clear of every threshold independent of the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points, the field repeats exactly in both axes, and the miss sentinel still cannot overflow mediump into +Inf and then NaN. test_surface_profiles.gd asserted "a rocky landing leaves the background alone" by naming lunar_surface.gdshader; it now reads the shader from the scene, which is the property it actually cares about. PlanetSurface.tscn's edit also carries the planet-name relocation described in the HUD commit - same scene file, so the two cannot be split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
extends SceneTree
# Not a test - a stopwatch for a fullscreen background shader, in the same
# spirit as _profile_startup.gd but aimed at the FRAGMENT side instead of
# scene setup. Forgejo #68 ("the moon game is a bit slow").
#
# Renders nothing but one fullscreen ColorRect carrying the given shader, so
# the frame time is essentially pure per-pixel shader cost with no gameplay,
# UI or draw calls mixed in. Run it via tests/profile_shader.sh, which
# supplies Xvfb + Mesa softpipe exactly like screenshot.sh does.
#
# Softpipe is a pure-C rasterizer, not the phone's GPU, so the ABSOLUTE
# numbers mean nothing. What it measures well is RELATIVE per-fragment ALU
# cost between two versions of the same shader: softpipe executes the
# fragment program per pixel on the CPU, so halving the arithmetic shows up
# as roughly half the frame time.
#
# TWO THINGS WILL MISLEAD YOU IF YOU READ THE RAW NUMBER.
#
# 1. There is a FLOOR. A shader that does nothing but assign a constant
# colour still costs ~657 ms/frame at 720x1280 here, which is softpipe's
# own per-pixel overhead. Subtract it before comparing, or a shader that
# got 20x cheaper will look like it only got 2x cheaper.
#
# 2. It cannot price a TEXTURE FETCH against arithmetic. Softpipe does
# filtering and mip selection in CPU code, so a fetch measures as
# expensive here; real GPUs - mobile ones especially - run it on
# dedicated hardware where it is close to free. Trading arithmetic for a
# lookup is exactly the kind of change this harness will report backwards.
# Use it to compare arithmetic against arithmetic, and nothing else.
#
# Args (after `--`): <shader_res_path> [timed_frames] [warmup_frames]
var shader_path: String
var timed_frames: int = 30
var warmup_frames: int = 10
var frames: int = 0
var rect: ColorRect
var start_usec: int = 0
func _initialize() -> void:
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.is_empty():
printerr("usage: -- <shader_res_path> [timed_frames] [warmup_frames]")
quit(1)
return
shader_path = args[0]
if args.size() >= 2:
timed_frames = int(args[1])
if args.size() >= 3:
warmup_frames = int(args[2])
var shader: Shader = load(shader_path)
if shader == null:
printerr("could not load shader: %s" % shader_path)
quit(1)
return
var material := ShaderMaterial.new()
material.shader = shader
var layer := CanvasLayer.new()
layer.layer = -10
root.add_child(layer)
rect = ColorRect.new()
rect.material = material
rect.anchor_right = 1.0
rect.anchor_bottom = 1.0
rect.size = root.get_visible_rect().size
layer.add_child(rect)
func _process(_delta: float) -> bool:
# Move the camera every frame. A static camera would let a driver skip
# redundant uniform uploads and would only ever exercise one crater
# layout; drifting keeps the measurement honest and representative of
# actually flying around.
var t: float = float(frames) * 0.05
var p: Vector2 = Vector2(cos(t), sin(t)) * 40.0
var cell := Vector2(floor(p.x), floor(p.y))
rect.material.set_shader_parameter("camera_cell", cell)
rect.material.set_shader_parameter("camera_frac", p - cell)
rect.material.set_shader_parameter("camera_zoom", 1.0)
rect.material.set_shader_parameter("viewport_size", root.get_visible_rect().size)
frames += 1
# Shaders compile on first use and the first frames carry that cost, so
# start the clock only once the pipeline is warm.
if frames == warmup_frames:
start_usec = Time.get_ticks_usec()
return false
if frames < warmup_frames + timed_frames:
return false
var span_usec: int = Time.get_ticks_usec() - start_usec
var per_frame_ms: float = float(span_usec) / 1000.0 / float(timed_frames)
var px: Vector2 = root.get_visible_rect().size
print("SHADER_PROFILE: %s" % shader_path)
print(" viewport : %dx%d (%d px)" % [px.x, px.y, int(px.x * px.y)])
print(" frames timed : %d (after %d warmup)" % [timed_frames, warmup_frames])
print(" per frame : %.1f ms" % per_frame_ms)
print(" per megapixel : %.1f ms" % (per_frame_ms / (px.x * px.y / 1000000.0)))
quit(0)
return true