Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
kotlin("multiplatform").apply(false)
kotlin("plugin.serialization").apply(false)
id("org.jlleitschuh.gradle.ktlint").version("12.2.0").apply(false)
id("org.jetbrains.dokka").apply(false)
id("com.vanniktech.maven.publish").apply(false)
Expand Down
3 changes: 3 additions & 0 deletions phosphor-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.dokka")
id("com.vanniktech.maven.publish")
Expand Down Expand Up @@ -51,12 +52,14 @@ kotlin {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package link.socket.phosphor.renderer

import kotlin.math.roundToInt

/**
* Generic color model for Compose-like draw surfaces.
*/
data class ComposeColor(
val red: Int,
val green: Int,
val blue: Int,
val alpha: Float = 1f,
) {
init {
require(red in 0..255) { "red must be 0..255, got $red" }
require(green in 0..255) { "green must be 0..255, got $green" }
require(blue in 0..255) { "blue must be 0..255, got $blue" }
require(alpha in 0f..1f) { "alpha must be 0..1, got $alpha" }
}

fun lighten(factor: Float): ComposeColor {
val clamped = factor.coerceIn(0f, 1f)
return ComposeColor(
red = (red + ((255 - red) * clamped)).roundToInt(),
green = (green + ((255 - green) * clamped)).roundToInt(),
blue = (blue + ((255 - blue) * clamped)).roundToInt(),
alpha = alpha,
)
}
}

data class ComposeGradient(
val start: ComposeColor,
val end: ComposeColor,
)

data class ComposeDrawCommand(
val x: Int,
val y: Int,
val char: Char,
val gradient: ComposeGradient,
val alpha: Float,
val bold: Boolean,
)

data class ComposeRenderFrame(
val width: Int,
val height: Int,
val commands: List<ComposeDrawCommand>,
)

/**
* Converts a simulation frame into draw commands usable by Canvas-based adapters.
*/
class ComposeRenderer(
override val preferredFps: Int = DEFAULT_TARGET_FPS,
private val skipWhitespaceCells: Boolean = true,
) : PhosphorRenderer<ComposeRenderFrame> {
override val target: RenderTarget = RenderTarget.COMPOSE

init {
require(preferredFps > 0) { "preferredFps must be > 0, got $preferredFps" }
}

override fun render(frame: SimulationFrame): ComposeRenderFrame {
val commands = ArrayList<ComposeDrawCommand>(frame.cells.size)

for (row in 0 until frame.height) {
for (col in 0 until frame.width) {
val cell = frame.cellAt(row, col)
if (skipWhitespaceCells && cell.char == ' ') continue

val start = ansi256ToColor(cell.fgColor)
val end = start.lighten(if (cell.bold) 0.25f else 0.12f)
val alpha = (cell.luminance ?: if (cell.char == ' ') 0f else 1f).coerceIn(0f, 1f)

commands +=
ComposeDrawCommand(
x = col,
y = row,
char = cell.char,
gradient = ComposeGradient(start = start, end = end),
alpha = alpha,
bold = cell.bold,
)
}
}

return ComposeRenderFrame(
width = frame.width,
height = frame.height,
commands = commands,
)
}

companion object {
const val DEFAULT_TARGET_FPS: Int = 60

private val ANSI_BASE_COLORS =
arrayOf(
ComposeColor(0, 0, 0),
ComposeColor(128, 0, 0),
ComposeColor(0, 128, 0),
ComposeColor(128, 128, 0),
ComposeColor(0, 0, 128),
ComposeColor(128, 0, 128),
ComposeColor(0, 128, 128),
ComposeColor(192, 192, 192),
ComposeColor(128, 128, 128),
ComposeColor(255, 0, 0),
ComposeColor(0, 255, 0),
ComposeColor(255, 255, 0),
ComposeColor(0, 0, 255),
ComposeColor(255, 0, 255),
ComposeColor(0, 255, 255),
ComposeColor(255, 255, 255),
)

private val ANSI_COLOR_CUBE_LEVELS = intArrayOf(0, 95, 135, 175, 215, 255)

internal fun ansi256ToColor(index: Int): ComposeColor {
val normalized = index.coerceIn(0, 255)

if (normalized < ANSI_BASE_COLORS.size) {
return ANSI_BASE_COLORS[normalized]
}

if (normalized in 16..231) {
val cubeIndex = normalized - 16
val r = ANSI_COLOR_CUBE_LEVELS[cubeIndex / 36]
val g = ANSI_COLOR_CUBE_LEVELS[(cubeIndex % 36) / 6]
val b = ANSI_COLOR_CUBE_LEVELS[cubeIndex % 6]
return ComposeColor(r, g, b)
}

val gray = 8 + ((normalized - 232) * 10)
return ComposeColor(gray, gray, gray)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package link.socket.phosphor.renderer

/**
* Platform renderer contract for a single simulation frame.
*/
interface PhosphorRenderer<out T> {
val target: RenderTarget
val preferredFps: Int

fun render(frame: SimulationFrame): T
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package link.socket.phosphor.renderer

/**
* Output surface families supported by the renderer registry.
*/
enum class RenderTarget {
TERMINAL,
COMPOSE,
WEBGL,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package link.socket.phosphor.renderer

/**
* Registry for renderer instances with hot-swap support.
*/
class RendererRegistry {
private val renderers = linkedMapOf<RenderTarget, PhosphorRenderer<*>>()
private var currentTarget: RenderTarget? = null

fun register(
renderer: PhosphorRenderer<*>,
activate: Boolean = currentTarget == null,
): RendererRegistry {
renderers[renderer.target] = renderer

if (activate || currentTarget == null) {
currentTarget = renderer.target
}

return this
}

fun unregister(target: RenderTarget): PhosphorRenderer<*>? {
val removed = renderers.remove(target)

if (removed != null && currentTarget == target) {
currentTarget = renderers.keys.firstOrNull()
}

return removed
}

fun clear() {
renderers.clear()
currentTarget = null
}

fun activate(target: RenderTarget) {
require(renderers.containsKey(target)) {
"No renderer registered for target $target"
}
currentTarget = target
}

fun isRegistered(target: RenderTarget): Boolean = renderers.containsKey(target)

fun activeTarget(): RenderTarget? = currentTarget

fun activeRenderer(): PhosphorRenderer<*>? = currentTarget?.let { target -> renderers[target] }

fun availableTargets(): Set<RenderTarget> = renderers.keys.toSet()

@Suppress("UNCHECKED_CAST")
fun <T> render(frame: SimulationFrame): T {
val target = currentTarget ?: error("No active renderer target is set")
val renderer = renderers[target] ?: error("No renderer registered for target $target")
return (renderer as PhosphorRenderer<T>).render(frame)
}

@Suppress("UNCHECKED_CAST")
fun <T> render(
target: RenderTarget,
frame: SimulationFrame,
): T {
val renderer = renderers[target] ?: error("No renderer registered for target $target")
return (renderer as PhosphorRenderer<T>).render(frame)
}

@Suppress("UNCHECKED_CAST")
fun renderAll(frame: SimulationFrame): Map<RenderTarget, Any> =
renderers.mapValues { (_, renderer) ->
(renderer as PhosphorRenderer<Any>).render(frame)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package link.socket.phosphor.renderer

import kotlinx.serialization.Serializable
import link.socket.phosphor.render.AsciiCell
import link.socket.phosphor.render.CellBuffer

/**
* Serializable snapshot for one render tick.
*/
@Serializable
data class SimulationFrame(
val tick: Long,
val timestampEpochMillis: Long,
val width: Int,
val height: Int,
val cells: List<FrameCell>,
val metadata: Map<String, Float> = emptyMap(),
) {
init {
require(width >= 0) { "width must be >= 0, got $width" }
require(height >= 0) { "height must be >= 0, got $height" }
require(cells.size == width * height) {
"cells size (${cells.size}) must equal width * height (${width * height})"
}
}

fun cellAt(
row: Int,
col: Int,
): FrameCell {
require(row in 0 until height) { "row out of bounds: $row" }
require(col in 0 until width) { "col out of bounds: $col" }
return cells[(row * width) + col]
}

fun toCellBuffer(): CellBuffer {
val buffer = CellBuffer(width, height)

for (row in 0 until height) {
for (col in 0 until width) {
buffer[row, col] = cellAt(row, col).toAsciiCell()
}
}

return buffer
}

companion object {
fun fromCellBuffer(
tick: Long,
timestampEpochMillis: Long,
buffer: CellBuffer,
metadata: Map<String, Float> = emptyMap(),
luminance: FloatArray? = null,
normalX: FloatArray? = null,
normalY: FloatArray? = null,
): SimulationFrame {
val expectedSize = buffer.width * buffer.height
validateComponentSize("luminance", expectedSize, luminance)
validateComponentSize("normalX", expectedSize, normalX)
validateComponentSize("normalY", expectedSize, normalY)

val cells = ArrayList<FrameCell>(expectedSize)

for (row in 0 until buffer.height) {
for (col in 0 until buffer.width) {
val index = (row * buffer.width) + col
val cell = buffer[row, col]
cells +=
FrameCell(
char = cell.char,
fgColor = cell.fgColor,
bgColor = cell.bgColor,
bold = cell.bold,
luminance = luminance?.get(index),
normalX = normalX?.get(index),
normalY = normalY?.get(index),
)
}
}

return SimulationFrame(
tick = tick,
timestampEpochMillis = timestampEpochMillis,
width = buffer.width,
height = buffer.height,
cells = cells,
metadata = metadata,
)
}

private fun validateComponentSize(
componentName: String,
expectedSize: Int,
values: FloatArray?,
) {
if (values == null) return
require(values.size == expectedSize) {
"$componentName size (${values.size}) must equal width * height ($expectedSize)"
}
}
}
}

@Serializable
data class FrameCell(
val char: Char = ' ',
val fgColor: Int = 7,
val bgColor: Int? = null,
val bold: Boolean = false,
val luminance: Float? = null,
val normalX: Float? = null,
val normalY: Float? = null,
) {
fun toAsciiCell(): AsciiCell =
AsciiCell(
char = char,
fgColor = fgColor,
bgColor = bgColor,
bold = bold,
)
}
Loading