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.
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:
segment, engine_frame, start_usec, end_usec, frame_ms: a monotonic
wall-clock interval. engine_frame identifies the opening process boundary.partial: true for intervals shortened by opening or closing F3. Keep these
for events, but exclude them from FPS, percentiles and budget-overrun rates.budget_ms, budget_excess_ms, markers: a complete frame strictly above
25 ms has frame_budget_exceeded. Exactly 25 ms is within budget.still_count: the number of automatic cached still requests in the interval.
Positive values add still_generated to markers. This is the call that
prepares/submits the high-quality still when movement settles, not a GPU
completion timestamp. Screenshot/export code never emits this marker.cpu: operation names mapped to ms, percent_frame and percent_budget.
Repeated calls are summed within the interval. An absent operation did not
run. Percentages use actual wall time, rather than a fixed target or smoothed
FPS. A zero-duration boundary interval has null percentages.render_passes: viewport timing observations, with viewport ID, dimensions,
observation timestamp, draw_requested, and cpu/gpu timing objects.
Each timing object has ms and percent_observed_frame; null means no usable
timing (unsupported renderer, headless, warm-up, or a zero timer result).recorder_cpu_ms: CPU wall time spent in capture callbacks, viewport
registration and viewport timing queries. This is already included in
frame_ms, and is not a correction factor for unprofiled performance.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.
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:
Fly movement + collision: camera movement and collision (src/camera/fly_camera.gd).CPU centre ray: centre-distance march (src/camera/center_ray.gd).CPU scale rays: scale-probe batch (src/camera/scale_probe.gd).Bindings + scene updates and Console readouts: main scene update work (src/main.gd).Camera distance + shape frames, Shader parameter upload, Shader kind swap,
Noise shader rebuild: fractal view work (src/fractal/fractal_view.gd).Still preparation + submission: the full draw_still_frame call.Still tile submission: CPU setup for one progressive batch (not GPU duration).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.
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.
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"])