HelpImStuckInAFractal

F3 performance logs

Open F3 to capture. Close F3 or exit normally to serialize the capture. One JSONL file belongs to one game process; each open/close cycle is a segment in that file. Hidden/suppressed F3 does not sample. Nothing is written during an open segment, and a crash can lose it. There is no frame cap or ring-buffer overwrite: memory grows with capture duration. Exporting/freeing a long capture can pause the game after recording stops.

Files live in user://performance/. On this project’s default macOS setup:

~/Library/Application Support/Godot/app_userdata/Help I'm Stuck In A Fractal/performance/

The full path is printed on successful save. Names include UTC time, process ID and a monotonic timestamp. An open/write error reports the error and retains the records in memory for a later close to retry. Disk write failure recovery is best effort; logs are not intended to survive abnormal termination.

Reading a file

Every line is a JSON object. Start with type: "run", check schema_version (currently 1), and read its embedded semantics. The run header contains the Godot version, renderer, display driver, CPU/GPU identity and the 40 FPS / 25 ms diagnostic budget. This threshold does not change gameplay’s FPS cap or governor.

type: "segment" supplies a segment ID, opening scene, time scale, FPS cap and viewport definitions. Definitions contain instance ID, registration path/name and engine frame; paths may subsequently change through reparenting. Metadata is finalized at export, so it includes passes created during capture. IDs in render_passes refer to this segment’s definitions. Different game processes must never be joined by viewport ID or engine-frame number alone.

type: "frame" contains:

type: "capture_end" confirms a successful export batch and its frame count. Normally it ends one segment. If a save failed and was retried after another segment, the batch can include several segments. Normal capture start/stop boundaries and gaps are explicit; frames are never silently downsampled.

CPU attribution

Frame boundaries are SceneTree.process_frame signals, before process callbacks. The interval includes all work until the next signal: processing, render submission, waiting and any physics callbacks that occur within it. Physics is attributed by wall interval, not retroactively assigned to a process iteration. Engine delta and its smoothing/clamping are not used.

The instrumented operations are:

CPU scopes are inclusive. For example a scale probe or upload can be inside a scene update. Do not add these percentages to infer total CPU load, subtract their sum from 100%, or interpret the remainder as GPU time. Uninstrumented engine/UI/wait work remains part of wall time. CPU and GPU execute in parallel.

GPU attribution and limits

All live viewports are registered, including the root window and embedded windows. Pass names identify SubViewport (fractal ray march and material shading, using its owning path), LensOutput, PaintViewport, PhosphorOutput, PhosphorHistory, BeaconMask, BeaconHorizontalBlur, BeaconVerticalBlur, StillBlendCache and FastPresentationCache. Root/window observations include UI/presentation work. The viewport list also records scene-specific passes.

Godot’s viewport timers expose latest available CPU submission and GPU duration. They do not expose the source-frame ID. A result can arrive several frames late and can repeat. Equal readings do not prove a stale sample, and a changed reading does not identify its source frame. percent_observed_frame divides by the wall interval in which the result was read; it is deliberately not presented as exact GPU work for that frame. Do not compute exact GPU frame percentiles or one-to-one causal attribution from these observations.

draw_requested records the pre-draw update mode (or window visibility), not proof that a pass rendered. Visibility-dependent updates may be skipped. Idle viewports are still polled to preserve late results from a one-shot still. Their readings are not evidence of new work. Headless captures validate the CPU/logging path but provide no real GPU timings.

The project uses GL Compatibility. Marching, normals, shadows and material shading are within one fractal shader draw; viewport timing cannot separate them. No shader variants, extra rays, per-pixel counters, forced GPU waits or CPU texture readbacks were added for profiling. See Godot’s viewport timing API.

Overhead and comparison rules

The recording path allocates one linked record per interval, transfers its CPU samples, and reads timers for each live viewport into packed numeric storage (56 bytes per viewport observation, plus buffer overhead). It avoids a growing frame array’s occasional full-buffer reallocations. JSON construction, percentages, file I/O and record disposal happen only after capture stops. Scope bookkeeping scales with the number of calls; render-query work scales with live viewports. New passes/scene changes also incur registration work.

An exactly constant overhead cannot be guaranteed: allocations, OS scheduling, timer instrumentation and GPU query implementation can vary. The existing F3 overlay also updates every 0.25 seconds. recorder_cpu_ms excludes the overlay, per-scope clock/dictionary overhead and engine GPU-query overhead. It should not be subtracted from a frame time to claim a precise unprofiled result. Compare captures with the same profiling configuration, examine CPU scopes and GPU observations separately, and treat still-adjacent GPU timings as a window of possible attribution. RAM pressure from very long captures can itself affect performance; prefer several shorter F3 segments when possible.

Example streaming extraction of complete over-budget frames:

import json

with open("capture.jsonl") as source:
    for line in source:
        row = json.loads(line)
        if row["type"] == "frame" and "frame_budget_exceeded" in row["markers"]:
            print(row["segment"], row["engine_frame"], row["frame_ms"], row["markers"])