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): advancetransition.elapsed; the first time it crossesfadeDuration(full black), callappState.select(id: toID)once (reseededflag) — the full re-seed, hidden by black. Clear the transition atelapsed >= 2*fadeDuration + blackHold, resetdwellElapsed.currentIndexresolves fromtransition?.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).beginTransitionguardstarget.id != selectedID || transition != nil(no-op when already there & idle)..togglePlaylistand 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:
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 thefadeDurationcrossing) — covered by a host test.