on the moon game is a bit slow, there's likely optimisations issues #68

Closed
opened 2026-08-16 10:22:54 +00:00 by metaheavens · 2 comments
Owner
No description provided.
Author
Owner

Investigated, not fixed - leaving this open.

Most probable cause identified via code review: lunar_surface.gdshader samples 25 points per pixel (a 5x5 neighbor scan for its Worley/cellular crater field). That's not accidental - the shader's own comments show it was deliberately widened from an earlier 3x3 scan to fix an on-device visual clipping bug in the crater edges, so simply shrinking the sample count back down would very likely reintroduce that older visual bug.

LunarBackground.gd (the CPU side) was also checked and is cheap - a handful of scalar ops per frame, not the bottleneck.

I don't have a way to profile GPU frame time on-device from here (no on-device GPU profiler access, and this is a rendering/perf regression that can't be judged from a headless test or a single static screenshot - it needs to be seen and measured running on the actual hardware). Any change to the shader's sample pattern risks trading this performance issue for the correctness issue it was already tuned to avoid, without a way to verify the tradeoff either way. Left open pending someone with device profiling access, or a specific target frame budget to hit.

Investigated, not fixed - leaving this open. Most probable cause identified via code review: `lunar_surface.gdshader` samples 25 points per pixel (a 5x5 neighbor scan for its Worley/cellular crater field). That's not accidental - the shader's own comments show it was deliberately widened from an earlier 3x3 scan to fix an on-device visual clipping bug in the crater edges, so simply shrinking the sample count back down would very likely reintroduce that older visual bug. `LunarBackground.gd` (the CPU side) was also checked and is cheap - a handful of scalar ops per frame, not the bottleneck. I don't have a way to profile GPU frame time on-device from here (no on-device GPU profiler access, and this is a rendering/perf regression that can't be judged from a headless test or a single static screenshot - it needs to be seen and measured running on the actual hardware). Any change to the shader's sample pattern risks trading this performance issue for the correctness issue it was already tuned to avoid, without a way to verify the tradeoff either way. Left open pending someone with device profiling access, or a specific target frame budget to hit.
Author
Owner

Fixed in 657ca9d.

What was slow

lunar_surface.gdshader runs on every pixel of a fullscreen rect, so it was by far the most expensive thing in the surface scene. Nothing else came close: RockField keeps only 14 rocks alive, Rock/Ship/Enemy already avoid per-frame queue_redraw() for transform-only changes, and LunarBackground.gd does a handful of scalar ops. Three things in the fragment shader carried the cost: a 5x5 (25-sample) neighbour scan, three hash evaluations per cell, and a sqrt per sample via length().

Part 1 - make the field cheap

  1. 5x5 -> 3x3. 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 present, bounds the reach whatever the hash returns. A crater two cells out is then >= 1.0 cells away - a normalized distance of >= 2.63 - and every shading threshold has saturated by 1.15, so the outer ring provably cannot change a pixel. This removes the clipping bug's cause rather than hiding it behind 16 extra samples.
  2. One hash42 per cell instead of hash21 twice plus hash22, decorrelating on output lanes rather than input offsets.
  3. Squared distances in the loop, square-rooted twice at the end instead of 25 times.

Part 2 - stop recomputing it every frame

The field is a fixed function of world position, so every frame was recomputing 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 a single tile, and scrolled with repeat wrapping by the new lunar_tiled.gdshader - one texture fetch per pixel, on a canvas that stays infinite.

Two costs, stated plainly: the field repeats every 7040 world units, and a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is unusually forgiving of both - no landmarks to recognise, and smooth gradients already. Both knobs are one line each in LunarBackground.gd (TILE_CELLS, BAKE_PIXELS_PER_CELL) if the ~17 MB tile or the repeat distance want trading against each other.

A gas giant swaps in its own background shader (#51), so the bake waits a frame and asks the material whether it actually samples a field_texture before committing - no 2048-square tile is rendered for a shader that will never read it. Headless runs skip it entirely.

Measured

New tests/profile_shader.sh + tests/_profile_surface_shader.gd time a fullscreen rect carrying a given shader. Two caveats are baked into the tool's own documentation, because the raw number misleads twice over:

  • There is a floor: a constant-colour shader still costs ~657 ms/frame at 720x1280 here. Subtract it, or a 23x win reads as 9.5x.
  • It cannot price a texture fetch against arithmetic. Softpipe filters in CPU code; real GPUs use dedicated hardware.

Shader work above the floor, same viewport, back to back:

version per frame above floor
before (5x5, 3 hashes/cell, sqrt per sample) 10139 ms 9482 ms
after (3x3, 1 hash/cell, sqrt per pixel) 1062 ms 405 ms - 23x less
baked + tiled (1 texture fetch) 1130 ms 473 ms

So part 1 is measured and decisive. Part 2 is not measurable on this hardware - it reads as slightly worse under a CPU rasterizer, which is exactly backwards for the target device. Its case is architectural: one mipmapped fetch against 9 Worley iterations with 3 sqrts and 9 divides is strongly favourable on a mobile GPU. Confirmed as an improvement on the actual device by the reporter.

Test

tests/test_lunar_field.gd. A fragment shader cannot be asserted on directly from GDScript, so the test guards the reasoning that made the reduction safe - the part that would silently break:

  • the source still carries every premise the bound rests on (3x3 loop, both clamps, MAX_RADIUS below a cell, shading saturating by 1.15);
  • worst-case ring-2 geometry stays clear of every threshold independent of what the hash returns, so it holds on any driver;
  • a 3x3 and a 5x5 scan render identically over 4000 sampled points, with the hash ported to GDScript;
  • the field repeats exactly in both axes, so the tile is seamless rather than merely close;
  • the miss sentinel still cannot overflow mediump into +Inf and then NaN.

If anyone later loosens a clamp or raises MAX_RADIUS, the crater clipping returns looking like a GPU mystery. This makes it fail as an edit instead.

Full suite green at 94/94.

Fixed in 657ca9d. ## What was slow `lunar_surface.gdshader` runs on every pixel of a fullscreen rect, so it was by far the most expensive thing in the surface scene. Nothing else came close: `RockField` keeps only 14 rocks alive, `Rock`/`Ship`/`Enemy` already avoid per-frame `queue_redraw()` for transform-only changes, and `LunarBackground.gd` does a handful of scalar ops. Three things in the fragment shader carried the cost: a 5x5 (25-sample) neighbour scan, three hash evaluations per cell, and a `sqrt` per sample via `length()`. ## Part 1 - make the field cheap 1. **5x5 -> 3x3.** 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 present, bounds the reach whatever the hash returns. A crater two cells out is then >= 1.0 cells away - a normalized distance of >= 2.63 - and every shading threshold has saturated by 1.15, so the outer ring provably cannot change a pixel. This removes the clipping bug's *cause* rather than hiding it behind 16 extra samples. 2. **One `hash42` per cell** instead of `hash21` twice plus `hash22`, decorrelating on output lanes rather than input offsets. 3. **Squared distances in the loop**, square-rooted twice at the end instead of 25 times. ## Part 2 - stop recomputing it every frame The field is a fixed function of world position, so every frame was recomputing 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 a single tile, and scrolled with repeat wrapping by the new `lunar_tiled.gdshader` - one texture fetch per pixel, on a canvas that stays infinite. Two costs, stated plainly: the field **repeats** every 7040 world units, and a fixed-resolution tile **softens** when zoomed past 1:1. Lunar regolith is unusually forgiving of both - no landmarks to recognise, and smooth gradients already. Both knobs are one line each in `LunarBackground.gd` (`TILE_CELLS`, `BAKE_PIXELS_PER_CELL`) if the ~17 MB tile or the repeat distance want trading against each other. A gas giant swaps in its own background shader (#51), so the bake waits a frame and asks the material whether it actually samples a `field_texture` before committing - no 2048-square tile is rendered for a shader that will never read it. Headless runs skip it entirely. ## Measured New `tests/profile_shader.sh` + `tests/_profile_surface_shader.gd` time a fullscreen rect carrying a given shader. **Two caveats are baked into the tool's own documentation, because the raw number misleads twice over:** - There is a **floor**: a constant-colour shader still costs ~657 ms/frame at 720x1280 here. Subtract it, or a 23x win reads as 9.5x. - It **cannot price a texture fetch against arithmetic**. Softpipe filters in CPU code; real GPUs use dedicated hardware. Shader work above the floor, same viewport, back to back: | version | per frame | above floor | |---|---|---| | before (5x5, 3 hashes/cell, sqrt per sample) | 10139 ms | 9482 ms | | after (3x3, 1 hash/cell, sqrt per pixel) | 1062 ms | **405 ms - 23x less** | | baked + tiled (1 texture fetch) | 1130 ms | 473 ms | So **part 1 is measured and decisive. Part 2 is not measurable on this hardware** - it reads as slightly *worse* under a CPU rasterizer, which is exactly backwards for the target device. Its case is architectural: one mipmapped fetch against 9 Worley iterations with 3 sqrts and 9 divides is strongly favourable on a mobile GPU. Confirmed as an improvement on the actual device by the reporter. ## Test `tests/test_lunar_field.gd`. A fragment shader cannot be asserted on directly from GDScript, so the test guards the reasoning that made the reduction safe - the part that would silently break: - the source still carries every premise the bound rests on (3x3 loop, both clamps, `MAX_RADIUS` below a cell, shading saturating by 1.15); - worst-case ring-2 geometry stays clear of every threshold **independent of what the hash returns**, so it holds on any driver; - a 3x3 and a 5x5 scan render identically over 4000 sampled points, with the hash ported to GDScript; - the field repeats **exactly** in both axes, so the tile is seamless rather than merely close; - the miss sentinel still cannot overflow mediump into `+Inf` and then `NaN`. If anyone later loosens a clamp or raises `MAX_RADIUS`, the crater clipping returns looking like a GPU mystery. This makes it fail as an edit instead. Full suite green at 94/94.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
metaheavens/spaceship#68
No description provided.