Zum Inhalt

Inc 12c — Phase 2: Crossfade-to-Black preset transition

Date: 2026-06-16 Status: Approved (design), ready for planning Scope: apple/PhysarumApp/Sources/{Ambient,TV} + Tests. Engine / Renderer / WGSL untouched.

Motivation

Phase 1 made preset switches seamless by holding the running sim's structure and morphing only the look — but that froze the structure: once settled, density / species-ratio never change across the whole cycle. Johannes' call: drop the in-place structural morph (which would need risky live engine surgery) and instead hide a full re-seed behind a fade to black. That adopts the entire target preset (agentCount, spawnPattern, populationShare, look) with zero engine changes and minimal risk.

Goal

Each preset switch (manual or auto-cycle): fade the visual to black → re-seed to the full target preset at black → fade back in. The complete target structure is adopted; the re-seed is invisible because the screen is black at that instant.

Non-Goals

  • In-place structural morphing (agentCount ramp / species migration) — explicitly rejected for risk.
  • Any Engine / SimEngine / Renderer / WGSL change.
  • Continuous param interpolation (the Phase-1 morph) — replaced by the fade.

Design

AmbientController — fade state machine (replaces the param morph)

Remove the Morph/pinnedMorph/adoptTransition machinery (its purpose — reseed-free in-place morph — is obsolete). Replace with a fade transition:

private struct Transition { let toID: String; var elapsed: Double; var reseeded: Bool }
private var transition: Transition?
let fadeDuration: Double   // each way, ~0.7s
let blackHold: Double      // warmup at full black before fade-in, ~0.3s
  • transitionDim: Double (0 = clear, 1 = black), exposed for the view:
  • elapsed < fadeDuration → elapsed / fadeDuration (fade out 0→1)
  • elapsed < fadeDuration + blackHold → 1 (hold black)
  • else → max(0, 1 - (elapsed - fadeDuration - blackHold) / fadeDuration) (fade in 1→0)
  • tick(dt): advance transition.elapsed; the first time it crosses fadeDuration (full black), call appState.select(id: toID) once (reseeded flag) — the full re-seed, hidden by black. Clear the transition at elapsed >= 2*fadeDuration + blackHold, reset dwellElapsed.
  • currentIndex resolves from transition?.toID ?? appState.selectedID (a skip mid-fade steps from the in-flight target).
  • apply: .next/.previous/.jumpTo → beginTransition(toIndex:) + setHUD(.preset(name)) only when a transition actually begins (truthful HUD, per the Phase-1 review). beginTransition guards target.id != selectedID || transition != nil (no-op when already there & idle). .togglePlaylist and overlay intents unchanged. Every intent still retires the startup hint.
  • HUD / hint timers unchanged.

Selection + edited change only at the black peak (via select), so the old preset renders (dimming) until black, then the new preset renders (brightening) — no mid-fade param flicker.

TVPlayerView — black overlay

A full-bleed black overlay between the MetalView and the (summonable) overlay, dimmed by the controller:

Color.black
    .opacity(controller.transitionDim)
    .ignoresSafeArea()
    .allowsHitTesting(false)

Z-order (ZStack): MetalView → black overlay → TVOverlayView (if visible) → TVHUDView → TVHintView. So the fade blacks only the visual; the HUD/hint/overlay stay readable. The 30 Hz tick drives transitionDim continuously (no implicit .animation — the per-tick value IS the animation).

Re-seed path (unchanged engine)

select(target) sets edited = target.params (full structure) → MetalView.sync → RenderCoordinator.switchPreset(target, full): structure differs → existing rebuild + feedback reset. It fires at full black, so invisible. During blackHold the MTKView keeps stepping the fresh sim (~0.3 s of warmup) so the fade-in reveals an established look, not an empty field. The Phase-1 switchPreset structural-guard stays (harmless; a same-structure pick still adopts live).

Tuning (device)

fadeDuration ≈ 0.7 s, blackHold ≈ 0.3 s, peak dim 1.0 (full black — required to fully hide the re-seed). If a full dropout reads harshly on the Ambilight set, peak dim and durations are the knobs (host-injectable like the other AmbientController constants).

Tests & Verification

Host-unit (AmbientControllerTests, rewritten for the fade): - transitionDim is 0 before, ramps to 1 by fadeDuration, holds 1 through blackHold, returns to 0 by end. - select (re-seed) fires exactly once, at/after the black peak — not during fade-out, not twice. - Manual .next/.previous/.jumpTo and auto-cycle all complete with selectedID == target and edited == target.params (full adoption). - beginTransition no-op (no HUD, no transition) for jumpTo-current / single-preset playlist. - HUD + hint behavior unchanged (existing tests stay green). - Remove the obsolete pinnedMorph* and adoptTransition-based tests.

Visual: tvOS-sim build + screenshot (mid-fade if capturable); device sign-off (Johannes): switch a preset → gentle fade to black → new preset (with its own density/ratio) fades in; no hard restart visible; auto-cycle does the same; tune duration/blackness on the Ambilight TV.

Risks

  • Full-black dip may read as a dropout on some panels → tunable (peak dim / duration).
  • The re-seed must land exactly at full black (the reseeded-once flag at the fadeDuration crossing) — covered by a host test.