You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With LumosRenderer<T> and VoxelFrame defined (#30), this ticket adds the builder that converts Phosphor runtime state into a VoxelFrame ready for downstream rendering. This is the most architecturally significant ticket in Wave 1 — it ports the entire pattern/color/geometry pipeline from the Lumos prototype into pure Kotlin running in Phosphor's pull-based runtime model.
snapshot.frameIndex and snapshot.elapsedTimeSeconds — for tick/timestamp on the output frame
A LumosRenderConfig (this ticket adds it) — embedder-controllable knobs
What the builder produces
A populated VoxelFrame with per-voxel position, scale, and color computed by applying the atmosphere's pattern, color logic, geometry deformation, and (if a transition is active) crossfade math to the source VoxelSphere. The frame is framework-free — consuming apps map cells to their own draw primitives.
Lessons from the Lumos prototype encoded here
The prototype iterated on this exact transformation for many days. Critical lessons that must be preserved verbatim:
Phase accumulators for frequencies. The builder maintains internal pulsePhase and patternPhase state, advanced by dt * frequency each tick. Never recompute phase from elapsed * frequency — that produces visible flickers when frequency changes. The accumulators are read from the running AtmosphereChoreographer via SceneSnapshot.atmosphereTransition (which carries them) when a transition is active; outside transitions, the builder uses its own accumulators.
Two-track easing. Numeric amplitude params (saturation, lightness, surfaceBump, pulseAmplitude) interpolate via the transition's progressEased. Color crossfade and pattern crossfade interpolate via progressLinear. Conflating these causes one or the other to feel abrupt at one end of the transition.
OKLab for color crossfades. When atmosphereTransition is active and either the source or target bipolarStrength > 0, color is computed for both source and target configurations and blended in OKLab via NeutralColor.lerpOklab. This is what prevents the amber↔purple lerp from passing through pink.
Bipolar mode produces geometry, not color, at the boundary. When bipolarStrength > 0, voxels near the pattern's mix=0.5 boundary have their scale reduced toward zero (with a smoothstep falloff). The two pole colors do not chromatically blend at the boundary — the boundary is a gap, not a third color. This is the deepest lesson from the prototype.
Pattern crossfade computes mix for both source and target patterns and lerps the mix value linearly via progressLinear when patterns differ across a transition.
Light budget cap is the renderer's responsibility, not the builder's. The builder produces per-voxel RGB colors in 0..1 sRGB. Downstream renderers must constrain their lighting setup so total ambient + directional ≤ 1.0 to avoid color overshoot. This constraint is documented in the LumosRenderConfig KDoc but not enforced by the builder.
Objective
Add VoxelFrameBuilder as a stateful builder in :phosphor-lumos that converts (VoxelSphere, SceneSnapshot, LumosRenderConfig) into a VoxelFrame per tick. Encode all pattern evaluation, color computation, bipolar geometry, breath pulse, surface bump, and transition crossfade logic from the Lumos prototype. Maintain phase-accumulator continuity across builder invocations.
Expected outcomes
A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderConfig.kt:
/** * Embedder-controllable knobs for VoxelFrame production. Distinct from * AtmosphereState (which describes scene-global visual character) — config * here is about how the renderer interprets that character.*/
@Serializable
data classLumosRenderConfig(
/** Y-axis squash multiplier applied to all voxel y-positions. The * AtmosphereState carries its own ySquash; this is a global override * applied on top. Default 1.0 (no override). */valglobalYSquashOverride:Float? = null,
/** Whether to populate glyph carving on output frames. When false, * the frame's glyph field is always null even if AtmosphereState * references a glyph. Useful for renderers that don't support * glyph carving. Default true. */valenableGlyphCarving:Boolean = true,
/** Threshold below which voxels are omitted from the output frame * entirely (scale < threshold means "don't emit this cell"). This * is an optimization for renderers that can skip invisible voxels * cheaply. Default 0.0 (emit all voxels regardless of scale). */valomitBelowScale:Float = 0.0f,
)
A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt:
classVoxelFrameBuilder(
initialResolution:Int,
privatevalconfig:LumosRenderConfig = LumosRenderConfig(),
) {
/** Continuous phase accumulators preserved across atmosphere changes * so frequency lerps don't produce phase discontinuities. */var pulsePhase:Float=0fprivate set
var patternPhase:Float=0fprivate set
/** Continuous rotation accumulators — rotationX and rotationY from * the atmosphere describe rates; the builder integrates them to * positions, exposed via VoxelFrame.ambient. */var orbRotationX:Float=0fprivate set
var orbRotationY:Float=0fprivate set
/** Current voxel lattice. Rebuilt when atmosphere resolution changes. */var voxelSphere:VoxelSphere=VoxelSphere(initialResolution)
private set
/** Produce a VoxelFrame from the current snapshot. Advances phase * accumulators and rotation by `dt` seconds. */funbuild(snapshot:SceneSnapshot, dt:Float): VoxelFrame
}
The build implementation, in roughly this order:
Resolve atmosphere state. Read snapshot.atmosphere; if null (atmosphere subsystem disabled), throw IllegalStateException with guidance: "VoxelFrameBuilder requires SceneConfiguration.enableAtmosphere = true". Optionally read snapshot.atmosphereTransition for crossfade data.
Maybe rebuild voxel sphere. If atmosphere.resolution != voxelSphere.resolution, call voxelSphere = voxelSphere.rebuild(atmosphere.resolution). This is the only allocation path; in steady state it's a no-op.
Advance phase accumulators.pulsePhase += dt * atmosphere.pulseFrequency * 2 * PI. patternPhase += dt * atmosphere.patternSpeed. orbRotationX += dt * atmosphere.rotationX. orbRotationY += dt * atmosphere.rotationY. Wrap each via modulo to keep numerical precision good over long runs.
Compute breath pulse multiplier.pulse = 1 + sin(pulsePhase) * atmosphere.pulseAmplitude. This is the per-voxel radial scale modulation.
For each voxel invoxelSphere.voxels, compute the cell:
Pattern mix: compute mix from atmosphere.pattern (and atmosphereTransition.from.pattern if active, with crossfade by progressLinear). See pattern formulas section below.
Bipolar boundary shrink: if atmosphere.bipolarStrength > 0 (or transitioning to/from a bipolar atmosphere), compute geometric shrink. The shrink follows a smoothstep falloff: full scale at mix < 0.5 - bipolarStrength*0.4, zero scale at mix == 0.5, full scale at mix > 0.5 + bipolarStrength*0.4.
Color: if no transition or no bipolar involvement, lerp primaryHue → secondaryHue along the shortest hue-wheel path via HSL→sRGB. If transition involves bipolar, compute source-side and target-side colors separately (each using its own bipolar/lerp logic), then blend them via NeutralColor.lerpOklab(sourceColor, targetColor, transition.progressLinear). See color algorithm section below.
Final scale: voxelGap * pulse * boundaryShrink * glyphCarve (glyphCarve is 1.0 when no glyph active, modulated by PHO-18's glyph logic when populated).
Compute ambient. Mid-point OKLab of (primaryHue, secondaryHue) at the atmosphere's saturation/lightness for the glow color. Glow intensity = atmosphere.glow. Rotation = current orbRotationX/orbRotationY (orbRotationZ = 0 for now).
When a transition is active and from.pattern != to.pattern: compute mixFrom = evaluatePattern(from.pattern, voxel, patternPhase) and mixTo = evaluatePattern(to.pattern, voxel, patternPhase) and mix = mixFrom * (1 - progressLinear) + mixTo * progressLinear.
Color algorithm
Encode in a private computeVoxelColor(...) function that returns an (r, g, b) triple in 0..1 sRGB:
Bipolar branch (when bipolarStrength > 0):
band = 0.4 * bipolarStrength (half-width of the soft transition zone around mix=0.5)
If mix < 0.5 - band: use primary hue color (hslToSrgb(primaryHue, saturation, lightness))
If mix > 0.5 + band: use secondary hue color
Otherwise (boundary zone): in this branch, the voxel's geometric scale is reduced toward zero (handled in the boundary shrink step above). The color itself is still computed by picking the closer pole (closer to mix=0.5 from below or above). This way the geometric thinning is the visual transition; the chromatic transition through forbidden colors is never rendered.
Lerp branch (bipolarStrength == 0):
Lerp primaryHue → secondaryHue along the shortest hue-wheel path
Transition crossfade: when transition is active and from.bipolarStrength != to.bipolarStrength, compute both source and target colors per voxel and NeutralColor.lerpOklab(sourceColor, targetColor, progressLinear) between them. Otherwise the snapshot's interpolated atmosphere state is already correct and a single-state color computation suffices.
Unit tests in phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.kt
Builder constructed with resolution N and steady-state IDLE atmosphere produces VoxelFrame with cells.size == VoxelSphere(N).count.
Two consecutive build() calls with identical snapshots (no transition, dt=0) produce frames whose cells are not identical because phases advance — but with dt=0, phases are unchanged and frames are bit-identical.
Successive calls with dt=0.016f show phase accumulation: pulsePhase and patternPhase increase monotonically; cell positions evolve continuously.
A snapshot with bipolarStrength > 0 produces cells where voxels near the pattern's mix=0.5 boundary have scale < 0.1 (geometric thinning is active).
A snapshot with an active atmosphereTransition between IDLE and UNCERTAIN produces colors whose RGB values are intermediate between the two states (verify by sampling a known voxel at progress=0.5).
The pattern crossfade is exercised: a transition from IDLE (LONGITUDE) to LISTENING (PLASMA) at progress=0.5 produces mix values that aren't equal to either pure pattern.
Phase continuity test: call build() with frequency=0.3 for N ticks, then change atmosphere to one with frequency=0.6, then call build() again — pulsePhase should be continuous (no jump), even though the rate it advances per tick has doubled.
IllegalStateException thrown if snapshot.atmosphere == null.
Technical constraints
KMP common code only.
All math uses kotlin.math.PI, kotlin.math.sin, etc. — no platform-specific math libraries.
Use NeutralColor.lerpOklab(...) for color crossfades (final API per Wave 0 recon A1: companion function on NeutralColor).
HSL → sRGB conversion: use NeutralColor.fromHsl(hue, saturation, lightness) if it exists in core; if not, implement locally in phosphor-lumos and don't add to core.
Verify Vector3 operations available — the builder needs at minimum addition, scaling, and component access (x, y, z).
Phase accumulators wrap via phase = phase % (2 * PI) after each update to maintain numerical precision over long runs.
Builder state is mutable; build() is not thread-safe. The expected usage is one builder per renderer, called from the render loop.
No anthropomorphizing language anywhere. No "mood", "feels", "wants". Atmospheres are visual character; voxels are geometry.
Run ./gradlew :phosphor-lumos:jvmTest and ./gradlew ktlintFormat before completion. iOS compile gate (CI-enforced).
API surface verification before starting
Run cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt and confirm the exact name and type of the atmosphere-transition field Add AtmosphereChoreographer for transitions between atmosphere states #24 landed (it may be atmosphereTransition: AtmosphereTransition? or named differently).
Run cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereTransition.kt and confirm field names match the recon (specifically progressLinear, progressEased, from, to).
Run cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.kt and confirm phase accumulator field names. The builder's accumulators may want to defer to the choreographer's accumulators during transitions rather than maintain its own — verify this is reasonable before duplicating state.
Run cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt and confirm the fromHsl method exists. If it doesn't, add it locally in :phosphor-lumos.
If any of these diverge from this ticket's assumptions, note the divergence in the PR description and adapt the implementation accordingly. The high-level algorithm above is unchanged regardless of specific field naming.
Any concrete LumosRenderer<T> implementation that renders the frame to a target surface.
Performance optimization beyond the omitBelowScale filter. Profile after Socket integration; the algorithm is correct first, fast second.
CLI projection (Wave 2).
The orb rotation Z-axis (atmosphere has rotationX and rotationY only; Z is reserved for future glyph-pop animations).
Verification
./gradlew :phosphor-lumos:jvmTest passes all new tests.
./gradlew :phosphor-lumos:allTests passes on all KMP targets (subject to the pre-existing JS test failure tracked in PHO-19).
Producing 1000 frames in a tight loop with IDLE atmosphere doesn't allocate more than the initial voxel sphere — verify via JVM profiler or Runtime.totalMemory() sampling.
Phase accumulators visibly track in the test fixtures (assertions on builder.pulsePhase after multiple build() calls).
Context
With
LumosRenderer<T>andVoxelFramedefined (#30), this ticket adds the builder that converts Phosphor runtime state into aVoxelFrameready for downstream rendering. This is the most architecturally significant ticket in Wave 1 — it ports the entire pattern/color/geometry pipeline from the Lumos prototype into pure Kotlin running in Phosphor's pull-based runtime model.What the builder consumes
VoxelSphere(built once per resolution via Voxel sphere construction primitive #21)SceneSnapshot(fromCognitiveSceneRuntime.update(dt))snapshot.atmosphere— the currentAtmosphereStatesnapshot.atmosphereTransition— non-null during transitions (added in Add AtmosphereChoreographer for transitions between atmosphere states #24)snapshot.frameIndexandsnapshot.elapsedTimeSeconds— for tick/timestamp on the output frameLumosRenderConfig(this ticket adds it) — embedder-controllable knobsWhat the builder produces
A populated
VoxelFramewith per-voxel position, scale, and color computed by applying the atmosphere's pattern, color logic, geometry deformation, and (if a transition is active) crossfade math to the sourceVoxelSphere. The frame is framework-free — consuming apps map cells to their own draw primitives.Lessons from the Lumos prototype encoded here
The prototype iterated on this exact transformation for many days. Critical lessons that must be preserved verbatim:
pulsePhaseandpatternPhasestate, advanced bydt * frequencyeach tick. Never recompute phase fromelapsed * frequency— that produces visible flickers when frequency changes. The accumulators are read from the runningAtmosphereChoreographerviaSceneSnapshot.atmosphereTransition(which carries them) when a transition is active; outside transitions, the builder uses its own accumulators.progressEased. Color crossfade and pattern crossfade interpolate viaprogressLinear. Conflating these causes one or the other to feel abrupt at one end of the transition.atmosphereTransitionis active and either the source or targetbipolarStrength > 0, color is computed for both source and target configurations and blended in OKLab viaNeutralColor.lerpOklab. This is what prevents the amber↔purple lerp from passing through pink.bipolarStrength > 0, voxels near the pattern's mix=0.5 boundary have theirscalereduced toward zero (with a smoothstep falloff). The two pole colors do not chromatically blend at the boundary — the boundary is a gap, not a third color. This is the deepest lesson from the prototype.progressLinearwhen patterns differ across a transition.LumosRenderConfigKDoc but not enforced by the builder.Objective
Add
VoxelFrameBuilderas a stateful builder in:phosphor-lumosthat converts(VoxelSphere, SceneSnapshot, LumosRenderConfig)into aVoxelFrameper tick. Encode all pattern evaluation, color computation, bipolar geometry, breath pulse, surface bump, and transition crossfade logic from the Lumos prototype. Maintain phase-accumulator continuity across builder invocations.Expected outcomes
A new file
phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/LumosRenderConfig.kt:A new file
phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt:The
buildimplementation, in roughly this order:snapshot.atmosphere; if null (atmosphere subsystem disabled), throwIllegalStateExceptionwith guidance: "VoxelFrameBuilder requires SceneConfiguration.enableAtmosphere = true". Optionally readsnapshot.atmosphereTransitionfor crossfade data.atmosphere.resolution != voxelSphere.resolution, callvoxelSphere = voxelSphere.rebuild(atmosphere.resolution). This is the only allocation path; in steady state it's a no-op.pulsePhase += dt * atmosphere.pulseFrequency * 2 * PI.patternPhase += dt * atmosphere.patternSpeed.orbRotationX += dt * atmosphere.rotationX.orbRotationY += dt * atmosphere.rotationY. Wrap each via modulo to keep numerical precision good over long runs.pulse = 1 + sin(pulsePhase) * atmosphere.pulseAmplitude. This is the per-voxel radial scale modulation.voxelSphere.voxels, compute the cell:voxel.normalizedPos, apply jitter (voxel.jitter * atmosphere.noise * 2.4f), apply ySquash (multiply y byatmosphere.ySquash), apply surface bump (bump = atmosphere.surfaceBump * sin(voxel.theta*3 + voxel.phi*2.5 + patternPhase * 1.4)), apply breath pulse uniformly.mixfromatmosphere.pattern(andatmosphereTransition.from.patternif active, with crossfade byprogressLinear). See pattern formulas section below.atmosphere.bipolarStrength > 0(or transitioning to/from a bipolar atmosphere), compute geometric shrink. The shrink follows a smoothstep falloff: full scale atmix < 0.5 - bipolarStrength*0.4, zero scale atmix == 0.5, full scale atmix > 0.5 + bipolarStrength*0.4.NeutralColor.lerpOklab(sourceColor, targetColor, transition.progressLinear). See color algorithm section below.voxelGap * pulse * boundaryShrink * glyphCarve(glyphCarve is 1.0 when no glyph active, modulated by PHO-18's glyph logic when populated).(primaryHue, secondaryHue)at the atmosphere's saturation/lightness for the glow color. Glow intensity =atmosphere.glow. Rotation = currentorbRotationX/orbRotationY(orbRotationZ = 0 for now).VoxelFrame(tick = snapshot.frameIndex, timestampEpochMillis = millisFromElapsed(snapshot.elapsedTimeSeconds), resolution = voxelSphere.resolution, cells, ambient, glyph = null). Filter cells wherescale < config.omitBelowScaleif non-zero.Pattern formulas
Encode in a private
evaluatePattern(pattern: AtmospherePattern, voxel: Voxel, patternPhase: Float): Floatfunction returning a mix value in [0, 1]:(sin(voxel.theta * 2 + patternPhase * 2) + 1) * 0.5(sin(voxel.phi * 2.5 + patternPhase * 2) + 1) * 0.5(sin(voxel.theta * 2 + voxel.phi * 3 + patternPhase * 2.5) + 1) * 0.5(sin(voxel.normalizedPos.y * 0.85 - patternPhase * 3) + 1) * 0.5(sin(voxel.normalizedPos.x * 0.55 + patternPhase * 1.7) + sin(voxel.normalizedPos.y * 0.50 + patternPhase * 1.2) + sin(voxel.normalizedPos.z * 0.65 + patternPhase * 2.0) + 3) / 6(sin(voxel.distance * 0.85 - patternPhase * 3) + 1) * 0.50.5(constant)When a transition is active and
from.pattern != to.pattern: computemixFrom = evaluatePattern(from.pattern, voxel, patternPhase)andmixTo = evaluatePattern(to.pattern, voxel, patternPhase)andmix = mixFrom * (1 - progressLinear) + mixTo * progressLinear.Color algorithm
Encode in a private
computeVoxelColor(...)function that returns an(r, g, b)triple in 0..1 sRGB:band = 0.4 * bipolarStrength(half-width of the soft transition zone around mix=0.5)mix < 0.5 - band: use primary hue color (hslToSrgb(primaryHue, saturation, lightness))mix > 0.5 + band: use secondary hue colorhue = lerpHueShortest(primaryHue, secondaryHue, mix)hslToSrgb(hue, saturation, lightness)from.bipolarStrength != to.bipolarStrength, compute both source and target colors per voxel andNeutralColor.lerpOklab(sourceColor, targetColor, progressLinear)between them. Otherwise the snapshot's interpolated atmosphere state is already correct and a single-state color computation suffices.Unit tests in
phosphor-lumos/src/commonTest/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilderTest.ktVoxelFramewithcells.size == VoxelSphere(N).count.build()calls with identical snapshots (no transition, dt=0) produce frames whose cells are not identical because phases advance — but withdt=0, phases are unchanged and frames are bit-identical.dt=0.016fshow phase accumulation:pulsePhaseandpatternPhaseincrease monotonically; cell positions evolve continuously.bipolarStrength > 0produces cells where voxels near the pattern's mix=0.5 boundary havescale < 0.1(geometric thinning is active).atmosphereTransitionbetween IDLE and UNCERTAIN produces colors whose RGB values are intermediate between the two states (verify by sampling a known voxel at progress=0.5).build()with frequency=0.3 for N ticks, then change atmosphere to one with frequency=0.6, then callbuild()again —pulsePhaseshould be continuous (no jump), even though the rate it advances per tick has doubled.IllegalStateExceptionthrown ifsnapshot.atmosphere == null.Technical constraints
kotlin.math.PI,kotlin.math.sin, etc. — no platform-specific math libraries.NeutralColor.lerpOklab(...)for color crossfades (final API per Wave 0 recon A1: companion function onNeutralColor).NeutralColor.fromHsl(hue, saturation, lightness)if it exists in core; if not, implement locally inphosphor-lumosand don't add to core.SceneSnapshot.atmosphereTransitionfield name and shape against the current code before starting — Add AtmosphereChoreographer for transitions between atmosphere states #24's final API may have used a slightly different field name. The Wave 0 recon documented planned fields but Add AtmosphereChoreographer for transitions between atmosphere states #24 finalized them.Vector3operations available — the builder needs at minimum addition, scaling, and component access (x,y,z).phase = phase % (2 * PI)after each update to maintain numerical precision over long runs.build()is not thread-safe. The expected usage is one builder per renderer, called from the render loop../gradlew :phosphor-lumos:jvmTestand./gradlew ktlintFormatbefore completion. iOS compile gate (CI-enforced).API surface verification before starting
cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.ktand confirm the exact name and type of the atmosphere-transition field Add AtmosphereChoreographer for transitions between atmosphere states #24 landed (it may beatmosphereTransition: AtmosphereTransition?or named differently).cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AtmosphereTransition.ktand confirm field names match the recon (specificallyprogressLinear,progressEased,from,to).cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AtmosphereChoreographer.ktand confirm phase accumulator field names. The builder's accumulators may want to defer to the choreographer's accumulators during transitions rather than maintain its own — verify this is reasonable before duplicating state.cat phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.ktand confirm thefromHslmethod exists. If it doesn't, add it locally in:phosphor-lumos.If any of these diverge from this ticket's assumptions, note the divergence in the PR description and adapt the implementation accordingly. The high-level algorithm above is unchanged regardless of specific field naming.
Out of scope
glyphfield exists from Add LumosRenderer interface and VoxelFrame data class #30 and will be populated by PHO-18 via a method on the builder).LumosRenderer<T>implementation that renders the frame to a target surface.omitBelowScalefilter. Profile after Socket integration; the algorithm is correct first, fast second.Verification
./gradlew :phosphor-lumos:jvmTestpasses all new tests../gradlew :phosphor-lumos:allTestspasses on all KMP targets (subject to the pre-existing JS test failure tracked in PHO-19).Runtime.totalMemory()sampling.builder.pulsePhaseafter multiplebuild()calls).