Physarum Native — Adaptive Sim Format Design Spec¶
Status: Approved · 2026-06-04 · author: Johannes (brainstormed with Claude) Builds on: the adaptive compact UI + the render fit/cover toggle (
2026-06-03-adaptive-compact-ui-design.md, the fit/cover work). A square sim is a compromise on every non-square screen (fit → surround, cover → crop). This makes the simulation field itself adopt the screen's format, so it fills natively.
1. Kontext & Ziel¶
Das Sim-Feld ist heute fix quadratisch (field = 720, als width == height an SimEngine/
Renderer übergeben). Auf einem hohen iPhone-Portrait oder breiten Landscape/TV-Screen ist das
ein Kompromiss. Gute Nachricht: die Engine ist bereits non-square-fähig — SimEngine/Renderer
nehmen getrennte width/height, und der Sim-Shader (agentStep.wgsl) nutzt getrennte
fieldWidth/fieldHeight fürs Wrapping. Dieser Increment lässt das Feld das Format des Player-
Views annehmen (Auto), mit optionalem manuellem Format-Override — ohne Engine-/Shader-Änderung.
2. Festgezurrte Entscheidungen (Brainstorming 2026-06-04)¶
| Achse | Entscheidung |
|---|---|
| Modell | Auto + manueller Override. Default Auto (Feld = Screen-Format → füllt nativ); optional ein fixes Format festnageln. |
| Auto-Feldgröße | Kurze Kante fix bei base (720); lange Kante proportional zum View-Seitenverhältnis, gedeckelt (maxLong, z.B. 1600) zur GPU-Begrenzung. |
| Re-Create | Bei merklicher Seitenverhältnis-Änderung (Rotation/Resize) baut der RenderCoordinator Sim+Renderer im neuen Format neu — debounced (~300 ms). Kurzer Re-Seed (wie Preset-Wechsel). |
| Dichte | agentCount wird mit der Feldfläche skaliert (Preset-Wert gilt fürs 512²-Baseline), gedeckelt an den Tier-Max — Presets sehen überall gleich dicht aus. |
| Override | Fixes Format (Square · 9:16 · 16:9 · …) im Editor; dann nutzt das Feld dieses Format und der bestehende Fit/Cover-Toggle regelt die Darstellung auf einem anders geformten Screen. Auto = Override aus. Gemerkt via @AppStorage. |
| Fit/Cover | Bleibt — relevant nur bei Override (oder transient während Resize); im Auto-Modus ein No-op (Feld == Screen). |
| Engine | Keine Shader-/Engine-Änderung. Nur App-Schicht. |
3. Kern: Feld-Dimensionierung (reine, testbare Funktion)¶
struct FieldSize: Equatable { let width: Int; let height: Int }
/// Maps a view aspect to integer field dimensions: short edge fixed at `base`,
/// long edge proportional, capped at `maxLong`. A square view → base×base.
func fieldSize(viewWidth: Double, viewHeight: Double,
base: Int = 720, maxLong: Int = 1600) -> FieldSize
Regel: aspect = max(w,h)/min(w,h); long = min(round(base*aspect), maxLong). Portrait
(h>w) → FieldSize(base, long); Landscape → FieldSize(long, base); quadratisch → base×base.
Degenerate Eingaben (0/negativ) → base×base.
Dichte-Skalierung (reine Funktion):
/// Scale a preset's agentCount (tuned at the 512² baseline) to the actual field
/// area, capped at `tierMax`, so density is preserved.
func densityScaledAgentCount(_ presetCount: Int, fieldWidth: Int, fieldHeight: Int,
baseline: Int = 512, tierMax: Int = 500_000) -> Int
scaled = round(presetCount * (w*h) / (baseline*baseline)), clamped [100, tierMax].
4. Format-State¶
enum SimFormat: Equatable { // persisted via @AppStorage (raw string)
case auto // field = view aspect
case fixed(FixedAspect) // square / portrait916 / landscape169 / …
}
enum FixedAspect: String, CaseIterable { case square, portrait, landscape /* +others */
var ratio: Double { … } // long/short
}
cover toggle lives (RootView @AppStorage + threaded to MetalView/RenderCoordinator).
- In .auto, the target field comes from the view size via fieldSize(...).
- In .fixed, the target field is base × round(base*ratio) oriented per the aspect; the existing fit/cover viewport displays it on the screen.
5. RenderCoordinator — re-create on resize¶
Today renderer + sim are created once in init at field×field. Changes:
- renderer becomes var; add private var currentField: FieldSize.
- A method setField(_ target: FieldSize) that, if target != currentField, debounce-recreates
(reuse the existing respawnWork DispatchWorkItem pattern, ~250–300 ms) a new Renderer +
SimEngine at the new dimensions (applying densityScaledAgentCount), then resetFeedback().
- The target field is computed from the view: MetalView.sync (or mtkView(_:drawableSizeWillChange:))
reads the drawable/view size + the SimFormat, computes the target FieldSize, and calls setField.
- All on the main thread (preserves the §11 single-threaded race-free model).
The agentCount scaling means
SimEngineis created with a scaled count; the preset's storedagentCountis unchanged (the editor still shows/edits the preset value at baseline semantics). The scaling is applied only when constructing the engine for the actual field.
6. Editor UI — format control¶
- A Format control in the editor (e.g. in a "Display"/Global area): a segmented/menu picker
Auto · Square · Portrait · Landscape(Auto =.auto, others =.fixed). - Selecting it updates the persisted
SimFormat→ triggers a field re-create. - The fit/cover toggle (topbar
aspectratioicon) stays; it only visibly matters when a fixed format differs from the screen.
7. Editor dismissal via canvas tap (idea #2)¶
Independent small UX fix in the same round: tapping the player/canvas dismisses the editor
when it is open (clearer than hunting the toggle, especially in landscape). A tap gesture on the
player layer sets showEditor = false (sheet) / collapses the inspector. Does not fire when the
editor is already closed (so it doesn't swallow other canvas interactions). Verify it doesn't
conflict with the sheet's presentationBackgroundInteraction.
8. Testing & Verifikation¶
- Unit-testable (app test target):
fieldSize: square→base²; portrait 9:19.5→base×capped-long; landscape→long×base; cap respected; degenerate→base².densityScaledAgentCount: baseline area→presetCount; 2× area→~2× (capped at tierMax); floor 100.SimFormatround-trips through its@AppStorageraw representation.- Build + visuell (Johannes): iPhone portrait + landscape (fills natively, no surround/crop in Auto), rotation re-seeds cleanly, Mac window resize re-seeds (debounced, no thrash), manual override + fit/cover, editor dismiss by tapping the canvas, performance of the larger portrait field on device.
- Gate: app suite green; macOS + iOS-sim BUILD SUCCEEDED.
9. Scope (in) / Out-of-scope¶
In: fieldSize + densityScaledAgentCount (pure, tested) · SimFormat state (auto + fixed, persisted) ·
RenderCoordinator debounced field re-create on resize/rotation/format-change · editor Format picker ·
canvas-tap-dismisses-editor · fit/cover kept for the override case · unit tests.
Out-of-scope (später): capture/export at a chosen format · per-preset stored format · animated format transitions (re-seed is instant) · tvOS · audio · CloudKit. No engine/shader changes.
10. Offene Risiken / im Plan zu klären¶
- Resize thrash on macOS: debounce must coalesce window-drag resizes; only re-create when the
computed
FieldSizeactually changes (integer dims), not per pixel. - Performance: a tall portrait field (720×1600 ≈ 1.15 M cells, ~2.2× of 512² baseline) plus
density-scaled agentCount roughly doubles GPU work — verify FPS on a real iPhone; if needed lower
baseormaxLong, or relax the density scaling. - Re-seed flash frequency: rotation/resize re-seeds lose the accumulated trail/feedback; acceptable (same as preset switch) but confirm it isn't jarring.
- Preset look on non-square: density is preserved via agentCount scaling, but sensorDistance/ trail scale is in absolute pixels — relative structure may shift slightly; verify a few presets.
densityScaledAgentCountvs the debounced respawn: the existing respawn path keys on agentCount/spawn/share changes; the field re-create is a separate, heavier path — keep them from double-firing on the same change.