Skip to content

Add VoxelFrameBuilder — pattern, color, and geometry pipeline #32

@wow-miley

Description

@wow-miley

Context

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.

What the builder consumes

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Pattern crossfade computes mix for both source and target patterns and lerps the mix value linearly via progressLinear when patterns differ across a transition.
  6. 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 class LumosRenderConfig(
    /** 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). */
    val globalYSquashOverride: 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. */
    val enableGlyphCarving: 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). */
    val omitBelowScale: Float = 0.0f,
)

A new file phosphor-lumos/src/commonMain/kotlin/link/socket/phosphor/lumos/VoxelFrameBuilder.kt:

class VoxelFrameBuilder(
    initialResolution: Int,
    private val config: LumosRenderConfig = LumosRenderConfig(),
) {
    /** Continuous phase accumulators preserved across atmosphere changes
     *  so frequency lerps don't produce phase discontinuities. */
    var pulsePhase: Float = 0f
        private set
    var patternPhase: Float = 0f
        private 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 = 0f
        private set
    var orbRotationY: Float = 0f
        private 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. */
    fun build(snapshot: SceneSnapshot, dt: Float): VoxelFrame
}

The build implementation, in roughly this order:

  1. 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.
  2. 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.
  3. 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.
  4. Compute breath pulse multiplier. pulse = 1 + sin(pulsePhase) * atmosphere.pulseAmplitude. This is the per-voxel radial scale modulation.
  5. For each voxel in voxelSphere.voxels, compute the cell:
    • Position: start from voxel.normalizedPos, apply jitter (voxel.jitter * atmosphere.noise * 2.4f), apply ySquash (multiply y by atmosphere.ySquash), apply surface bump (bump = atmosphere.surfaceBump * sin(voxel.theta*3 + voxel.phi*2.5 + patternPhase * 1.4)), apply breath pulse uniformly.
    • 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).
  6. 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).
  7. Emit frame. Construct VoxelFrame(tick = snapshot.frameIndex, timestampEpochMillis = millisFromElapsed(snapshot.elapsedTimeSeconds), resolution = voxelSphere.resolution, cells, ambient, glyph = null). Filter cells where scale < config.omitBelowScale if non-zero.

Pattern formulas

Encode in a private evaluatePattern(pattern: AtmospherePattern, voxel: Voxel, patternPhase: Float): Float function returning a mix value in [0, 1]:

Pattern Formula
LONGITUDE (sin(voxel.theta * 2 + patternPhase * 2) + 1) * 0.5
LATITUDE (sin(voxel.phi * 2.5 + patternPhase * 2) + 1) * 0.5
SPIRAL (sin(voxel.theta * 2 + voxel.phi * 3 + patternPhase * 2.5) + 1) * 0.5
SCAN (sin(voxel.normalizedPos.y * 0.85 - patternPhase * 3) + 1) * 0.5
PLASMA (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
PULSE (sin(voxel.distance * 0.85 - patternPhase * 3) + 1) * 0.5
SOLID 0.5 (constant)

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:

  1. 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.
  2. Lerp branch (bipolarStrength == 0):
    • Lerp primaryHue → secondaryHue along the shortest hue-wheel path
    • hue = lerpHueShortest(primaryHue, secondaryHue, mix)
    • Result = hslToSrgb(hue, saturation, lightness)
  3. 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 the actual SceneSnapshot.atmosphereTransition field 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.
  • 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

  1. 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).
  2. 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).
  3. 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.
  4. 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.

Out of scope

  • Glyph carving (PHO-18 — but the frame's glyph field exists from Add LumosRenderer interface and VoxelFrame data class #30 and will be populated by PHO-18 via a method on the builder).
  • 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).

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions