Zum Inhalt

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
}
- Lives where the 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 SimEngine is created with a scaled count; the preset's stored agentCount is 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 aspectratio icon) 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.
  • SimFormat round-trips through its @AppStorage raw 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 FieldSize actually 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 base or maxLong, 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.
  • densityScaledAgentCount vs 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.