Wenn das Spiel das iPhone aufheizt: Godot mobil optimieren
10 Min. Lesezeit

Kalecik ist ein ruhiges Burg- und Dorfbauspiel, das ich mit Godot 4 entwickelt habe: Sie ziehen mit dem Finger Linien, und daraus entstehen Steinmauern, Häuser und Brücken. Beim Testen der ersten Builds auf meinem iPhone 15 Pro Max fiel mir etwas auf: Beim Spielen wurde das Telefon in der Hand spürbar warm. Auf dem Bildschirm waren eine Wiese, ein paar Schafe und Steinmauern zu sehen, kein Actionspiel. Für so viel Wärme musste es einen Grund geben.
Dieser Beitrag beschreibt diesen Grund und die Korrekturen, die darauf folgten: ein Display mit 120 Hz, ein Framerate-Regler, Schatten-Proxys, in Chunks aufgeteiltes Gras, Mesh-Aufbau auf Worker-Threads und eine Idee, die wir ausprobiert und wieder verworfen haben. Die größere Geschichte, also wie das Spiel mit fünf parallelen Claude-Code-Sitzungen entstanden ist, steht im Hauptbeitrag der Serie.
Zwei Dinge vorab. Erstens: Den Code in diesem Projekt haben Claude-Code-Agenten geschrieben. Ich habe die Erwärmung bemerkt und gemeldet, eine eigene Sitzung für Performance geöffnet, die Entscheidungen getroffen und jeden Build auf dem Telefon getestet. Zweitens: Alle Millisekundenwerte in diesem Beitrag wurden auf einem M1-Mac gemessen. Temperatur- oder fps-Werte vom Telefon habe ich nicht, deshalb werden Sie hier keinen Satz wie „das Telefon wurde um X Grad kühler“ finden.
Erst messen, auch wenn Metal null meldet
Die Performance-Sitzung begann mit Messungen, nicht mit Vermutungen. scripts/perf.gd bringt zwei Werkzeuge mit:
- Ein Protokoll auf dem Gerät. Debug-Builds schreiben jede Sekunde eine Zeile nach
user://perf.csv: fps, die aktuelle Framerate-Grenze, den längsten Frame, CPU- und GPU-Zeit, Draw Calls und Primitive. Unter iOS landet die Datei im Documents-Ordner der App und lässt sich mitxcrun devicectlauf den Mac kopieren. - Ein A/B-Vergleich. Mit
-- --benchgestartet, lässt das Spiel das geladene Dorf unverändert und schaltet Render-Einstellungen einzeln um (Glow aus, Schattenfilter 1 und 0, Schattenatlas 1024, nur ein Schatten-Split, Render-Skalierung 0,7, MSAA aus, kein Gras, keine Schatten). Für jeden Fall wird die durchschnittliche Frame-Zeit notiert.
Die erste Überraschung kam sofort: Godots Metal-Treiber meldete die GPU-Zeit des Viewports als 0. Mit dem Standardtreiber auf dem Mac war also nicht zu sehen, wie stark die GPU arbeitet. Der Agent wich auf Vulkan aus, auf macOS also MoltenVK:
# Metal meldet 0 als GPU-Zeit, daher wurde über Vulkan (MoltenVK) gemessen
godot --path . --rendering-driver vulkan --rendering-method mobile -- --benchEin Hinweis: Auf dem M1 schwankt dieselbe Messung von Lauf zu Lauf deutlich. Wir haben deshalb nur große Unterschiede ernst genommen, keine kleinen.
Die eigentliche Ursache: 120 fps
Der entscheidende Fund lag nicht in den Render-Einstellungen, sondern in den Export-Einstellungen. Godots iOS-Export schreibt CADisableMinimumFrameDurationOnPhone = true in die Info.plist. Dieser Schlüssel erlaubt einer iPhone-App, über 60 Hz zu gehen. Dazu kommt, dass Godots Einstellung display/window/ios/allow_high_refresh_rate standardmäßig true ist. Zusammen führte das dazu, dass das Spiel auf dem ProMotion-Display des iPhone 15 Pro Max 120 Bilder pro Sekunde zeichnete.
Für ein gemütliches Dorfspiel bedeutet das, eine meist unveränderte Szene doppelt so oft wie nötig neu zu zeichnen. Die Korrektur ist eine Zeile in project.godot:
[display]
window/ios/allow_high_refresh_rate=falseGodot liest diesen Wert nur beim Start; während des Spiels gibt es keinen Weg zurück zu 120. Das ist für uns in Ordnung, denn genau das wollen wir.
Die Lehre daraus: Wer auf einer Engine oder einer Vorlage aufbaut, sollte die Export-Standardwerte einmal bewusst durchsehen. Die Ursache der Wärme lag nicht im Spielcode, sondern in einem plist-Schlüssel und einem Standardwert, den niemand angeschaut hatte.
Ein Framerate-Regler: 60, 30, 10
Selbst 60 fps sind nicht immer nötig. Solange niemand den Bildschirm berührt und man nur das Dorf betrachtet, sehen laufende Schafe und wogendes Gras bei 30 fps genauso aus. perf.gd enthält deshalb einen kleinen Regler:
const FPS_ACTIVE := 60
const FPS_IDLE := 30
const FPS_AWAY := 10 # App im Hintergrund oder ohne Fokus
const IDLE_S := 1.5 # nach so vielen ruhigen Sekunden auf 30 senken
var _calm := 0.0
var _away := false
func _cap(fps: int) -> void:
if Engine.max_fps != fps:
Engine.max_fps = fps
## Liegt ein Finger auf, gleitet die Kamera, werden im Hintergrund Meshes gebaut?
func _busy() -> bool:
if not (game.touches as Dictionary).is_empty():
return true
if game.view != null and game.view.busy():
return true
return game.rig != null and game.rig.moving()
func _input(_ev: InputEvent) -> void:
_calm = 0.0
if not _away:
_cap(FPS_ACTIVE) # die erste Berührung bringt sofort die volle Rate zurück
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_APPLICATION_PAUSED:
_away = true
_cap(FPS_AWAY)
NOTIFICATION_APPLICATION_FOCUS_IN, NOTIFICATION_APPLICATION_RESUMED:
_away = false
_calm = 0.0
func _process(delta: float) -> void:
if _away:
return
_calm = 0.0 if _busy() else _calm + delta
_cap(FPS_ACTIVE if _calm < IDLE_S else FPS_IDLE)Entscheidend ist eine saubere Definition von „beschäftigt“. Berührungen allein reichen nicht: Gleitet die Kamera nach dem Loslassen weiter oder wird im Hintergrund noch eine große Mauer gebaut, braucht es weiterhin 60 fps, sonst ruckelt die Bewegung. In automatisierten Tests ist der Regler aus, weil Tests eine unbegrenzte Framerate brauchen.
Schatten: Detaillierte Steine werfen keine
Vor der Korrektur entfielen rund 32 % der GPU-Zeit auf Schatten und etwa 24 % auf Gras (M1, MoltenVK). Die Schatten waren so teuer, weil die Mauer-Meshes sehr detailliert sind: Hunderte Steine pro Meter, dazu Efeublätter. Beim Zeichnen der Schattenkarte der Sonne wurden all diese Steine ein zweites Mal gezeichnet, obwohl ein Schatten nur die Silhouette braucht.

Aus der Nähe ist jeder Stein zu sehen; den Schatten wirft ein schlichter Stellvertreter.
Die Lösung des Agenten waren Schatten-Proxys: Jeder Mauerabschnitt, jeder Turm und jedes Haus bekommt eine schlichte Begleitform (zwei Seiten und eine Oberseite, Zinnen als einfache Quader, Torbögen offen). Das detaillierte Mesh nimmt am Schattendurchgang gar nicht teil, den Schatten wirft die schlichte Form:
## Das sichtbare Mesh wirft keinen Schatten; das übernimmt ein reiner Silhouetten-Stellvertreter.
func _pair(mesh: Mesh, shadow: Mesh) -> Node3D:
var n := Node3D.new()
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
n.add_child(mi)
var sh := MeshInstance3D.new()
sh.mesh = shadow
sh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
n.add_child(sh)
return nDas Ergebnis: Die Schatten-Primitive sanken von 164.000 auf 31.000, die GPU-Zeit in der Fernansicht von 4,57 ms auf 3,81 ms. Wie der Kommentar im Code sagt: Für einen Schatten zählt nur die Silhouette, nicht jede Unebenheit jedes Steins.
Im selben Durchgang änderten sich zwei weitere Einstellungen. Glow ist jetzt nur an, wenn die Fenster leuchten, also im Wesentlichen abends und nachts; tagsüber läuft er nicht mehr umsonst. Nach dem A/B-Vergleich wurde die Qualität des Schattenfilters von 2 auf 1 gesenkt, während MSAA 2x und die Render-Skalierung 0,8 unverändert blieben, damit das Bild nicht leidet.
Auch der Himmel entpuppte sich als versteckter Kostenfaktor. Jeder Schreibzugriff auf eine Farbe des ProceduralSkyMaterial rendert die Radiance-Map neu, die die Szene beleuchtet, selbst wenn derselbe Wert geschrieben wird. Bei Sonnenuntergängen und zu Beginn eines Regens geschah das in jedem Frame. Bei der Standardgröße von 256 Pixeln kostete das +1,4 ms. Die Größe liegt jetzt bei 64 Pixeln (+0,16 ms, gleiches Bild), und der Himmel wird nur geschrieben, wenn sich eine Farbe wirklich geändert hat, bei Übergängen höchstens alle 0,2 Sekunden:
sky.radiance_size = Sky.RADIANCE_SIZE_64 # Standard ist 256
# jeder Schreibzugriff rendert die Radiance-Map neu: ohne Änderung nichts schreiben
if sky.sky_top_color != p.top or sky.sky_horizon_color != p.hor:
sky.sky_top_color = p.top
sky.sky_horizon_color = p.horGras: MultiMesh-Chunks und ein LOD von 7 auf 4 Halme
Auf der Wiese stehen 39.000 Grasbüschel. Alle in einem einzigen MultiMesh würde bedeuten, jedes davon zu zeichnen, auch wenn die Kamera nur in eine Ecke blickt. grass.gd teilt die Fläche stattdessen in Quadrate von 12,5 Metern, jedes ein eigenes MultiMesh; die Kamera überspringt Quadrate, die sie nicht sieht. Ändert sich das Gelände, werden nur wenige kleine Quadrate neu hochgeladen.

Das Gras der Wiese besteht aus 12,5-Meter-MultiMesh-Abschnitten.
Jedes Quadrat hat zwei MultiMeshes über dieselben Büschel: nah Büschel mit 7 Halmen, ab 55 Metern 4 breitere Halme. Ein Büschel von wenigen Pixeln sieht in beiden Fällen gleich aus, das ferne Mesh hat aber rund 40 % weniger Dreiecke. Den Wechsel übernehmen Godots Sichtbarkeitsbereiche:
const CHUNK := 12.5 # Meter
const LOD_M := 55.0 # ab hier das Büschel mit 4 Halmen
if li == 0: # nahes Mesh: 7 Halme
mmi.visibility_range_end = LOD_M
mmi.visibility_range_end_margin = 5.0
else: # fernes Mesh: 4 breite Halme
mmi.visibility_range_begin = LOD_M
mmi.visibility_range_begin_margin = 5.0
mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFFAuch die Aktualisierung nach jedem Strich war schwer. Nach jedem Strich wurde jedes Büschel neu auf den Boden gesetzt, was in einem großen Dorf 26 ms dauerte. Jetzt werden nur die Büschel im geänderten Rechteck der Höhenkarte neu gesetzt: 0,46 ms. Eine ähnliche Aufgabe für Teiche sank von 14 ms auf 0,12 ms, und die gesamte Aktualisierung nach einem Strich fiel von 64 ms auf 18,6 ms.
Das Gras lieferte außerdem einen lehrreichen Fehler. In GDScript sind Packed-Arrays wie PackedFloat32Array Werttypen. Wer eines aus einem Dictionary holt, etwas anhängt und es nicht zurückschreibt, verliert die Änderung:
var cell: PackedFloat32Array = cells.get(key, PackedFloat32Array())
cell.append_array([...])
cells[key] = cell # ohne diese Zeile landet das Anhängen in einer KopieDieser Fehler ließ zeitweise 46 Gras-Chunks leer. Bemerkt wurde er auf eine interessante Weise: Die Store-Screenshots zeigten plötzlich kahlen Boden.
Mauern: 4-Meter-Chunks und Worker-Threads
Wie die Mauern erzeugt werden, beschreibe ich im Beitrag über prozedurale Mauern, Tore und Brücken; hier geht es nur um die Kosten. In der ersten Version wurde bei jeder Fingerbewegung die ganze Mauer neu gebaut. Bei einer 50-Meter-Mauer dauerte das 80 ms, ein sichtbares Ruckeln beim Zeichnen. Die Lösung: Mauern in 4-Meter-Chunks zerlegen und jeden zwischenspeichern. Beim Zeichnen werden nur die letzten 4 Meter (das Ende) neu gebaut. Ergebnis: Eine Live-Aktualisierung der Mauer dauert im Schnitt 8 ms, schlimmstenfalls 11 ms. Ein Turm braucht etwa 7,5 ms, das Abschließen eines Strichs etwa 27 ms.
Große Änderungen, also ab vier zu bauenden Chunks, gehen an den WorkerThreadPool, während das alte Bild stehen bleibt. Hier lauerte eine GDScript-typische Falle: Teilten sich Threads dasselbe RefCounted-Objekt, warteten sie ständig am Referenzzähler aufeinander. Die Lösung ist, jedem Job eine eigene Kopie des Pfads zu geben:
if jobs.size() >= ASYNC_MIN: # ASYNC_MIN = 4; kleine Jobs bleiben im Hauptthread
for j: Job in jobs:
if j.path != null:
j.path = j.path.copy() as WallPath # eine Kopie pro Job: kein geteilter Zähler
var id := WorkerThreadPool.add_group_task(_run_job.bind(jobs), jobs.size(), -1, true, "castle meshes")Zum Messen schrieb der Agent tools/bench_threads.gd: Es baut eine geschlossene 56-Meter-Mauer als 14 Jobs, zuerst nacheinander, dann im Pool. Nacheinander lagen die Werte zwischen 354 und 2.158 ms, im Pool zwischen 141 und 164 ms. Eine so große Spanne zeigt, dass die Messung sehr verrauscht ist; einen genauen Beschleunigungsfaktor nenne ich deshalb nicht. Sie zeigt aber, dass der Pool deutlich schneller und wesentlich gleichmäßiger ist.
Ausprobiert und verworfen: automatische Mesh-LODs
Nicht jede Optimierung hat sich gelohnt. Der Agent probierte Godots automatische LOD-Erzeugung (ImporterMesh.generate_lods) für Häuser, Türme und Mauer-Chunks: Nach dem Aufbau vereinfachte ein Worker mit niedriger Priorität das Mesh, und Godot wählte die Stufe nach dem Fehler auf dem Bildschirm. Auf dem Papier eine gute Idee.
Die Messung zeigte, dass die LODs nie griffen. Die Übergänge begannen etwa 280 Meter von der Kamera entfernt, die Kamera des Spiels entfernt sich aber höchstens 75 Meter. Das System erzeugte nur zusätzliche Arbeit und entlastete keinen einzigen Frame, also wurde es entfernt. Eine gute Erinnerung daran, eine Optimierung erst zu akzeptieren, wenn gemessen ist, dass sie tatsächlich etwas bewirkt.
Eine kurze Checkliste
Wenn Ihr eigenes mobiles Godot-Projekt heiß läuft, prüfen Sie der Reihe nach:
- Mit welcher Framerate läuft das Spiel wirklich? Auf einem iPhone mit ProMotion lohnt ein Blick auf
allow_high_refresh_rate. - Gibt es einen Regler, der die Framerate senkt, solange die Szene ruhig ist?
- Sind die Meshes im Schattendurchgang detaillierter als nötig? Für eine Silhouette reicht ein schlichter Stellvertreter.
- Sind Tausende Instanzen (Gras, Bäume) in Chunks aufgeteilt, und wechseln sie in der Ferne auf ein leichteres Mesh?
- Gibt es eine Stelle, an der Sie jeden Frame „denselben Wert“ schreiben, die Engine aber etwas Teures neu berechnet, etwa beim Himmel?
- Greift die hinzugefügte Optimierung in der Messung tatsächlich?
Das Spiel befindet sich noch in Entwicklung: Die App-Store-Version erscheint in Kürze, die Android-Version ist in Vorbereitung. Den aktuellen Stand und die Funktionen finden Sie auf der Kalecik-Seite. Wie die Veröffentlichungsseite automatisiert wurde, erkläre ich im Beitrag zur App Store Connect API.
Häufige Fragen
Warum wird ein Godot-Spiel auf dem iPhone heiß?
Der häufigste Grund ist eine höhere Framerate als nötig. Auf iPhones mit ProMotion erlaubt Godots iOS-Export mehr als 60 Hz, und allow_high_refresh_rate ist standardmäßig aktiv, sodass ein Spiel mit 120 fps laufen kann. Kommen GPU-intensive Aufgaben wie detaillierte Schatten und Gras mit Tausenden Instanzen hinzu, erwärmt sich das Telefon schnell.
Sieht das Spiel ohne 120 Hz schlechter aus?
In einem schnellen, reaktionsbetonten Spiel kann man den Unterschied spüren. In einem ruhigen Aufbauspiel wie Kalecik wirken 60 fps flüssig, und der Wechsel auf 30 fps, solange niemand den Bildschirm berührt, fällt nicht auf. Die Entscheidung hängt vom Genre ab, der Standardwert sollte aber in jedem Fall bewusst gewählt sein.
Warum zeigt Godot mit Metal eine GPU-Zeit von 0?
In der für dieses Projekt verwendeten Godot-Version meldete der Metal-Treiber die GPU-Zeit des Viewports als 0. Um auf dem Mac echte GPU-Zeiten zu sehen, lief das Spiel über MoltenVK mit --rendering-driver vulkan --rendering-method mobile. Die M1-Werte entsprechen nicht exakt denen des Telefons, reichen aber aus, um teure Einstellungen zu erkennen.
Gelten die Millisekundenwerte auch auf dem Telefon?
Nein, sie wurden alle auf einem M1-Mac gemessen. Für das Telefon schreibt das Spiel jede Sekunde eine Protokollzeile, und ein Skript für Energie-Traces mit Instruments wurde vorbereitet, aber zum Zeitpunkt dieses Beitrags lag keine auf dem Telefon aufgezeichnete Messung vor. Die Mac-Werte zeigen, welche Änderung viel bewirkt hat, nicht den genauen Wert auf dem Telefon.
Verwandte Artikel
Prozedurale Steinmauern, Tore und Brücken in Godot
Wie in Kalecik aus einem Fingerstrich eine Steinmauer, ein Torbogen, eine Brücke oder ein Haus wird: Glättung, geseedetes Mauerwerk und Mesh-Chunks.
Ein Mobile-Spiel mit KI-Agenten entwickeln: Kalecik
Vom leeren Ordner aufs iPhone: wie ich Kalecik mit fünf parallelen Claude-Code-Sitzungen gebaut habe, was ich entschied und wo es hakte.
App Store Connect API: Veröffentlichung automatisieren
So läuft die App-Store-Vorbereitung von Kalecik über die API: JWT, Preisplan, Store-Texte in 50 Sprachen, Screenshots aus der Engine, Upload per Befehl.