Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created September 9, 2026 15:07
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/7c872b0a85167f0483ad8d8299da6612 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/7c872b0a85167f0483ad8d8299da6612 to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.graphics.Bitmap
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/*
* A rusted, half-painted steel tear plate: the industrial floor you have walked on
* in every car park and every fire escape.
*
* Nothing here is a traced bitmap. The plate is a pure function of position, and
* every constant in it was measured off one photograph rather than tuned by eye.
* The lattice came out of the image's own autocorrelation (76.35 x 72.51 px, two
* studs per cell, half a cell apart, tilting +-44.6 degrees). The stud came out of
* two hundred studs stacked after being re-aligned to each other. The rust came
* out of the photograph's band energies and tonal quantiles, so the texture is
* generated but its statistics are the photograph's.
*
* What that buys is a plate with no repeat. The reference is 992 x 662 px of
* floor; this is however much floor the screen asks for, at the same density, with
* no tile seam anywhere in it.
*/
private const val PX = 76.35f
private const val PY = 72.51f
private const val INV_PX = 1f / PX
private const val INV_PY = 1f / PY
private const val PHASE_X = 2.83f
private const val PHASE_Y = 18.94f
private val COS_A = cos(Math.toRadians(44.89).toFloat())
private val SIN_A = sin(Math.toRadians(44.89).toFloat())
private val COS_B = cos(Math.toRadians(-44.28).toFloat())
private val SIN_B = sin(Math.toRadians(-44.28).toFloat())
private const val CAP_A = 20.4f
private const val CAP_B = 6f
private const val RIM_A = 23.657f
private const val RIM_B = 7.8881f
private const val CAP_GRAIN = 0.235f
private const val EDGE_AA = 1.5f
private const val INV_EDGE_AA = 1f / EDGE_AA
private const val INV_CAP_A = 1f / CAP_A
private const val INV_CAP_B = 1f / CAP_B
/**
* Radius of the two arcs that make the boss outline, and the offset of their
* centres from the stud's own centre.
*/
private val RIM_R = (RIM_A * RIM_A + RIM_B * RIM_B) / (2f * RIM_B)
private val RIM_CY = RIM_R - RIM_B
private val CAP_R = (CAP_A * CAP_A + CAP_B * CAP_B) / (2f * CAP_B)
private val CAP_CY = CAP_R - CAP_B
private const val LIGHT_X = -0.32048f
private const val LIGHT_Y = -0.65463f
private const val LIGHT_Z = 0.37003f
private val LIGHT_LEN = sqrt(LIGHT_X * LIGHT_X + LIGHT_Y * LIGHT_Y + LIGHT_Z * LIGHT_Z)
private val LIGHT_XY = sqrt(LIGHT_X * LIGHT_X + LIGHT_Y * LIGHT_Y)
private val INV_LIGHT_LEN = 1f / LIGHT_LEN
private val INV_LIGHT_XY = 1f / LIGHT_XY
private const val DOME_H = 6.6818f
private const val DOME_U = DOME_H / (CAP_A * CAP_A)
private const val DOME_V = DOME_H / (CAP_B * CAP_B)
private const val AMB_R = 52.911f
private const val AMB_G = 123.53f
private const val AMB_B = 139.07f
private const val DIF_R = 42.515f
private const val DIF_G = 43.408f
private const val DIF_B = 43.612f
private const val RUST_AMB_R = 70f
private const val RUST_AMB_G = 62f
private const val RUST_AMB_B = 50f
private const val RUST_DIF_R = 76f
private const val RUST_DIF_G = 66f
private const val RUST_DIF_B = 55f
private const val EDGE_W = 2.9922f
private const val INV_EDGE_W = 1f / EDGE_W
private const val EDGE_P = 1.809f
private const val DARK_R = 14.199f
private const val DARK_G = 0.00061179f
private const val DARK_B = 0.00039027f
private const val RUST_DARK_R = 25f
private const val RUST_DARK_G = 14f
private const val RUST_DARK_B = 8f
private const val RIM_LO = 8f
private const val RIM_HI = 170.91f
private const val RIM_P = 1.6226f
private const val CAST_SHADOW = 0.47512f
private const val SPEC_AMP = 96f
private const val SPEC_K = SPEC_AMP / 120f
private const val SPEC_W = 1f
private const val SPEC_R = 153.57f
private const val SPEC_G = 203.95f
private const val SPEC_B = 202.38f
private val STAIN = floatArrayOf(
99.5151f, -4.32356f, -4.51962f, -23.3768f, 10.0501f, -13.0407f, -0.453534f,
3.89637f, -8.5532f, -36.1819f
)
private val OCT_LAMBDA = floatArrayOf(
7.6f, 12.5f, 20.6f, 34f, 56f, 92f, 152f
)
private val OCT_AMP = floatArrayOf(
38.35f, 12.64f, 11.72f, 10.9f, 11.65f, 23.37f, 29.86f
)
private const val COARSE_FROM = 2
private const val FINE_GAIN = 19.844f
private val CHROMA_LAMBDA = floatArrayOf(
2.977f, 7.814f, 18.49f, 64.71f
)
private val CHROMA_AMP = floatArrayOf(
0.9235f, 0.63f, 0.4195f, 0.3413f
)
private const val CHROMA_MID = 0.63673f
private const val CHROMA_K = 1.5737f
private const val PAINT_LAM1 = 34.587f
private const val PAINT_LAM2 = 15.228f
private const val PAINT_W1 = 0.75415f
private const val PAINT_LO = 0.69658f
private const val PAINT_WIDTH = 0.026746f
private const val INV_PAINT_WIDTH = 1f / PAINT_WIDTH
private const val PAINT_NEAR_KEEP = 0.2191f
private const val PAINT_NEAR = 8.8849f
private const val PAINT_FAR = 18.832f
private const val PAINT_TONE = 0.36043f
private const val FLECK_P = 0.00040f
private const val PIT_P = 0.00022f
private const val CLUSTER_LO = 0.25f
private const val CLUSTER_HI = 1.5f
/**
* The loosest threshold either population can ask for, at the clustering field's
* ceiling. A pixel that fails this cannot pass the real test at any clustering, so
* the field itself is only evaluated for the one pixel in a thousand that gets
* that far.
*/
private const val FLECK_GATE = 1f - FLECK_P * (CLUSTER_LO + CLUSTER_HI)
private const val PIT_GATE = 1f - PIT_P * (CLUSTER_LO + CLUSTER_HI)
private const val CHROMA_FINE = 2
private const val FINE_WARP = 1.347f
private const val FINE_CONTRAST = 0.24f
private const val FINE_S1 = 0.4719f
private const val FINE_S2 = 1.1522f
private const val FINE_S3 = 1.0288f
private const val INV_FINE_S1 = 1f / FINE_S1
private const val INV_FINE_S2 = 1f / FINE_S2
private const val INV_FINE_S3 = 1f / FINE_S3
private const val FINE_CONTRAST_K = FINE_CONTRAST / 0.29f
private const val FINE_GAIN_S3 = FINE_GAIN / FINE_S3
/**
* |g| raised to the warp exponent, tabulated. The warp runs on every pixel of the
* plate, and Math.pow there costs more than every octave of noise put together.
* The field is normalised to unit sd, so eight sigma covers the whole domain.
*/
private val WARP_LUT = FloatArray(1025) { i ->
Math.pow((i * 8f / 1024f).toDouble(), FINE_WARP.toDouble()).toFloat()
}
private fun warp(g: Float): Float {
val a = if (g < 0f) -g else g
val x = a * 128f
val i = x.toInt()
if (i >= 1024) return if (g < 0f) -WARP_LUT[1024] else WARP_LUT[1024]
val w = WARP_LUT[i] + (WARP_LUT[i + 1] - WARP_LUT[i]) * (x - i)
return if (g < 0f) -w else w
}
/** exp for |x| < 0.5, which is the only range the contrast term ever reaches. */
private fun expSmall(x: Float): Float = 1f + x * (1f + x * (0.5f + x * 0.1666667f))
/**
* Four more tabulated curves. exp and pow measure 135 and 163 ns a call on the
* test device, an order of magnitude past a table read, and between them they run
* on every pixel anywhere near a stud, which is most of the plate. The first two
* are indexed by the SQUARED distance, so a pixel off the boss never pays for a
* square root either.
*/
private val SHADOW_Q0 = RIM_R * RIM_R
private val SHADOW_Q1 = (RIM_R + 12f) * (RIM_R + 12f)
private val INV_SHADOW_SPAN = 1f / (SHADOW_Q1 - SHADOW_Q0)
private val SHADOW_LUT = FloatArray(257) {
val d = sqrt(SHADOW_Q0 + (SHADOW_Q1 - SHADOW_Q0) * it / 256f) - RIM_R
exp(-(if (d > 0f) d else 0f) / 2.5f)
}
private val GATE_Q0 = (RIM_R + PAINT_NEAR) * (RIM_R + PAINT_NEAR)
private val GATE_Q1 = (RIM_R + PAINT_FAR) * (RIM_R + PAINT_FAR)
private val INV_GATE_SPAN = 1f / (GATE_Q1 - GATE_Q0)
private val GATE_LUT = FloatArray(257) {
val d = sqrt(GATE_Q0 + (GATE_Q1 - GATE_Q0) * it / 256f) - RIM_R
clamp01((d - PAINT_NEAR) / (PAINT_FAR - PAINT_NEAR))
}
private val BOSS_Q = (RIM_R + EDGE_AA * 0.5f) * (RIM_R + EDGE_AA * 0.5f)
private val RIM_LUT = FloatArray(257) { Math.pow(it / 256.0, RIM_P.toDouble()).toFloat() }
private val EDGE_LUT = FloatArray(257) { Math.pow(it / 256.0, EDGE_P.toDouble()).toFloat() }
private val SPEC_LUT = FloatArray(257) {
val d = it * 8f / 256f
exp(-(d / SPEC_W) * (d / SPEC_W))
}
/** Reads a 257-entry table over 0..1 with linear interpolation. */
private fun lut01(t: FloatArray, x: Float): Float {
val p = clamp01(x) * 256f
val i = p.toInt()
if (i >= 256) return t[256]
return t[i] + (t[i + 1] - t[i]) * (p - i)
}
private const val LUT_N = 1024
private const val T_LO = -69.5467f
private const val T_HI = 237.646f
private const val LUT_K = (LUT_N - 1f) / (T_HI - T_LO)
/**
* The tone quantiles the three colour ramps are indexed by. They are non-uniform,
* dense where the plate spends its time, so the ramps are resampled onto a uniform
* lookup once at load rather than binary-searched per pixel.
*/
private val TONE_Q = floatArrayOf(
-69.5467f, 15.7951f, 25.4389f, 31.4504f, 36.0867f, 39.725f, 42.9351f, 45.6747f,
48.1273f, 50.36f, 52.4905f, 54.4382f, 56.2221f, 57.9629f, 59.5674f, 61.1434f,
62.6654f, 64.1216f, 65.5534f, 66.9279f, 68.2747f, 69.5971f, 70.8873f, 72.1526f,
73.4146f, 74.6483f, 75.8554f, 77.0348f, 78.2107f, 79.3847f, 80.5766f, 81.7359f,
82.9096f, 84.0611f, 85.2143f, 86.4067f, 87.5652f, 88.731f, 89.9261f, 91.0977f,
92.2644f, 93.4915f, 94.7503f, 96.019f, 97.3387f, 98.6748f, 100.046f, 101.424f,
102.884f, 104.344f, 105.881f, 107.463f, 109.177f, 110.948f, 112.827f, 114.858f,
117.041f, 119.397f, 122.047f, 125.036f, 128.51f, 132.953f, 138.722f, 147.899f,
237.646f
)
private val PAINT_TONE_MID = TONE_Q[32]
private val PAINT_TONE_K = PAINT_TONE / (TONE_Q[56] - TONE_Q[8])
/**
* Three ramps, all 65 stops against [TONE_Q]. Each takes its luminance from the
* photograph's quantile at that stop and its hue from the mean colour there. Mean
* RGB alone averages the ends away (its top is 168 where the plate reaches 186),
* and per-channel quantiles alone bleach them, because they put the maximum red
* and the maximum blue on the same pixel and the photograph never does.
*/
private val RAMP_SATURATED = intArrayOf(
0x000000, 0x220400, 0x2E0800, 0x370C01, 0x3F1002, 0x451303, 0x4B1604, 0x501905,
0x551C07, 0x5A1F08, 0x5F2209, 0x64250B, 0x68270C, 0x6C2A0D, 0x6F2C0F, 0x722E10,
0x753011, 0x773212, 0x7A3413, 0x7C3614, 0x7E3716, 0x803916, 0x833A17, 0x843C18,
0x863D19, 0x873F1A, 0x89401B, 0x8A411C, 0x8B431D, 0x8D441D, 0x8E451E, 0x8F471F,
0x904820, 0x914921, 0x924A22, 0x934C23, 0x954D23, 0x964E24, 0x974F25, 0x985126,
0x995227, 0x9A5328, 0x9B5428, 0x9C562A, 0x9E572B, 0x9F582C, 0x9F5A2D, 0xA05B2E,
0xA25C2F, 0xA35E30, 0xA45F31, 0xA66132, 0xA76333, 0xA86435, 0xA96637, 0xAB6838,
0xAD6A3A, 0xAE6D3C, 0xB06F3F, 0xB27242, 0xB57645, 0xB97A49, 0xBE8150, 0xC78C5C,
0xFFE5A2
)
private val RAMP_DULL = intArrayOf(
0x000000, 0x140704, 0x1D0D07, 0x24110A, 0x29150C, 0x2E180E, 0x331C10, 0x381F12,
0x3C2314, 0x402616, 0x442918, 0x482C1A, 0x4B2E1C, 0x4E311D, 0x51331F, 0x543521,
0x563722, 0x583924, 0x5B3B25, 0x5C3D27, 0x5E3F28, 0x604029, 0x61422A, 0x63442C,
0x65452C, 0x66462E, 0x68482F, 0x694930, 0x6A4A31, 0x6B4C32, 0x6C4D34, 0x6E4E34,
0x6F5036, 0x705137, 0x715238, 0x725339, 0x73553A, 0x74563B, 0x75573C, 0x76583D,
0x775A3E, 0x785B3F, 0x795C41, 0x7B5D41, 0x7C5F42, 0x7C6044, 0x7D6145, 0x7E6346,
0x806448, 0x816549, 0x82674A, 0x83694C, 0x846A4D, 0x866C4F, 0x876E50, 0x897052,
0x8A7254, 0x8C7456, 0x8E7759, 0x91795C, 0x947D5F, 0x978264, 0x9C886A, 0xA79476,
0xFFEFC7
)
private val RAMP_PAINT = intArrayOf(
0x000000, 0x040B0C, 0x081212, 0x0B1718, 0x0D1C1D, 0x0F2021, 0x122425, 0x142829,
0x152C2D, 0x172F32, 0x183336, 0x1A3639, 0x1B393D, 0x1D3C40, 0x1F3E42, 0x224043,
0x254344, 0x244548, 0x25474A, 0x27494C, 0x2A4B4C, 0x294D4F, 0x2B4E50, 0x2E5050,
0x2D5254, 0x2E5355, 0x2F5557, 0x2F5659, 0x30585A, 0x33595A, 0x345A5C, 0x355B5D,
0x375C5E, 0x375E5F, 0x395F60, 0x3B6060, 0x3C6162, 0x3C6364, 0x3E6364, 0x3F6565,
0x416666, 0x426767, 0x426869, 0x44696A, 0x446B6C, 0x446D6D, 0x476D6D, 0x4A6E6D,
0x4B706F, 0x4B7171, 0x4D7372, 0x507473, 0x517674, 0x527776, 0x547978, 0x567B7A,
0x597D7C, 0x5B7F7E, 0x5E8180, 0x628383, 0x668786, 0x6B8B8C, 0x739191, 0x819B9C,
0xD5F9FA
)
private val SAT_LUT = buildLut(RAMP_SATURATED)
private val DULL_LUT = buildLut(RAMP_DULL)
private val PAINT_LUT = buildLut(RAMP_PAINT)
/**
* How much paint each stud kept, as 101 quantiles: 6% of them are down to a fifth
* of their cap, 57% still have four fifths. Drawing every stud as the average is
* exactly what makes a procedural plate read as wallpaper.
*/
private val SURVIVAL = floatArrayOf(
0.000f, 0.047f, 0.060f, 0.085f, 0.119f, 0.169f, 0.195f, 0.227f, 0.247f, 0.268f,
0.300f, 0.360f, 0.373f, 0.394f, 0.401f, 0.403f, 0.425f, 0.454f, 0.491f, 0.519f,
0.530f, 0.534f, 0.541f, 0.560f, 0.578f, 0.610f, 0.642f, 0.656f, 0.674f, 0.686f,
0.710f, 0.725f, 0.742f, 0.748f, 0.756f, 0.763f, 0.770f, 0.771f, 0.774f, 0.785f,
0.787f, 0.798f, 0.800f, 0.800f, 0.807f, 0.809f, 0.809f, 0.812f, 0.818f, 0.822f,
0.829f, 0.836f, 0.848f, 0.858f, 0.858f, 0.859f, 0.863f, 0.864f, 0.868f, 0.870f,
0.876f, 0.879f, 0.886f, 0.890f, 0.896f, 0.896f, 0.899f, 0.903f, 0.907f, 0.907f,
0.910f, 0.912f, 0.917f, 0.926f, 0.930f, 0.932f, 0.936f, 0.937f, 0.944f, 0.945f,
0.947f, 0.952f, 0.956f, 0.959f, 0.961f, 0.965f, 0.965f, 0.971f, 0.975f, 0.980f,
0.984f, 0.986f, 0.986f, 0.988f, 0.991f, 0.993f, 1.000f, 1.000f, 1.000f, 1.000f,
1.000f
)
/**
* The reference frame is 992 px across; matching that keeps the plate at the
* density it was photographed at whatever the screen is.
*/
private const val ART_W = 992f
/**
* The row and seed half of [hash2]. Int overflow wraps, which is the arithmetic
* this wants, and it is also what makes the split exact: the three terms are added
* in a ring, so folding two of them early cannot change the result.
*/
private fun hashRow(iy: Int, seed: Int) = iy * 668265263 + seed * 1442695041
/**
* [hash2] with the row already folded in. A scanline holds iy and the seed fixed
* for its whole length, so two of the hash's three multiplies belong to the row
* rather than to the pixel.
*/
private fun hashAt(ix: Int, row: Int): Float {
var h = ix * 374761393 + row
h = (h xor (h ushr 13)) * 1274126177
h = h xor (h ushr 16)
return ((h ushr 8) and 0xFFFF) * (1f / 65535f)
}
/** A 32-bit integer hash of a lattice site. */
private fun hash2(ix: Int, iy: Int, seed: Int) = hashAt(ix, hashRow(iy, seed))
/**
* Bilinear value noise on a unit lattice, walked along a scanline.
*
* A bake samples about eight octaves a pixel, and four fifths of the lattice
* corners they ask for belong to a cell the previous pixel already visited: an
* octave with a 12 px wavelength changes cell once in thirteen. Holding the row's
* four corner hashes and shifting them when the cell does advance turns thirty-two
* hashes a pixel into nine, and that is the difference between a bake you wait for
* and one you do not.
*
* Art coordinates are never negative here, so toInt() is floor().
*/
private class Octave(lambda: Float, private val seed: Int) {
private val invLambda = 1f / lambda
private var row0 = 0
private var row1 = 0
private var sy = 0f
private var ix = Int.MIN_VALUE
private var h00 = 0f
private var h10 = 0f
private var h01 = 0f
private var h11 = 0f
fun row(ay: Float) {
val yy = ay * invLambda
val iy = yy.toInt()
val t = yy - iy
sy = t * t * (3f - 2f * t)
row0 = hashRow(iy, seed)
row1 = hashRow(iy + 1, seed)
ix = Int.MIN_VALUE
}
fun at(ax: Float): Float {
val xx = ax * invLambda
val cx = xx.toInt()
if (cx != ix) {
if (cx == ix + 1) {
h00 = h10; h01 = h11
h10 = hashAt(cx + 1, row0); h11 = hashAt(cx + 1, row1)
} else {
h00 = hashAt(cx, row0); h10 = hashAt(cx + 1, row0)
h01 = hashAt(cx, row1); h11 = hashAt(cx + 1, row1)
}
ix = cx
}
val t = xx - cx
val sx = t * t * (3f - 2f * t)
val top = h00 + (h10 - h00) * sx
return top + (h01 + (h11 - h01) * sx - top) * sy
}
}
/**
* How much of a stud's paint has blistered off at this pixel. Two octaves, walked
* along the row like every other one, and read at most twice a pixel because both
* stud families share the field.
*/
private class Blot(seed: Int) {
private val fine = Octave(7f, seed + 9001)
private val coarse = Octave(17f, seed + 9203)
fun row(ay: Float) {
fine.row(ay)
coarse.row(ay)
}
fun at(ax: Float): Float =
0.5f + 1.9f * ((fine.at(ax) * 0.72f + coarse.at(ax) * 0.28f) - 0.5f)
}
/**
* Marquise field: negative inside, in pixels, so it doubles as antialiasing
* coverage. [out] receives the outward normal, which is what the lighting needs.
* The bevel and the white edge are both functions of which way the outline faces,
* not of where the pixel sits.
*
* The shape is max() of two circles, and for cy > 0 the sign of v alone decides
* which arc is nearer. Choosing the arc first is one square root instead of two,
* and a square root here costs 139 ns, more than an octave of noise, so the branch
* pays for itself many times over.
*/
private fun lens(u: Float, v: Float, r: Float, cy: Float, out: FloatArray): Float {
val vv = if (v >= 0f) v + cy else v - cy
val len = sqrt(u * u + vv * vv)
val inv = if (len > 1e-6f) 1f / len else 0f
out[0] = u * inv
out[1] = vv * inv
return len - r
}
/**
* The squared distance to the active arc centre, the same field before the square
* root. Everything a pixel off the boss needs (the cast shadow, the paint gate) is
* a monotone function of it, so those are tabulated against it and only the one
* pixel in eight that actually lands on a stud pays for the root.
*/
private fun lensQ(u: Float, v: Float, cy: Float): Float {
val vv = if (v >= 0f) v + cy else v - cy
return u * u + vv * vv
}
/**
* Toward the bottom edge the paint does not merely darken, it stops existing. A
* brightness multiply cannot fake that, so coverage carries it.
*/
private fun tealSurvival(ny: Float): Float = when {
ny < 0.635f -> 1f
ny < 0.816f -> 1f - 0.28f * (ny - 0.635f) / 0.181f
ny < 0.907f -> 0.72f - 0.28f * (ny - 0.816f) / 0.091f
ny < 0.975f -> 0.44f * (1f - (ny - 0.907f) / 0.068f)
else -> 0f
}
private fun clamp01(v: Float) = if (v < 0f) 0f else if (v > 1f) 1f else v
/**
* Resample a 65-stop ramp onto a uniform lookup over the tone range, so the
* per-pixel cost is an index and a lerp instead of a search through [TONE_Q].
*/
private fun buildLut(ramp: IntArray): FloatArray {
val lut = FloatArray(LUT_N * 3)
var stop = 0
for (i in 0 until LUT_N) {
val t = T_LO + (T_HI - T_LO) * i / (LUT_N - 1f)
while (stop < TONE_Q.size - 2 && TONE_Q[stop + 1] < t) stop++
val span = TONE_Q[stop + 1] - TONE_Q[stop]
val f = clamp01(if (span > 1e-4f) (t - TONE_Q[stop]) / span else 0f)
val c0 = ramp[stop]
val c1 = ramp[stop + 1]
lut[i * 3] = ((c0 shr 16) and 0xFF) + (((c1 shr 16) and 0xFF) - ((c0 shr 16) and 0xFF)) * f
lut[i * 3 + 1] = ((c0 shr 8) and 0xFF) + (((c1 shr 8) and 0xFF) - ((c0 shr 8) and 0xFF)) * f
lut[i * 3 + 2] = (c0 and 0xFF) + ((c1 and 0xFF) - (c0 and 0xFF)) * f
}
return lut
}
private const val COARSE = 4
private const val INV_COARSE = 1f / COARSE
/**
* Everything slower than about 12 px of wavelength, evaluated on a quarter
* resolution grid and read back bilinearly. Nine octaves of value noise per pixel
* is most of the bake's cost and none of its detail: at this scale a quarter-res
* grid is indistinguishable, and it is sixteen times less work.
*
* The three fields share a grid, so a pixel resolves one cell index and one pair
* of weights and spends them three times.
*/
private class SlowFields(val w: Int, val h: Int) {
val tone = FloatArray(w * h)
val chroma = FloatArray(w * h)
val paint = FloatArray(w * h)
fun sample(field: FloatArray, i: Int, fx: Float, fy: Float): Float {
val top = field[i] + (field[i + 1] - field[i]) * fx
val bot = field[i + w] + (field[i + w + 1] - field[i + w]) * fx
return top + (bot - top) * fy
}
}
/**
* Only octaves longer than about 15 art px go on the coarse grid. Its step is 3.7
* art px, so anything finer would be sampled below Nyquist, and the chroma field's
* shortest octave, at 2.96 px, carries its largest amplitude. Putting that one on
* the grid would quietly throw the plate's chroma variation away.
*/
private suspend fun buildSlow(w: Int, h: Int, scale: Float, seed: Int): SlowFields {
val cw = w / COARSE + 2
val ch = h / COARSE + 2
val s = SlowFields(cw, ch)
val tone = Array(OCT_LAMBDA.size - COARSE_FROM) {
Octave(OCT_LAMBDA[it + COARSE_FROM], seed + 1000 + (it + COARSE_FROM) * 13)
}
val chroma = Array(CHROMA_LAMBDA.size - CHROMA_FINE) {
Octave(CHROMA_LAMBDA[it + CHROMA_FINE], seed + 8101 + (it + CHROMA_FINE) * 211)
}
val paint1 = Octave(PAINT_LAM1, seed + 7001)
val paint2 = Octave(PAINT_LAM2, seed + 7307)
var i = 0
for (cy in 0 until ch) {
coroutineContext.ensureActive()
val ay = cy * COARSE / scale
for (o in tone) o.row(ay)
for (o in chroma) o.row(ay)
paint1.row(ay); paint2.row(ay)
for (cx in 0 until cw) {
val ax = cx * COARSE / scale
var t = 0f
for (k in tone.indices) t += OCT_AMP[k + COARSE_FROM] * (tone[k].at(ax) - 0.5f)
s.tone[i] = t
var c = 0f
for (k in chroma.indices) c += CHROMA_AMP[k + CHROMA_FINE] * (chroma[k].at(ax) - 0.5f)
s.chroma[i] = c
s.paint[i] = PAINT_W1 * paint1.at(ax) + (1f - PAINT_W1) * paint2.at(ax)
i++
}
}
return s
}
/**
* Where the nearest stud of one family is, in that stud's own frame, written into
* [q] as u, v and the lattice site. Returns the squared distance to the arc
* centre, so the caller can reject a pixel without a square root.
*
* Only the rounded lattice site is tested, not its eight neighbours, and that is
* exact rather than an approximation: a stud reaches 23.8 px along its axis and
* the half cell is 38.2 x 36.3, so no other site's lens can cover this pixel.
* Checking all nine would cost eighteen lens evaluations a pixel for nothing.
*
* A scanline sits in one lattice row, so the row index and the offset to it are
* resolved once in [row] and only the column moves per pixel.
*/
private class Lattice(
private val phx: Float, private val phy: Float,
private val ct: Float, private val st: Float,
) {
private var gj = 0
private var dy = 0f
fun row(ay: Float) {
gj = ((ay - phy) * INV_PY).roundToInt()
dy = ay - (gj * PY + phy)
}
fun at(q: FloatArray, ax: Float): Float {
val gi = ((ax - phx) * INV_PX).roundToInt()
val dx = ax - (gi * PX + phx)
q[0] = dx * ct + dy * st
q[1] = -dx * st + dy * ct
q[2] = gi.toFloat()
q[3] = gj.toFloat()
return lensQ(q[0], q[1], RIM_CY)
}
}
/** Lays one stud over the ground: bevel, cap, dark edge, white edge, mottle. */
private fun shadeStud(
rgb: FloatArray, n: FloatArray, q: FloatArray, ax: Float, ct: Float, st: Float,
capSeed: Int, survival: Float, grain: Float, blot: Blot,
) {
val u = q[0]
val v = q[1]
val dRim = lens(u, v, RIM_R, RIM_CY, n)
val mRim = clamp01(0.5f - dRim * INV_EDGE_AA)
if (mRim <= 0f) return
run {
val rL = ((n[0] * ct - n[1] * st) * LIGHT_X + (n[0] * st + n[1] * ct) * LIGHT_Y) * INV_LIGHT_XY
val rim = RIM_LO + (RIM_HI - RIM_LO) * lut01(RIM_LUT, rL)
rgb[0] += (rim * 0.92f - rgb[0]) * mRim
rgb[1] += (rim * 0.95f - rgb[1]) * mRim
rgb[2] += (rim - rgb[2]) * mRim
}
val dCap = lens(u, v, CAP_R, CAP_CY, n)
val mCap = clamp01(0.5f - dCap * INV_EDGE_AA)
if (mCap <= 0f) return
val nL = ((n[0] * ct - n[1] * st) * LIGHT_X + (n[0] * st + n[1] * ct) * LIGHT_Y) * INV_LIGHT_XY
val ua = u * INV_CAP_A
val va = v * INV_CAP_B
val hh = sqrt((1f - ua * ua - va * va).coerceIn(1e-3f, 1f))
val invHh = 1f / hh
val gu = DOME_U * u * invHh
val gv = DOME_V * v * invHh
val sx = gu * ct - gv * st
val sy = gu * st + gv * ct
val diff = clamp01((sx * LIGHT_X + sy * LIGHT_Y + LIGHT_Z) * INV_LIGHT_LEN /
sqrt(sx * sx + sy * sy + 1f))
val keptIdx = (hash2(q[2].toInt(), q[3].toInt(), capSeed) * 100f).toInt()
val kept = SURVIVAL[keptIdx.coerceIn(0, 100)] * survival
val eu = clamp01(abs(ua))
val keep = clamp01((kept - blot.at(ax) - 0.22f * eu * eu) * 11f + 0.5f)
var cr = (RUST_AMB_R + (AMB_R - RUST_AMB_R) * keep) + (RUST_DIF_R + (DIF_R - RUST_DIF_R) * keep) * diff
var cg = (RUST_AMB_G + (AMB_G - RUST_AMB_G) * keep) + (RUST_DIF_G + (DIF_G - RUST_DIF_G) * keep) * diff
var cb = (RUST_AMB_B + (AMB_B - RUST_AMB_B) * keep) + (RUST_DIF_B + (DIF_B - RUST_DIF_B) * keep) * diff
val e = lut01(EDGE_LUT, 1f + dCap * INV_EDGE_W) * clamp01(-nL)
if (e > 0f) {
cr += ((RUST_DARK_R + (DARK_R - RUST_DARK_R) * keep) - cr) * e
cg += ((RUST_DARK_G + (DARK_G - RUST_DARK_G) * keep) - cg) * e
cb += ((RUST_DARK_B + (DARK_B - RUST_DARK_B) * keep) - cb) * e
}
if (nL > 0f) {
val din = if (dCap < 0f) -dCap else 0f
val nl5 = nL * nL * nL * nL * nL
val spec = clamp01(SPEC_K * lut01(SPEC_LUT, din * 0.125f) * nl5) * keep
cr += (SPEC_R - cr) * spec
cg += (SPEC_G - cg) * spec
cb += (SPEC_B - cb) * spec
}
val mot = (1f + CAP_GRAIN * grain).coerceIn(0.12f, 2.4f)
cr *= mot; cg *= mot; cb *= mot
rgb[0] += (cr - rgb[0]) * mCap
rgb[1] += (cg - rgb[1]) * mCap
rgb[2] += (cb - rgb[2]) * mCap
}
/**
* Bakes the plate into a bitmap. Called once per size change, off the UI thread
* and split across cores by row band; after that a frame is one drawBitmap.
*
* The row loop hoists everything that only depends on y: the stain polynomial
* collapses to four coefficients a row and is then evaluated in nx by Horner, and
* the coarse grid's row index and vertical weight are resolved once for all three
* slow fields.
*/
private suspend fun bakePlate(w: Int, h: Int, seed: Int): Bitmap = coroutineScope {
val scale = w / ART_W
val invScale = 1f / scale
val invW2 = 2f / w
val invH = 1f / h
val pixels = IntArray(w * h)
val slow = buildSlow(w, h, scale, seed)
val workers = min(Runtime.getRuntime().availableProcessors(), 8).coerceAtLeast(1)
val band = (h + workers - 1) / workers
(0 until workers).map { worker ->
async(Dispatchers.Default) {
val rgb = FloatArray(3)
val nrm = FloatArray(2)
val qa = FloatArray(4)
val qb = FloatArray(4)
val ground = Array(COARSE_FROM) { Octave(OCT_LAMBDA[it], seed + 1000 + it * 13) }
val fine = Array(4) { Octave(1f * (1 shl it), seed + 3001 + it * 97) }
val contrast = Octave(24f, seed + 3907)
val cluster = Octave(16f, seed + 9601)
val chromaFine = Array(CHROMA_FINE) { Octave(CHROMA_LAMBDA[it], seed + 8101 + it * 211) }
val blot = Blot(seed)
val latA = Lattice(PHASE_X, PHASE_Y, COS_A, SIN_A)
val latB = Lattice(PHASE_X + PX * 0.5f, PHASE_Y + PY * 0.5f, COS_B, SIN_B)
val capSeedA = seed * 7919 + 104729
val capSeedB = seed * 7919 + 209458
val y1 = min(h, (worker + 1) * band)
for (y in worker * band until y1) {
val ay = y * invScale
val iy = ay.toInt()
val ny = y * invH
val nyc = ny * 2f - 1f
val nyc2 = nyc * nyc
val survival = tealSurvival(ny)
val painted = survival > 0f
val stain0 = STAIN[0] + STAIN[2] * nyc + STAIN[5] * nyc2 + STAIN[9] * nyc2 * nyc
val stain1 = STAIN[1] + STAIN[4] * nyc + STAIN[8] * nyc2
val stain2 = STAIN[3] + STAIN[7] * nyc
val fleckRow = hashRow(iy, seed + 9707)
val pitRow = hashRow(iy, seed + 9811)
val sy0 = y / COARSE
val sfy = (y - sy0 * COARSE) * INV_COARSE
val srow = sy0 * slow.w
for (o in ground) o.row(ay)
for (o in fine) o.row(ay)
contrast.row(ay)
cluster.row(ay)
for (o in chromaFine) o.row(ay)
blot.row(ay)
latA.row(ay); latB.row(ay)
ensureActive()
var idx = y * w
for (x in 0 until w) {
val ax = x * invScale
val nx = x * invW2 - 1f
val sx0 = x / COARSE
val sfx = (x - sx0 * COARSE) * INV_COARSE
val si = srow + sx0
var t = stain0 + nx * (stain1 + nx * (stain2 + nx * STAIN[6]))
t += slow.sample(slow.tone, si, sfx, sfy)
for (k in 0 until COARSE_FROM) t += OCT_AMP[k] * (ground[k].at(ax) - 0.5f)
var g = 0f
for (o in fine) g += o.at(ax) - 0.5f
g = warp(g * INV_FINE_S1) * INV_FINE_S2
g *= expSmall(FINE_CONTRAST_K * (contrast.at(ax) - 0.5f))
t += FINE_GAIN_S3 * g
g *= INV_FINE_S3
val li = ((t - T_LO) * LUT_K).toInt().coerceIn(0, LUT_N - 1) * 3
var cr0 = slow.sample(slow.chroma, si, sfx, sfy)
for (k in 0 until CHROMA_FINE) cr0 += CHROMA_AMP[k] * (chromaFine[k].at(ax) - 0.5f)
val c = clamp01(CHROMA_MID + CHROMA_K * cr0)
rgb[0] = DULL_LUT[li] + (SAT_LUT[li] - DULL_LUT[li]) * c
rgb[1] = DULL_LUT[li + 1] + (SAT_LUT[li + 1] - DULL_LUT[li + 1]) * c
rgb[2] = DULL_LUT[li + 2] + (SAT_LUT[li + 2] - DULL_LUT[li + 2]) * c
val qA = latA.at(qa, ax)
val qB = latB.at(qb, ax)
val qMin = if (qA < qB) qA else qB
if (qMin > BOSS_Q && painted) {
val gate = PAINT_NEAR_KEEP + (1f - PAINT_NEAR_KEEP) *
lut01(GATE_LUT, (qMin - GATE_Q0) * INV_GATE_SPAN)
val pn = slow.sample(slow.paint, si, sfx, sfy) +
PAINT_TONE_K * (t - PAINT_TONE_MID)
val p = clamp01((pn - PAINT_LO) * INV_PAINT_WIDTH) * gate * survival
if (p > 0f) {
rgb[0] += (PAINT_LUT[li] - rgb[0]) * p
rgb[1] += (PAINT_LUT[li + 1] - rgb[1]) * p
rgb[2] += (PAINT_LUT[li + 2] - rgb[2]) * p
}
}
if (qA < SHADOW_Q1) {
val sh = 1f - CAST_SHADOW * lut01(SHADOW_LUT, (qA - SHADOW_Q0) * INV_SHADOW_SPAN)
rgb[0] *= sh; rgb[1] *= sh; rgb[2] *= sh
}
if (qB < SHADOW_Q1) {
val sh = 1f - CAST_SHADOW * lut01(SHADOW_LUT, (qB - SHADOW_Q0) * INV_SHADOW_SPAN)
rgb[0] *= sh; rgb[1] *= sh; rgb[2] *= sh
}
if (qA < BOSS_Q) shadeStud(rgb, nrm, qa, ax, COS_A, SIN_A, capSeedA, survival, g, blot)
if (qB < BOSS_Q) shadeStud(rgb, nrm, qb, ax, COS_B, SIN_B, capSeedB, survival, g, blot)
val ix = ax.toInt()
val fleck = hashAt(ix, fleckRow)
val pit = hashAt(ix, pitRow)
if (fleck > FLECK_GATE || pit > PIT_GATE) {
val cl = CLUSTER_LO + CLUSTER_HI * cluster.at(ax)
if (fleck > 1f - FLECK_P * cl) {
rgb[0] += 73f; rgb[1] += 77f; rgb[2] += 77f
} else if (pit > 1f - PIT_P * cl) {
rgb[0] -= 73f; rgb[1] -= 72f; rgb[2] -= 62f
}
}
pixels[idx++] = (0xFF shl 24) or
(rgb[0].toInt().coerceIn(0, 255) shl 16) or
(rgb[1].toInt().coerceIn(0, 255) shl 8) or
rgb[2].toInt().coerceIn(0, 255)
}
}
}
}.awaitAll()
Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888).apply {
setHasAlpha(false)
prepareToDraw()
}
}
/**
* How much bigger than the viewport the plate is baked, so a drag has somewhere to
* go. 8% costs 17% of the bake and buys the whole gesture.
*/
private const val SLACK = 0.08f
/** The plate's own mean, so the frame before the bake lands is not a flash. */
private val PLATE_MEAN = Color(0xFF71452B)
/**
* The viewport settles a frame or two after the first layout. Waiting this long
* before baking keeps the first bake from still running when the second starts,
* where the two halve each other on a four-core device.
*/
private const val VIEWPORT_SETTLE_MS = 48L
@Composable
fun DiamondPlateCanvas(modifier: Modifier = Modifier) {
MatchStatusBarToPage(PLATE_MEAN)
var plate by remember { mutableStateOf<Bitmap?>(null) }
var viewport by remember { mutableStateOf(IntSize.Zero) }
val pan = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var rig by remember { mutableStateOf<WeldRig?>(null) }
val weld = remember { WeldPaints() }
val weldClock = remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
LaunchedEffect(viewport) {
if (viewport.width > 0 && viewport.height > 0) {
delay(VIEWPORT_SETTLE_MS)
plate = withContext(Dispatchers.Default) {
bakePlate(
(viewport.width * (1 + SLACK)).toInt(),
(viewport.height * (1 + SLACK)).toInt(),
seed = 1
)
}
}
}
LaunchedEffect(rig) {
val r = rig ?: return@LaunchedEffect
weld.reset()
weldClock.floatValue = 0f
var last = 0L
while (true) {
withFrameNanos { now ->
val dt = if (last == 0L) 0.016f else ((now - last) / 1e9f).coerceIn(0.001f, 0.05f)
last = now
r.advance(dt)
weldClock.floatValue = r.t
}
if (r.finished && r.doneAt > 7f && r.sparkCount() == 0) break
}
}
Canvas(
modifier
.fillMaxSize()
.onSizeChanged { viewport = it }
.pointerInput(Unit) {
detectTapGestures { rig = WeldRig(0f, 0f) }
}
.pointerInput(Unit) {
val mx = size.width * SLACK * 0.5f
val my = size.height * SLACK * 0.5f
detectDragGestures(
onDragEnd = { scope.launch { pan.animateTo(Offset.Zero, spring(0.9f, 90f)) } }
) { change, drag ->
change.consume()
scope.launch {
pan.snapTo(
Offset(
(pan.value.x + drag.x * 0.35f).coerceIn(-mx, mx),
(pan.value.y + drag.y * 0.35f).coerceIn(-my, my)
)
)
}
}
}
) {
val bmp = plate
if (bmp == null) {
drawRect(PLATE_MEAN)
return@Canvas
}
val ox = pan.value.x - size.width * SLACK * 0.5f
val oy = pan.value.y - size.height * SLACK * 0.5f
val r = rig
val elapsed = weldClock.floatValue
drawIntoCanvas {
val c = it.nativeCanvas
c.drawBitmap(bmp, ox, oy, null)
if (r != null && elapsed > 0f) {
val s = bmp.width / ART_W
c.save()
c.translate(ox, oy)
c.scale(s, s)
c.translate(bmp.width / (2f * s), bmp.height / (2f * s))
drawWeld(r, weld)
c.restore()
}
}
}
}
@Composable
fun DiamondPlateScreen() {
DiamondPlateCanvas()
}
@Preview(showSystemUi = true)
@Composable
fun DiamondPlatePreview() {
DiamondPlateCanvas()
}
@ummersiddique

Copy link
Copy Markdown

Classes are missing

WeldRig
MatchStatusBarToPage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment