diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphLifecycle.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphLifecycle.kt new file mode 100644 index 0000000..bb76e72 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphLifecycle.kt @@ -0,0 +1,40 @@ +package link.socket.phosphor.lumos + +/** + * State for an active glyph. + * + * Tracks remaining time and emits the progress envelope used by frame builders + * to fade glyph-member voxels in and out. + */ +internal data class GlyphLifecycle( + val glyph: LumosGlyph, + val totalDurationSeconds: Float, + val ageSeconds: Float, +) { + val progress: Float get() = (ageSeconds / totalDurationSeconds).coerceIn(0f, 1f) + + val isComplete: Boolean get() = ageSeconds >= totalDurationSeconds + + val visibility: Float + get() { + val p = progress + val fadeIn = 0.20f + val fadeOut = 0.80f + return when { + p < fadeIn -> smoothstep(0f, fadeIn, p) + p > fadeOut -> 1f - smoothstep(fadeOut, 1f, p) + else -> 1f + } + } + + fun advance(dt: Float): GlyphLifecycle = copy(ageSeconds = ageSeconds + dt) + + private fun smoothstep( + edge0: Float, + edge1: Float, + x: Float, + ): Float { + val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) + return t * t * (3f - 2f * t) + } +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphShape.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphShape.kt new file mode 100644 index 0000000..9c96072 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/GlyphShape.kt @@ -0,0 +1,161 @@ +package link.socket.phosphor.lumos + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * A 2D shape predicate that classifies points in [-1, 1]^2 screen-space as + * inside-the-glyph or outside. + * + * The screen-space coordinate is the voxel's unit direction, rotated into + * camera space, projected to its X/Y components. + */ +fun interface GlyphShape { + fun contains( + screenX: Float, + screenY: Float, + ): Boolean + + companion object { + fun forGlyph(glyph: LumosGlyph): GlyphShape = + when (glyph) { + LumosGlyph.CHECK -> CheckShape + LumosGlyph.EXCLAIM -> ExclaimShape + LumosGlyph.QUESTION -> QuestionShape + LumosGlyph.HEART -> HeartShape + LumosGlyph.STAR -> StarShape + LumosGlyph.LIGHTNING -> LightningShape + } + } +} + +internal object CheckShape : GlyphShape { + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean = + distanceToSegment(screenX, screenY, -0.50f, 0.00f, -0.10f, -0.40f) < 0.105f || + distanceToSegment(screenX, screenY, -0.10f, -0.40f, 0.52f, 0.42f) < 0.105f +} + +internal object ExclaimShape : GlyphShape { + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean { + val stem = abs(screenX) < 0.08f && screenY in -0.35f..0.35f + val dot = distance(screenX, screenY, 0f, -0.55f) < 0.12f + return stem || dot + } +} + +internal object QuestionShape : GlyphShape { + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean { + val dx = screenX - 0.02f + val dy = screenY - 0.20f + val radius = sqrt(dx * dx + dy * dy) + val angle = atan2(dy, dx) + val arc = abs(radius - 0.32f) < 0.085f && angle in -0.15f..2.95f + val hook = distanceToSegment(screenX, screenY, 0.25f, 0.06f, 0.02f, -0.24f) < 0.085f + val dot = distance(screenX, screenY, 0f, -0.55f) < 0.12f + return arc || hook || dot + } +} + +internal object HeartShape : GlyphShape { + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean { + val x = screenX * 1.18f + val y = screenY * 1.18f + 0.10f + val field = x * x + y * y - 0.60f + return field * field * field - x * x * y * y * y < 0f + } +} + +internal object StarShape : GlyphShape { + private val vertices: List> = + buildList { + repeat(STAR_VERTEX_COUNT) { index -> + val radius = if (index % 2 == 0) OUTER_RADIUS else INNER_RADIUS + val angle = (PI / 2.0 + index * PI / 5.0).toFloat() + add(cos(angle) * radius to sin(angle) * radius) + } + } + + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean = pointInPolygon(screenX, screenY, vertices) + + private const val STAR_VERTEX_COUNT = 10 + private const val OUTER_RADIUS = 0.58f + private const val INNER_RADIUS = 0.25f +} + +internal object LightningShape : GlyphShape { + override fun contains( + screenX: Float, + screenY: Float, + ): Boolean = + distanceToSegment(screenX, screenY, 0.24f, 0.52f, -0.08f, 0.03f) < 0.10f || + distanceToSegment(screenX, screenY, -0.08f, 0.03f, 0.14f, 0.03f) < 0.10f || + distanceToSegment(screenX, screenY, 0.14f, 0.03f, -0.24f, -0.52f) < 0.10f +} + +private fun distanceToSegment( + x: Float, + y: Float, + ax: Float, + ay: Float, + bx: Float, + by: Float, +): Float { + val vx = bx - ax + val vy = by - ay + val lengthSquared = vx * vx + vy * vy + if (lengthSquared == 0f) return distance(x, y, ax, ay) + + val t = (((x - ax) * vx + (y - ay) * vy) / lengthSquared).coerceIn(0f, 1f) + return distance(x, y, ax + vx * t, ay + vy * t) +} + +private fun distance( + x: Float, + y: Float, + cx: Float, + cy: Float, +): Float { + val dx = x - cx + val dy = y - cy + return sqrt(dx * dx + dy * dy) +} + +private fun pointInPolygon( + x: Float, + y: Float, + vertices: List>, +): Boolean { + var inside = false + var previousIndex = vertices.lastIndex + + for (index in vertices.indices) { + val (xi, yi) = vertices[index] + val (xj, yj) = vertices[previousIndex] + val intersects = (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi + if (intersects) { + inside = !inside + } + previousIndex = index + } + + return inside +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosGlyph.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosGlyph.kt new file mode 100644 index 0000000..d79b8a4 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosGlyph.kt @@ -0,0 +1,40 @@ +package link.socket.phosphor.lumos + +import kotlinx.serialization.Serializable + +/** + * Canonical glyph identifiers for the Lumos voxel-orb visualization. + * + * Glyphs are punctuated single-shot animations, distinct from + * [link.socket.phosphor.signal.AtmosphereState], which describes continuous + * scene-global character. Renderers receive an active glyph via [VoxelFrame.glyph]. + */ +@Serializable +enum class LumosGlyph( + /** + * Semantic accent color in HSL, applied to glyph-member voxels. + * + * Renderers convert this to sRGB at frame-build time. + */ + val hue: Float, + val saturation: Float, + val lightness: Float, +) { + /** Completion / task done. */ + CHECK(hue = 145f, saturation = 0.65f, lightness = 0.55f), + + /** Attention required / warning. */ + EXCLAIM(hue = 32f, saturation = 0.95f, lightness = 0.55f), + + /** CHI escalation / uncertainty surface. */ + QUESTION(hue = 280f, saturation = 0.70f, lightness = 0.60f), + + /** Affirmation / preference. */ + HEART(hue = 340f, saturation = 0.80f, lightness = 0.60f), + + /** Achievement / milestone. */ + STAR(hue = 50f, saturation = 0.95f, lightness = 0.65f), + + /** Active execution / spark. */ + LIGHTNING(hue = 244f, saturation = 0.85f, lightness = 0.60f), +} diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt index 5d97b37..521309c 100644 --- a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt @@ -6,6 +6,8 @@ import kotlin.math.sin import link.socket.phosphor.color.NeutralColor import link.socket.phosphor.field.Voxel import link.socket.phosphor.field.VoxelSphere +import link.socket.phosphor.field.facingCamera +import link.socket.phosphor.math.Vector3 import link.socket.phosphor.runtime.SceneSnapshot import link.socket.phosphor.signal.AtmospherePattern import link.socket.phosphor.signal.AtmosphereState @@ -74,6 +76,25 @@ class VoxelFrameBuilder( var voxelSphere: VoxelSphere = VoxelSphere(initialResolution) private set + private var activeGlyph: GlyphLifecycle? = null + + /** True if a glyph is currently being rendered. */ + val hasActiveGlyph: Boolean get() = activeGlyph != null + + /** Queue a glyph for display. Replaces any currently active glyph. */ + fun queueGlyph( + glyph: LumosGlyph, + durationSeconds: Float = 1.5f, + ) { + require(durationSeconds > 0f) { "durationSeconds must be > 0" } + activeGlyph = + GlyphLifecycle( + glyph = glyph, + totalDurationSeconds = durationSeconds, + ageSeconds = 0f, + ) + } + /** * Produce a [VoxelFrame] from [snapshot], advancing phase accumulators * and rotations by [dt] seconds. @@ -96,17 +117,43 @@ class VoxelFrameBuilder( voxelSphere = voxelSphere.rebuild(atmosphere.resolution) } + val glyphLifecycle = advanceGlyph(dt) advancePhases(atmosphere, dt) val pulse = 1f + sin(pulsePhase) * atmosphere.pulseAmplitude val effectiveYSquash = atmosphere.ySquash * (config.globalYSquashOverride ?: 1f) + val glyphColor = + glyphLifecycle + ?.takeIf { config.enableGlyphCarving } + ?.let { NeutralColor.fromHsl(it.glyph.hue, it.glyph.saturation, it.glyph.lightness) } + val glyphShape = glyphLifecycle?.takeIf { config.enableGlyphCarving }?.let { GlyphShape.forGlyph(it.glyph) } + val glyphVisibility = glyphLifecycle?.takeIf { config.enableGlyphCarving }?.visibility ?: 0f + val glyphRotation = Vector3(orbRotationX, orbRotationY, 0f) val cells = ArrayList(voxelSphere.voxels.size) for (voxel in voxelSphere.voxels) { val mix = computeMix(atmosphere, transition, voxel) val boundaryShrink = computeBoundaryShrink(atmosphere.bipolarStrength, mix) - val color = computeVoxelColor(atmosphere, transition, mix) - val scale = atmosphere.voxelGap * pulse * boundaryShrink + val baseColor = computeVoxelColor(atmosphere, transition, mix) + val rotatedDirection = glyphShape?.let { voxel.unitDirection.rotatedBy(glyphRotation) } + val isGlyphMember = + glyphShape != null && + rotatedDirection != null && + voxel.facingCamera(glyphRotation, GLYPH_FACING_THRESHOLD) && + glyphShape.contains(rotatedDirection.x, rotatedDirection.y) + val color = + if (isGlyphMember && glyphColor != null) { + NeutralColor.lerp(baseColor, glyphColor, glyphVisibility) + } else { + baseColor + } + val glyphShrink = + if (glyphLifecycle != null && config.enableGlyphCarving && !isGlyphMember) { + 1f - GLYPH_BACKGROUND_SHRINK * glyphVisibility + } else { + 1f + } + val scale = atmosphere.voxelGap * pulse * boundaryShrink * glyphShrink if (config.omitBelowScale > 0f && scale < config.omitBelowScale) continue @@ -145,10 +192,28 @@ class VoxelFrameBuilder( resolution = voxelSphere.resolution, cells = cells, ambient = ambient, - glyph = null, + glyph = + glyphLifecycle + ?.takeIf { config.enableGlyphCarving } + ?.let { lifecycle -> + checkNotNull(glyphColor) + VoxelGlyphState( + glyphName = lifecycle.glyph.name, + progress = lifecycle.progress, + red = glyphColor.red, + green = glyphColor.green, + blue = glyphColor.blue, + ) + }, ) } + private fun advanceGlyph(dt: Float): GlyphLifecycle? { + val next = activeGlyph?.advance(dt) ?: return null + activeGlyph = next.takeUnless { it.isComplete } + return activeGlyph + } + private fun advancePhases( atmosphere: AtmosphereState, dt: Float, @@ -243,6 +308,8 @@ class VoxelFrameBuilder( private const val BUMP_PHI: Float = 2.5f private const val BUMP_PHASE: Float = 1.4f private const val BAND_GAIN: Float = 0.4f + private const val GLYPH_FACING_THRESHOLD: Float = 0.15f + private const val GLYPH_BACKGROUND_SHRINK: Float = 0.30f internal fun evaluatePattern( pattern: AtmospherePattern, diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphLifecycleTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphLifecycleTest.kt new file mode 100644 index 0000000..842e9b2 --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphLifecycleTest.kt @@ -0,0 +1,71 @@ +package link.socket.phosphor.lumos + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GlyphLifecycleTest { + @Test + fun `progress linearly tracks age over total duration`() { + val lifecycle = + GlyphLifecycle( + glyph = LumosGlyph.CHECK, + totalDurationSeconds = 2f, + ageSeconds = 0.5f, + ) + + assertEquals(0.25f, lifecycle.progress, absoluteTolerance = 1e-5f) + } + + @Test + fun `visibility fades in holds and fades out`() { + fun lifecycleAt(progress: Float): GlyphLifecycle = + GlyphLifecycle( + glyph = LumosGlyph.CHECK, + totalDurationSeconds = 10f, + ageSeconds = progress * 10f, + ) + + assertEquals(0f, lifecycleAt(0f).visibility, absoluteTolerance = 1e-5f) + assertTrue(lifecycleAt(0.1f).visibility in 0f..1f) + assertEquals(1f, lifecycleAt(0.2f).visibility, absoluteTolerance = 1e-5f) + assertEquals(1f, lifecycleAt(0.5f).visibility, absoluteTolerance = 1e-5f) + assertEquals(1f, lifecycleAt(0.8f).visibility, absoluteTolerance = 1e-5f) + assertTrue(lifecycleAt(0.9f).visibility in 0f..1f) + assertEquals(0f, lifecycleAt(1f).visibility, absoluteTolerance = 1e-5f) + } + + @Test + fun `advance returns a copy with incremented age`() { + val lifecycle = + GlyphLifecycle( + glyph = LumosGlyph.EXCLAIM, + totalDurationSeconds = 1.5f, + ageSeconds = 0.25f, + ) + + val advanced = lifecycle.advance(0.5f) + + assertEquals(0.25f, lifecycle.ageSeconds) + assertEquals(0.75f, advanced.ageSeconds) + } + + @Test + fun `isComplete flips when age reaches total duration`() { + assertFalse( + GlyphLifecycle( + glyph = LumosGlyph.QUESTION, + totalDurationSeconds = 1f, + ageSeconds = 0.99f, + ).isComplete, + ) + assertTrue( + GlyphLifecycle( + glyph = LumosGlyph.QUESTION, + totalDurationSeconds = 1f, + ageSeconds = 1f, + ).isComplete, + ) + } +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphShapeTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphShapeTest.kt new file mode 100644 index 0000000..a0df15f --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/GlyphShapeTest.kt @@ -0,0 +1,42 @@ +package link.socket.phosphor.lumos + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GlyphShapeTest { + @Test + fun `each shape contains at least one inside point and excludes an outside point`() { + val samples = + mapOf( + CheckShape to (-0.30f to -0.20f), + ExclaimShape to (0.00f to 0.00f), + QuestionShape to (0.00f to 0.52f), + HeartShape to (0.00f to 0.00f), + StarShape to (0.00f to 0.00f), + LightningShape to (0.02f to 0.03f), + ) + + samples.forEach { (shape, insidePoint) -> + assertTrue(shape.contains(insidePoint.first, insidePoint.second)) + assertFalse(shape.contains(0.90f, -0.90f)) + } + } + + @Test + fun `forGlyph resolves each canonical glyph to its shape object`() { + val expected = + mapOf( + LumosGlyph.CHECK to CheckShape, + LumosGlyph.EXCLAIM to ExclaimShape, + LumosGlyph.QUESTION to QuestionShape, + LumosGlyph.HEART to HeartShape, + LumosGlyph.STAR to StarShape, + LumosGlyph.LIGHTNING to LightningShape, + ) + + expected.forEach { (glyph, shape) -> + assertTrue(GlyphShape.forGlyph(glyph) === shape) + } + } +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosGlyphTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosGlyphTest.kt new file mode 100644 index 0000000..95f375c --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/LumosGlyphTest.kt @@ -0,0 +1,30 @@ +package link.socket.phosphor.lumos + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import link.socket.phosphor.color.NeutralColor + +class LumosGlyphTest { + @Test + fun `enum has the six canonical glyph names`() { + assertEquals( + listOf("CHECK", "EXCLAIM", "QUESTION", "HEART", "STAR", "LIGHTNING"), + LumosGlyph.entries.map { it.name }, + ) + } + + @Test + fun `each glyph accent color resolves to non-grey sRGB`() { + LumosGlyph.entries.forEach { glyph -> + val color = NeutralColor.fromHsl(glyph.hue, glyph.saturation, glyph.lightness) + val isGrey = + abs(color.red - color.green) < 1e-5f && + abs(color.green - color.blue) < 1e-5f + + assertTrue(glyph.saturation > 0f, "${glyph.name} should declare a saturated accent") + assertTrue(!isGrey, "${glyph.name} resolved to a grey accent") + } + } +} diff --git a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt index 4e2f5f6..cdc255d 100644 --- a/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt @@ -4,11 +4,17 @@ import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue +import link.socket.phosphor.color.NeutralColor import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.field.VoxelSphere +import link.socket.phosphor.field.facingCamera +import link.socket.phosphor.math.Vector3 import link.socket.phosphor.palette.AtmospherePresets import link.socket.phosphor.runtime.SceneSnapshot import link.socket.phosphor.signal.AtmospherePattern @@ -291,6 +297,82 @@ class VoxelFrameBuilderTest { assertEquals(1_500L, frame.timestampEpochMillis) } + @Test + fun `queueGlyph causes the next build to populate glyph state`() { + val builder = VoxelFrameBuilder(initialResolution = 5) + + builder.queueGlyph(LumosGlyph.CHECK) + val frame = builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0f) + + val glyph = assertNotNull(frame.glyph) + assertEquals("CHECK", glyph.glyphName) + assertEquals(0f, glyph.progress, absoluteTolerance = 1e-5f) + assertTrue(builder.hasActiveGlyph) + } + + @Test + fun `glyph clears after its duration elapses`() { + val builder = VoxelFrameBuilder(initialResolution = 5) + + builder.queueGlyph(LumosGlyph.CHECK, durationSeconds = 0.2f) + val activeFrame = builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0.1f) + val expiredFrame = builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0.1f) + + assertNotNull(activeFrame.glyph) + assertNull(expiredFrame.glyph) + assertFalse(builder.hasActiveGlyph) + } + + @Test + fun `glyph-member voxels lerp to the glyph accent color`() { + val atmosphere = + AtmospherePresets.IDLE.copy( + resolution = 8, + noise = 0f, + pulseAmplitude = 0f, + rotationX = 0f, + rotationY = 0f, + surfaceBump = 0f, + ) + val builder = VoxelFrameBuilder(initialResolution = atmosphere.resolution) + val shape = GlyphShape.forGlyph(LumosGlyph.CHECK) + val sampleIndex = + builder.voxelSphere.voxels.indexOfFirst { voxel -> + voxel.facingCamera(Vector3.ZERO) && + shape.contains(voxel.unitDirection.x, voxel.unitDirection.y) + } + + assertTrue(sampleIndex >= 0, "expected at least one CHECK glyph voxel at resolution ${atmosphere.resolution}") + + builder.queueGlyph(LumosGlyph.CHECK, durationSeconds = 1.5f) + val frame = builder.build(snapshot(atmosphere = atmosphere), dt = 0.3f) + val cell = frame.cells[sampleIndex] + val accent = + NeutralColor.fromHsl( + LumosGlyph.CHECK.hue, + LumosGlyph.CHECK.saturation, + LumosGlyph.CHECK.lightness, + ) + + assertEquals(accent.red, cell.red, absoluteTolerance = 0.02f) + assertEquals(accent.green, cell.green, absoluteTolerance = 0.02f) + assertEquals(accent.blue, cell.blue, absoluteTolerance = 0.02f) + } + + @Test + fun `queueGlyph replaces the active glyph`() { + val builder = VoxelFrameBuilder(initialResolution = 5) + + builder.queueGlyph(LumosGlyph.CHECK, durationSeconds = 1.5f) + builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0.3f) + builder.queueGlyph(LumosGlyph.QUESTION, durationSeconds = 1.5f) + val frame = builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0f) + + val glyph = assertNotNull(frame.glyph) + assertEquals("QUESTION", glyph.glyphName) + assertEquals(0f, glyph.progress, absoluteTolerance = 1e-5f) + } + private fun snapshot( atmosphere: AtmosphereState?, transition: AtmosphereTransition? = null,