diff --git a/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderConfig.kt b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderConfig.kt new file mode 100644 index 0000000..d817d31 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderConfig.kt @@ -0,0 +1,33 @@ +package link.socket.phosphor.lumos + +import kotlinx.serialization.Serializable + +/** + * Embedder-controllable knobs for [VoxelFrame] production. + * + * Distinct from [link.socket.phosphor.signal.AtmosphereState], which describes + * scene-global visual character. Config here is about how the renderer + * interprets that character — global format adjustments, optional features the + * downstream renderer cannot honor, and cheap emission filters. + * + * Downstream renderers are also responsible for the light budget: per-voxel + * sRGB output from [VoxelFrameBuilder] is in 0..1, and the consuming renderer + * must constrain its lighting setup so that total ambient + directional + * illumination does not exceed 1.0 to avoid color overshoot. + * + * @property globalYSquashOverride Multiplicative Y-axis squash applied on top + * of [link.socket.phosphor.signal.AtmosphereState.ySquash]. Null leaves the + * atmosphere's squash untouched; a non-null value multiplies further. + * @property enableGlyphCarving When false, [VoxelFrame.glyph] is always null + * even if the atmosphere references a glyph. Useful for renderers that do + * not support glyph carving. Default true. + * @property omitBelowScale Voxels whose final [VoxelCell.scale] falls below + * this threshold are omitted from the output frame entirely. Default 0 + * emits all voxels regardless of scale. + */ +@Serializable +data class LumosRenderConfig( + val globalYSquashOverride: Float? = null, + val enableGlyphCarving: Boolean = true, + val omitBelowScale: Float = 0.0f, +) 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 new file mode 100644 index 0000000..5d97b37 --- /dev/null +++ b/phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt @@ -0,0 +1,307 @@ +package link.socket.phosphor.lumos + +import kotlin.math.PI +import kotlin.math.abs +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.runtime.SceneSnapshot +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.signal.AtmosphereState +import link.socket.phosphor.signal.AtmosphereTransition + +/** + * Stateful translator from Phosphor's runtime state into a [VoxelFrame]. + * + * The builder owns continuous phase accumulators and the current voxel + * lattice so the surrounding pull-based runtime can call [build] each tick + * without re-allocating geometry or resetting animation phase. Atmosphere + * frequency changes can be interpolated through transitions without phase + * discontinuities because [pulsePhase] and [patternPhase] persist across + * calls and are advanced by the current state's rate. + * + * The builder is intentionally mutable and single-threaded: one builder per + * renderer, called from the render loop. Sharing across threads or sharing + * across renderers will produce torn output. + * + * @param initialResolution Starting resolution for the underlying + * [VoxelSphere]. Rebuilt when [AtmosphereState.resolution] changes. + * @param config Optional embedder knobs; see [LumosRenderConfig]. + */ +class VoxelFrameBuilder( + initialResolution: Int, + private val config: LumosRenderConfig = LumosRenderConfig(), +) { + /** + * Continuous breath-pulse phase in radians. + * + * Advanced each tick by `dt * pulseFrequency * 2 * PI` so the sine wave + * driving [VoxelCell.scale] modulation has no discontinuities when the + * atmosphere's frequency interpolates during a transition. Wrapped at + * 2π to preserve floating-point precision over long runs. + */ + var pulsePhase: Float = 0f + private set + + /** + * Continuous pattern-evaluation phase. + * + * Advanced each tick by `dt * patternSpeed`. Wrapped at `2π * 10` so + * non-integer pattern multipliers (1.2, 2.5, ...) still hit an integer + * multiple of 2π at the wrap boundary; this keeps each pattern's sine + * wave continuous when the accumulator resets. + */ + var patternPhase: Float = 0f + private set + + /** + * Integrated X-axis rotation in radians. Exposed via [VoxelAmbient.orbRotationX]. + */ + var orbRotationX: Float = 0f + private set + + /** + * Integrated Y-axis rotation in radians. Exposed via [VoxelAmbient.orbRotationY]. + */ + var orbRotationY: Float = 0f + private set + + /** + * Current voxel lattice. Rebuilt when [AtmosphereState.resolution] changes. + * Stable across ticks otherwise — no per-frame allocation. + */ + var voxelSphere: VoxelSphere = VoxelSphere(initialResolution) + private set + + /** + * Produce a [VoxelFrame] from [snapshot], advancing phase accumulators + * and rotations by [dt] seconds. + * + * @throws IllegalStateException when the atmosphere subsystem is disabled + * on the runtime that produced [snapshot] + * (`SceneConfiguration.enableAtmosphere = false`). + */ + fun build( + snapshot: SceneSnapshot, + dt: Float, + ): VoxelFrame { + val atmosphere = + checkNotNull(snapshot.atmosphere) { + "VoxelFrameBuilder requires SceneConfiguration.enableAtmosphere = true" + } + val transition = snapshot.atmosphereTransition + + if (atmosphere.resolution != voxelSphere.resolution) { + voxelSphere = voxelSphere.rebuild(atmosphere.resolution) + } + + advancePhases(atmosphere, dt) + + val pulse = 1f + sin(pulsePhase) * atmosphere.pulseAmplitude + val effectiveYSquash = atmosphere.ySquash * (config.globalYSquashOverride ?: 1f) + + 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 + + if (config.omitBelowScale > 0f && scale < config.omitBelowScale) continue + + val px = voxel.normalizedPos.x + voxel.jitter.x * atmosphere.noise * JITTER_GAIN + val py = voxel.normalizedPos.y + voxel.jitter.y * atmosphere.noise * JITTER_GAIN + val pz = voxel.normalizedPos.z + voxel.jitter.z * atmosphere.noise * JITTER_GAIN + val bumpAmount = + atmosphere.surfaceBump * + sin(voxel.theta * BUMP_THETA + voxel.phi * BUMP_PHI + patternPhase * BUMP_PHASE) + + val ux = voxel.unitDirection.x + val uy = voxel.unitDirection.y + val uz = voxel.unitDirection.z + + val finalX = (px + ux * bumpAmount) * pulse + val finalY = (py + uy * bumpAmount) * effectiveYSquash * pulse + val finalZ = (pz + uz * bumpAmount) * pulse + + cells += + VoxelCell( + x = finalX, + y = finalY, + z = finalZ, + scale = scale, + red = color.red, + green = color.green, + blue = color.blue, + ) + } + + val ambient = computeAmbient(atmosphere) + + return VoxelFrame( + tick = snapshot.frameIndex, + timestampEpochMillis = millisFromElapsed(snapshot.elapsedTimeSeconds), + resolution = voxelSphere.resolution, + cells = cells, + ambient = ambient, + glyph = null, + ) + } + + private fun advancePhases( + atmosphere: AtmosphereState, + dt: Float, + ) { + pulsePhase = wrap(pulsePhase + dt * atmosphere.pulseFrequency * TWO_PI, TWO_PI) + patternPhase = wrap(patternPhase + dt * atmosphere.patternSpeed, PATTERN_WRAP) + orbRotationX = wrap(orbRotationX + dt * atmosphere.rotationX, TWO_PI) + orbRotationY = wrap(orbRotationY + dt * atmosphere.rotationY, TWO_PI) + } + + private fun computeMix( + atmosphere: AtmosphereState, + transition: AtmosphereTransition?, + voxel: Voxel, + ): Float { + val toMix = evaluatePattern(atmosphere.pattern, voxel, patternPhase) + if (transition == null || transition.from.pattern == transition.to.pattern) { + return toMix + } + val fromMix = evaluatePattern(transition.from.pattern, voxel, patternPhase) + return fromMix * (1f - transition.progressLinear) + toMix * transition.progressLinear + } + + private fun computeBoundaryShrink( + bipolarStrength: Float, + mix: Float, + ): Float { + if (bipolarStrength <= 0f) return 1f + val band = BAND_GAIN * bipolarStrength + if (band <= 0f) return 1f + val distance = abs(mix - 0.5f) + if (distance >= band) return 1f + return smoothstep(distance / band) + } + + private fun computeVoxelColor( + atmosphere: AtmosphereState, + transition: AtmosphereTransition?, + mix: Float, + ): NeutralColor { + val crossfade = + transition?.takeIf { it.from.bipolarStrength != it.to.bipolarStrength } + ?: return singleStateColor(atmosphere, mix) + + val fromColor = singleStateColor(crossfade.from, mix) + val toColor = singleStateColor(crossfade.to, mix) + return NeutralColor.lerpOklab(fromColor, toColor, crossfade.progressLinear) + } + + private fun singleStateColor( + state: AtmosphereState, + mix: Float, + ): NeutralColor { + if (state.bipolarStrength > 0f) { + val band = BAND_GAIN * state.bipolarStrength + return when { + mix < 0.5f - band -> + NeutralColor.fromHsl(state.primaryHue, state.saturation, state.lightness) + mix > 0.5f + band -> + NeutralColor.fromHsl(state.secondaryHue, state.saturation, state.lightness) + mix <= 0.5f -> + NeutralColor.fromHsl(state.primaryHue, state.saturation, state.lightness) + else -> + NeutralColor.fromHsl(state.secondaryHue, state.saturation, state.lightness) + } + } + val hue = lerpHueShortest(state.primaryHue, state.secondaryHue, mix) + return NeutralColor.fromHsl(hue, state.saturation, state.lightness) + } + + private fun computeAmbient(atmosphere: AtmosphereState): VoxelAmbient { + val primary = NeutralColor.fromHsl(atmosphere.primaryHue, atmosphere.saturation, atmosphere.lightness) + val secondary = NeutralColor.fromHsl(atmosphere.secondaryHue, atmosphere.saturation, atmosphere.lightness) + val glow = NeutralColor.lerpOklab(primary, secondary, 0.5f) + return VoxelAmbient( + glowRed = glow.red, + glowGreen = glow.green, + glowBlue = glow.blue, + glowIntensity = atmosphere.glow, + orbRotationX = orbRotationX, + orbRotationY = orbRotationY, + orbRotationZ = 0f, + ) + } + + companion object { + private const val TWO_PI: Float = (2.0 * PI).toFloat() + private const val PATTERN_WRAP: Float = (2.0 * PI * 10.0).toFloat() + + private const val JITTER_GAIN: Float = 2.4f + private const val BUMP_THETA: Float = 3.0f + private const val BUMP_PHI: Float = 2.5f + private const val BUMP_PHASE: Float = 1.4f + private const val BAND_GAIN: Float = 0.4f + + internal fun evaluatePattern( + pattern: AtmospherePattern, + voxel: Voxel, + patternPhase: Float, + ): Float = + when (pattern) { + AtmospherePattern.LONGITUDE -> + (sin(voxel.theta * 2f + patternPhase * 2f) + 1f) * 0.5f + AtmospherePattern.LATITUDE -> + (sin(voxel.phi * 2.5f + patternPhase * 2f) + 1f) * 0.5f + AtmospherePattern.SPIRAL -> + (sin(voxel.theta * 2f + voxel.phi * 3f + patternPhase * 2.5f) + 1f) * 0.5f + AtmospherePattern.SCAN -> + (sin(voxel.normalizedPos.y * 0.85f - patternPhase * 3f) + 1f) * 0.5f + AtmospherePattern.PLASMA -> + ( + sin(voxel.normalizedPos.x * 0.55f + patternPhase * 1.7f) + + sin(voxel.normalizedPos.y * 0.50f + patternPhase * 1.2f) + + sin(voxel.normalizedPos.z * 0.65f + patternPhase * 2.0f) + 3f + ) / 6f + AtmospherePattern.PULSE -> + (sin(voxel.distance * 0.85f - patternPhase * 3f) + 1f) * 0.5f + AtmospherePattern.SOLID -> 0.5f + } + + internal fun smoothstep(t: Float): Float { + val clamped = t.coerceIn(0f, 1f) + return clamped * clamped * (3f - 2f * clamped) + } + + internal fun lerpHueShortest( + startDegrees: Float, + endDegrees: Float, + t: Float, + ): Float { + val rawDiff = endDegrees - startDegrees + val shortest = + when { + rawDiff > 180f -> rawDiff - 360f + rawDiff < -180f -> rawDiff + 360f + else -> rawDiff + } + var hue = startDegrees + shortest * t + while (hue < 0f) hue += 360f + while (hue >= 360f) hue -= 360f + return hue + } + + private fun wrap( + value: Float, + limit: Float, + ): Float { + if (limit <= 0f) return value + var wrapped = value % limit + if (wrapped < 0f) wrapped += limit + return wrapped + } + + private fun millisFromElapsed(elapsedSeconds: Float): Long = (elapsedSeconds * 1_000.0).toLong() + } +} 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 new file mode 100644 index 0000000..4e2f5f6 --- /dev/null +++ b/phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt @@ -0,0 +1,316 @@ +package link.socket.phosphor.lumos + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.field.VoxelSphere +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.runtime.SceneSnapshot +import link.socket.phosphor.signal.AtmospherePattern +import link.socket.phosphor.signal.AtmosphereState +import link.socket.phosphor.signal.AtmosphereTransition +import link.socket.phosphor.signal.CognitivePhase + +class VoxelFrameBuilderTest { + @Test + fun `steady-state IDLE atmosphere emits one cell per voxel`() { + val resolution = 6 + val builder = VoxelFrameBuilder(initialResolution = resolution) + val snapshot = snapshot(atmosphere = AtmospherePresets.IDLE.copy(resolution = resolution)) + + val frame = builder.build(snapshot, dt = 0.016f) + + assertEquals(resolution, frame.resolution) + assertEquals(VoxelSphere(resolution).count, frame.cells.size) + } + + @Test + fun `two consecutive builds with dt zero produce bit-identical frames`() { + val builder = VoxelFrameBuilder(initialResolution = 5) + val snapshot = snapshot(atmosphere = AtmospherePresets.IDLE) + + val first = builder.build(snapshot, dt = 0f) + val second = builder.build(snapshot, dt = 0f) + + assertEquals(first, second) + assertEquals(0f, builder.pulsePhase) + assertEquals(0f, builder.patternPhase) + } + + @Test + fun `successive builds with non-zero dt advance phase monotonically`() { + val builder = VoxelFrameBuilder(initialResolution = 4) + val snapshot = snapshot(atmosphere = AtmospherePresets.IDLE) + + val priorPulse = builder.pulsePhase + val priorPattern = builder.patternPhase + + val first = builder.build(snapshot, dt = 0.016f) + val midPulse = builder.pulsePhase + val midPattern = builder.patternPhase + val second = builder.build(snapshot, dt = 0.016f) + val finalPulse = builder.pulsePhase + val finalPattern = builder.patternPhase + + assertTrue(midPulse > priorPulse, "pulsePhase did not advance after first build") + assertTrue(midPattern > priorPattern, "patternPhase did not advance after first build") + assertTrue(finalPulse > midPulse, "pulsePhase did not advance after second build") + assertTrue(finalPattern > midPattern, "patternPhase did not advance after second build") + assertNotEquals(first.cells, second.cells) + } + + @Test + fun `bipolar strength collapses voxels at the pattern boundary`() { + val solidBipolar = + AtmospherePresets.IDLE.copy( + pattern = AtmospherePattern.SOLID, + bipolarStrength = 0.45f, + ) + val builder = VoxelFrameBuilder(initialResolution = 5) + + val frame = builder.build(snapshot(atmosphere = solidBipolar), dt = 0f) + + assertTrue(frame.cells.isNotEmpty()) + frame.cells.forEach { cell -> + assertTrue( + cell.scale < 0.1f, + "voxel at SOLID pattern boundary should be thinned, got scale=${cell.scale}", + ) + } + } + + @Test + fun `bipolar voxels away from the boundary remain at full scale`() { + val patternBipolar = + AtmospherePresets.IDLE.copy( + pattern = AtmospherePattern.LONGITUDE, + bipolarStrength = 0.45f, + ) + val builder = VoxelFrameBuilder(initialResolution = 6) + + val frame = builder.build(snapshot(atmosphere = patternBipolar), dt = 0f) + + val baseline = patternBipolar.voxelGap * (1f + 0f) + val unthinnedCount = frame.cells.count { abs(it.scale - baseline) < 1e-5f } + assertTrue( + unthinnedCount > 0, + "expected at least one voxel away from boundary to remain unthinned", + ) + } + + @Test + fun `crossfade between IDLE and UNCERTAIN produces colors distinct from either endpoint`() { + val from = AtmospherePresets.IDLE + val to = AtmospherePresets.UNCERTAIN + val transition = + AtmosphereTransition( + from = from, + to = to, + fromPresetName = "idle", + toPresetName = "uncertain", + progressLinear = 0.5f, + progressEased = 0.5f, + easingName = "linear", + durationSeconds = 1f, + ) + + val builder = VoxelFrameBuilder(initialResolution = 5) + val sourceFrame = builder.build(snapshot(atmosphere = from), dt = 0f) + val crossFrame = + builder.build( + snapshot(atmosphere = from, transition = transition), + dt = 0f, + ) + val targetFrame = builder.build(snapshot(atmosphere = to), dt = 0f) + + val sourceColors = sourceFrame.cells.map { Triple(it.red, it.green, it.blue) } + val crossColors = crossFrame.cells.map { Triple(it.red, it.green, it.blue) } + val targetColors = targetFrame.cells.map { Triple(it.red, it.green, it.blue) } + + assertNotEquals(sourceColors, crossColors) + assertNotEquals(targetColors, crossColors) + } + + @Test + fun `pattern crossfade lands between pure source and pure target mix`() { + val from = AtmospherePresets.IDLE + val to = AtmospherePresets.LISTENING + val sphere = VoxelSphere(4) + val patternPhase = 0f + + val sample = + sphere.voxels.first { voxel -> + val fromMix = + VoxelFrameBuilder.evaluatePattern(from.pattern, voxel, patternPhase) + val toMix = + VoxelFrameBuilder.evaluatePattern(to.pattern, voxel, patternPhase) + abs(fromMix - toMix) > 0.05f + } + + val fromMix = VoxelFrameBuilder.evaluatePattern(from.pattern, sample, patternPhase) + val toMix = VoxelFrameBuilder.evaluatePattern(to.pattern, sample, patternPhase) + val blended = fromMix * 0.5f + toMix * 0.5f + + assertNotEquals(fromMix, blended) + assertNotEquals(toMix, blended) + assertTrue(blended in minOf(fromMix, toMix)..maxOf(fromMix, toMix)) + } + + @Test + fun `pulse phase stays continuous when frequency doubles between ticks`() { + val builder = VoxelFrameBuilder(initialResolution = 3) + val slow = AtmospherePresets.IDLE.copy(pulseFrequency = 0.3f) + val fast = AtmospherePresets.IDLE.copy(pulseFrequency = 0.6f) + val dt = 0.016f + + repeat(8) { builder.build(snapshot(atmosphere = slow), dt = dt) } + val phaseBeforeChange = builder.pulsePhase + + builder.build(snapshot(atmosphere = fast), dt = dt) + val phaseAfterChange = builder.pulsePhase + + val expectedDelta = dt * fast.pulseFrequency * 2f * kotlin.math.PI.toFloat() + val actualDelta = phaseAfterChange - phaseBeforeChange + assertTrue( + abs(actualDelta - expectedDelta) < 1e-5f, + "expected phase to advance by $expectedDelta but advanced by $actualDelta", + ) + } + + @Test + fun `phase accumulators wrap to stay within their respective bounds`() { + val builder = VoxelFrameBuilder(initialResolution = 2) + val fast = + AtmospherePresets.IDLE.copy( + pulseFrequency = 50f, + patternSpeed = 50f, + rotationX = 50f, + rotationY = 50f, + ) + val twoPi = 2f * kotlin.math.PI.toFloat() + val patternWrap = twoPi * 10f + + repeat(200) { builder.build(snapshot(atmosphere = fast), dt = 0.5f) } + + assertTrue(builder.pulsePhase in 0f..twoPi) + assertTrue(builder.patternPhase in 0f..patternWrap) + assertTrue(builder.orbRotationX in 0f..twoPi) + assertTrue(builder.orbRotationY in 0f..twoPi) + } + + @Test + fun `resolution change rebuilds the voxel sphere`() { + val builder = VoxelFrameBuilder(initialResolution = 4) + val higher = AtmospherePresets.IDLE.copy(resolution = 7) + + val frame = builder.build(snapshot(atmosphere = higher), dt = 0f) + + assertEquals(7, builder.voxelSphere.resolution) + assertEquals(VoxelSphere(7).count, frame.cells.size) + } + + @Test + fun `omitBelowScale filters voxels below the threshold`() { + val solidBipolar = + AtmospherePresets.IDLE.copy( + pattern = AtmospherePattern.SOLID, + bipolarStrength = 0.45f, + ) + val builder = + VoxelFrameBuilder( + initialResolution = 5, + config = LumosRenderConfig(omitBelowScale = 0.01f), + ) + + val frame = builder.build(snapshot(atmosphere = solidBipolar), dt = 0f) + + assertEquals(0, frame.cells.size) + } + + @Test + fun `globalYSquashOverride multiplies the atmosphere ySquash`() { + val builder = + VoxelFrameBuilder( + initialResolution = 4, + config = LumosRenderConfig(globalYSquashOverride = 0.5f), + ) + val baselineBuilder = VoxelFrameBuilder(initialResolution = 4) + val flatAtmosphere = + AtmospherePresets.IDLE.copy( + noise = 0f, + surfaceBump = 0f, + pulseAmplitude = 0f, + ) + + val override = builder.build(snapshot(atmosphere = flatAtmosphere), dt = 0f) + val baseline = baselineBuilder.build(snapshot(atmosphere = flatAtmosphere), dt = 0f) + + override.cells.zip(baseline.cells).forEach { (overridden, normal) -> + assertEquals(normal.x, overridden.x, "x should not change") + assertEquals(normal.z, overridden.z, "z should not change") + assertEquals(normal.y * 0.5f, overridden.y, absoluteTolerance = 1e-5f) + } + } + + @Test + fun `ambient glow comes from the OKLab midpoint of primary and secondary hues`() { + val builder = VoxelFrameBuilder(initialResolution = 3) + val frame = builder.build(snapshot(atmosphere = AtmospherePresets.IDLE), dt = 0f) + + val ambient = frame.ambient + assertTrue(ambient.glowRed in 0f..1f) + assertTrue(ambient.glowGreen in 0f..1f) + assertTrue(ambient.glowBlue in 0f..1f) + assertEquals(AtmospherePresets.IDLE.glow, ambient.glowIntensity) + assertEquals(0f, ambient.orbRotationZ) + } + + @Test + fun `build throws when snapshot atmosphere is null`() { + val builder = VoxelFrameBuilder(initialResolution = 3) + val snapshot = snapshot(atmosphere = null) + + assertFailsWith { builder.build(snapshot, dt = 0.016f) } + } + + @Test + fun `frame tick and timestamp track the snapshot`() { + val builder = VoxelFrameBuilder(initialResolution = 3) + val snapshot = + snapshot(atmosphere = AtmospherePresets.IDLE) + .copy(frameIndex = 42L, elapsedTimeSeconds = 1.5f) + + val frame = builder.build(snapshot, dt = 0f) + + assertEquals(42L, frame.tick) + assertEquals(1_500L, frame.timestampEpochMillis) + } + + private fun snapshot( + atmosphere: AtmosphereState?, + transition: AtmosphereTransition? = null, + ): SceneSnapshot = + SceneSnapshot( + frameIndex = 0L, + elapsedTimeSeconds = 0f, + coordinateSpace = CoordinateSpace.WORLD_CENTERED, + agentStates = emptyList(), + substrateState = SubstrateState.create(2, 2), + particleStates = emptyList(), + flowConnections = emptyList(), + flowField = null, + waveformHeightField = null, + waveformGridWidth = null, + waveformGridDepth = null, + cameraTransform = null, + emitterStates = emptyList(), + choreographyPhase = CognitivePhase.NONE, + atmosphere = atmosphere, + atmosphereTransition = transition, + ) +}