{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pigment-bath",
  "type": "registry:component",
  "title": "Kubelka–Munk Pigment Bath",
  "description": "A stirred emulsion mixing pigments with two-constant Kubelka–Munk scattering — K/S inverted from masstone reflectance — across linseed oil, watercolor, and encaustic wax vehicles.",
  "registryDependencies": [
    "https://chromologium.com/r/optical-engine.json"
  ],
  "files": [
    {
      "path": "components/optics/renderers/pigment.ts",
      "content": "/* Design tokens & architecture sourced from The Chromologium (https://chromologium.com) */\n/**\n * The Chromologium — Kubelka–Munk Pigment Emulsion Renderer (LAB-08)\n *\n * A stirred pigment bath: up to three archival pigments live in the R/G/B\n * channels of a ping-pong concentration field, advected semi-Lagrangian\n * through a self-advecting velocity field driven by the visitor's stirring.\n * The composite pass mixes pigments with genuine two-constant Kubelka–Munk\n * math — K/S coefficients are inverted from each pigment's masstone\n * reflectance via K/S = (1 − R)² / 2R (Curtis et al., 1997), so mixing\n * ultramarine into gold behaves like paint, not like `mix-blend-mode`.\n *\n * The vehicle (linseed oil / watercolor wash / encaustic wax) is one\n * parameter set on one solver: velocity damping, capillary diffusion,\n * impulse radius, and surface gloss.\n */\n\nimport { readTokenSrgb, type Rgb } from \"@/lib/shaderTokens\";\nimport {\n  createPingPong,\n  createProgram,\n  disposePingPong,\n  drawFullscreen,\n  setUniforms,\n  type PingPongTarget,\n} from \"../glUtils\";\nimport type { FrameInfo, OpticalRenderer, OpticalSurfaceState } from \"../types\";\n\n/** Simulation runs at this fraction of the display resolution. */\nconst SIM_SCALE = 0.5;\n\nconst VELOCITY_FS = `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nout vec4 outColor;\n\nuniform sampler2D u_vel;\nuniform float u_dt;\nuniform float u_damp;        // per-second velocity retention (vehicle)\nuniform float u_time;\nuniform float u_drift;       // ambient curl amplitude (vehicle)\nuniform vec2 u_pointer;      // uv\nuniform vec2 u_pointerVel;   // uv/s\nuniform float u_pointerIn;\nuniform float u_impulseR;    // uv radius (vehicle)\n\nvec2 decodeV(vec4 t) { return (t.xy - 0.5) * 2.0; }\nvec4 encodeV(vec2 v) { return vec4(clamp(v, -1.0, 1.0) * 0.5 + 0.5, 0.0, 1.0); }\n\n// Cheap gradient noise for ambient drift\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p);\n  vec2 f = fract(p);\n  f = f * f * (3.0 - 2.0 * f);\n  return mix(\n    mix(hash(i), hash(i + vec2(1, 0)), f.x),\n    mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x),\n    f.y\n  );\n}\n\nvoid main() {\n  vec2 vel = decodeV(texture(u_vel, v_uv));\n\n  // Semi-Lagrangian self-advection\n  vec2 back = v_uv - vel * u_dt * 0.6;\n  vel = decodeV(texture(u_vel, back));\n\n  // Vehicle viscosity: exponential retention\n  vel *= pow(u_damp, u_dt * 60.0);\n\n  // Ambient curl drift keeps the bath faintly alive\n  float n1 = noise(v_uv * 5.0 + u_time * 0.12);\n  float n2 = noise(v_uv * 5.0 - u_time * 0.09 + 17.3);\n  vel += vec2(n1 - 0.5, n2 - 0.5) * u_drift * u_dt;\n\n  // Stirring impulse from the pointer\n  if (u_pointerIn > 0.5) {\n    float d = length(v_uv - u_pointer);\n    float g = exp(-(d * d) / (u_impulseR * u_impulseR));\n    vel += u_pointerVel * g * u_dt * 1.6;\n  }\n\n  outColor = encodeV(vel);\n}`;\n\nconst PIGMENT_FS = `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nout vec4 outColor;\n\nuniform sampler2D u_pig;\nuniform sampler2D u_vel;\nuniform vec2 u_texel;\nuniform float u_dt;\nuniform float u_diffuse;     // capillary bleed (vehicle)\nuniform float u_keep;        // pigment retention per second\nuniform vec2 u_pointer;\nuniform float u_pointerIn;\nuniform float u_pointerDown;\nuniform float u_brush;       // -1 none, 0/1/2 = channel being deposited\nuniform float u_brushR;\nuniform float u_auto;        // 1 = ambient deposit from three orbiting wells\nuniform float u_time;\n\nvec2 decodeV(vec4 t) { return (t.xy - 0.5) * 2.0; }\n\nvoid main() {\n  vec2 vel = decodeV(texture(u_vel, v_uv));\n  vec2 back = v_uv - vel * u_dt * 0.6;\n  vec3 pig = texture(u_pig, back).rgb;\n\n  // Capillary diffusion (watercolor bleeds, oil holds its trail)\n  if (u_diffuse > 0.0) {\n    vec3 nsum = texture(u_pig, back + vec2(u_texel.x, 0)).rgb\n      + texture(u_pig, back - vec2(u_texel.x, 0)).rgb\n      + texture(u_pig, back + vec2(0, u_texel.y)).rgb\n      + texture(u_pig, back - vec2(0, u_texel.y)).rgb;\n    pig = mix(pig, nsum * 0.25, clamp(u_diffuse * u_dt * 60.0, 0.0, 0.35));\n  }\n\n  // Slow settling\n  pig *= pow(u_keep, u_dt * 60.0);\n\n  // Ambient wells: three orbiting sources seed the bath on their own\n  if (u_auto > 0.5) {\n    for (int i = 0; i < 3; i++) {\n      float ph = float(i) * 2.0944; // 120 degrees apart\n      vec2 well = vec2(0.5) + 0.26 * vec2(\n        cos(u_time * 0.21 + ph),\n        sin(u_time * 0.17 + ph * 1.3)\n      );\n      float dw = length(v_uv - well);\n      float gw = exp(-(dw * dw) / 0.0016);\n      vec3 addw = vec3(0.0);\n      if (i == 0) addw.r = gw;\n      else if (i == 1) addw.g = gw;\n      else addw.b = gw;\n      pig = clamp(pig + addw * u_dt * 0.9, 0.0, 1.0);\n    }\n  }\n\n  // Loaded brush deposits pigment while the pointer is held down\n  if (u_brush > -0.5 && u_pointerDown > 0.5 && u_pointerIn > 0.5) {\n    float d = length(v_uv - u_pointer);\n    float g = exp(-(d * d) / (u_brushR * u_brushR));\n    vec3 add = vec3(0.0);\n    if (u_brush < 0.5) add.r = g;\n    else if (u_brush < 1.5) add.g = g;\n    else add.b = g;\n    pig = clamp(pig + add * u_dt * 5.0, 0.0, 1.0);\n  }\n\n  outColor = vec4(pig, 1.0);\n}`;\n\nconst COMPOSITE_FS = `#version 300 es\nprecision highp float;\nin vec2 v_uv;\nout vec4 outColor;\n\nuniform sampler2D u_pig;\nuniform vec2 u_texel;\nuniform vec3 u_paper;        // substrate color (sRGB)\nuniform vec3 u_ks0;          // K/S per channel for pigment slots\nuniform vec3 u_ks1;\nuniform vec3 u_ks2;\nuniform float u_gloss;       // vehicle specular strength\nuniform float u_grain;       // paper tooth visibility (vehicle)\nuniform float u_time;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n\n// Kubelka–Munk infinite-thickness reflectance from K/S\nvec3 kmReflectance(vec3 ks) {\n  return 1.0 + ks - sqrt(ks * ks + 2.0 * ks);\n}\n\nvoid main() {\n  vec3 c = texture(u_pig, v_uv).rgb;\n  float total = c.r + c.g + c.b;\n\n  // Concentration-weighted K/S mixture (S normalized to 1 per pigment)\n  vec3 ks = (c.r * u_ks0 + c.g * u_ks1 + c.b * u_ks2) / max(total, 1e-4);\n  vec3 pigmentColor = kmReflectance(ks);\n\n  // Optical coverage: thin washes let the substrate through\n  float coverage = 1.0 - exp(-total * 3.2);\n\n  // Paper tooth\n  float tooth = (hash(floor(v_uv / u_texel * 0.5)) - 0.5) * u_grain;\n  vec3 paper = clamp(u_paper + tooth, 0.0, 1.0);\n\n  vec3 col = mix(paper, pigmentColor, coverage);\n\n  // Vehicle sheen: fake normal from the concentration field gradient\n  if (u_gloss > 0.0) {\n    float hx = texture(u_pig, v_uv + vec2(u_texel.x * 2.0, 0.0)).a\n      + dot(texture(u_pig, v_uv + vec2(u_texel.x * 2.0, 0.0)).rgb, vec3(1.0));\n    float hy = texture(u_pig, v_uv + vec2(0.0, u_texel.y * 2.0)).a\n      + dot(texture(u_pig, v_uv + vec2(0.0, u_texel.y * 2.0)).rgb, vec3(1.0));\n    vec3 n = normalize(vec3((total - hx) * 3.0, (total - hy) * 3.0, 1.0));\n    vec3 light = normalize(vec3(-0.4, 0.55, 0.8));\n    float spec = pow(max(dot(n, light), 0.0), 24.0);\n    col += vec3(spec) * u_gloss * coverage * 0.5;\n  }\n\n  outColor = vec4(col, 1.0);\n}`;\n\nexport interface VehicleParams {\n  /** Velocity retention per frame-second (viscous memory). */\n  damp: number;\n  /** Ambient curl drift amplitude. */\n  drift: number;\n  /** Capillary diffusion rate. */\n  diffuse: number;\n  /** Pigment retention (evaporation/settling). */\n  keep: number;\n  /** Stir impulse radius (uv). */\n  impulseR: number;\n  /** Brush deposit radius (uv). */\n  brushR: number;\n  /** Specular sheen strength. */\n  gloss: number;\n  /** Paper tooth visibility. */\n  grain: number;\n}\n\nexport const VEHICLES: Record<\n  \"linseed-oil\" | \"watercolor\" | \"encaustic-wax\",\n  VehicleParams\n> = {\n  // Viscous memory, long trails, oily sheen\n  \"linseed-oil\": {\n    damp: 0.988,\n    drift: 0.012,\n    diffuse: 0,\n    keep: 0.9999,\n    impulseR: 0.09,\n    brushR: 0.05,\n    gloss: 0.55,\n    grain: 0.015,\n  },\n  // Fast capillary bleed into the paper fibers, matte\n  watercolor: {\n    damp: 0.9,\n    drift: 0.02,\n    diffuse: 0.55,\n    keep: 0.9993,\n    impulseR: 0.13,\n    brushR: 0.07,\n    gloss: 0,\n    grain: 0.06,\n  },\n  // Localized, high surface tension, waxy satin\n  \"encaustic-wax\": {\n    damp: 0.8,\n    drift: 0.004,\n    diffuse: 0,\n    keep: 1.0,\n    impulseR: 0.05,\n    brushR: 0.04,\n    gloss: 0.25,\n    grain: 0.03,\n  },\n};\n\n/** Invert masstone reflectance to K/S per channel (Curtis et al., 1997). */\nexport function ksFromColor(rgb: Rgb): Rgb {\n  const ks = rgb.map((channel) => {\n    const r = Math.min(0.98, Math.max(0.02, channel));\n    return ((1 - r) * (1 - r)) / (2 * r);\n  });\n  return [ks[0], ks[1], ks[2]];\n}\n\n/** CPU-side KM mix of pigments at given concentrations, for readouts. */\nexport function kmMix(pigments: Rgb[], concentrations: number[]): Rgb {\n  const total = concentrations.reduce((sum, v) => sum + v, 0);\n  if (total <= 0) return [1, 1, 1];\n  const out: number[] = [];\n  for (let ch = 0; ch < 3; ch++) {\n    let ksSum = 0;\n    pigments.forEach((pigment, i) => {\n      ksSum += ksFromColor(pigment)[ch] * concentrations[i];\n    });\n    const ks = ksSum / total;\n    out.push(1 + ks - Math.sqrt(ks * ks + 2 * ks));\n  }\n  return [out[0], out[1], out[2]];\n}\n\ninterface PigmentStore {\n  velProgram: WebGLProgram;\n  pigProgram: WebGLProgram;\n  compProgram: WebGLProgram;\n  velLoc: Map<string, WebGLUniformLocation | null>;\n  pigLoc: Map<string, WebGLUniformLocation | null>;\n  compLoc: Map<string, WebGLUniformLocation | null>;\n  velocity: PingPongTarget;\n  pigment: PingPongTarget;\n  lastRinse: number;\n  lastPointer: { x: number; y: number };\n}\n\nfunction simSize(surface: OpticalSurfaceState): [number, number] {\n  return [\n    Math.max(8, Math.round(surface.width * SIM_SCALE)),\n    Math.max(8, Math.round(surface.height * SIM_SCALE)),\n  ];\n}\n\nexport function createPigmentRenderer(): OpticalRenderer {\n  return {\n    resizeReinit: true,\n\n    init(gl: WebGL2RenderingContext, surface: OpticalSurfaceState): void {\n      const [sw, sh] = simSize(surface);\n      const store: PigmentStore = {\n        velProgram: createProgram(gl, VELOCITY_FS),\n        pigProgram: createProgram(gl, PIGMENT_FS),\n        compProgram: createProgram(gl, COMPOSITE_FS),\n        velLoc: new Map(),\n        pigLoc: new Map(),\n        compLoc: new Map(),\n        velocity: createPingPong(gl, sw, sh),\n        pigment: createPingPong(gl, sw, sh),\n        lastRinse: -1,\n        lastPointer: { x: 0, y: 0 },\n      };\n      // Velocity must start at encoded zero (0.5, 0.5)\n      for (const fbo of store.velocity.framebuffers) {\n        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n        gl.clearColor(0.5, 0.5, 0, 1);\n        gl.clear(gl.COLOR_BUFFER_BIT);\n      }\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      surface.store.set(\"pigment\", store);\n    },\n\n    render(\n      gl: WebGL2RenderingContext,\n      surface: OpticalSurfaceState,\n      frame: FrameInfo,\n    ): void {\n      const store = surface.store.get(\"pigment\") as PigmentStore;\n      const custom = surface.getUniforms?.() ?? {};\n      const [sw, sh] = [store.velocity.width, store.velocity.height];\n      const dt = Math.min(frame.dt, 1 / 30);\n\n      // Rinse request: clear both fields\n      const rinse = (custom.u_rinse as number) ?? 0;\n      if (rinse !== store.lastRinse) {\n        store.lastRinse = rinse;\n        for (const fbo of store.velocity.framebuffers) {\n          gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n          gl.viewport(0, 0, sw, sh);\n          gl.clearColor(0.5, 0.5, 0, 1);\n          gl.clear(gl.COLOR_BUFFER_BIT);\n        }\n        for (const fbo of store.pigment.framebuffers) {\n          gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n          gl.viewport(0, 0, sw, sh);\n          gl.clearColor(0, 0, 0, 1);\n          gl.clear(gl.COLOR_BUFFER_BIT);\n        }\n      }\n\n      const pointerUv: [number, number] = [\n        surface.pointer.x / Math.max(1, surface.cssWidth),\n        1 - surface.pointer.y / Math.max(1, surface.cssHeight),\n      ];\n      const pointerVel: [number, number] = [\n        surface.pointer.vx / Math.max(1, surface.cssWidth),\n        -surface.pointer.vy / Math.max(1, surface.cssHeight),\n      ];\n\n      // --- Pass 1: velocity ---\n      gl.useProgram(store.velProgram);\n      gl.bindFramebuffer(\n        gl.FRAMEBUFFER,\n        store.velocity.framebuffers[1 - store.velocity.read],\n      );\n      gl.viewport(0, 0, sw, sh);\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(\n        gl.TEXTURE_2D,\n        store.velocity.textures[store.velocity.read],\n      );\n      gl.uniform1i(gl.getUniformLocation(store.velProgram, \"u_vel\"), 0);\n      setUniforms(gl, store.velProgram, store.velLoc, {\n        u_dt: dt,\n        u_time: frame.t,\n        u_damp: (custom.u_damp as number) ?? 0.97,\n        u_drift: (custom.u_drift as number) ?? 0.01,\n        u_pointer: pointerUv,\n        u_pointerVel: [\n          Math.max(-3, Math.min(3, pointerVel[0])),\n          Math.max(-3, Math.min(3, pointerVel[1])),\n        ],\n        u_pointerIn: surface.pointer.inside ? 1 : 0,\n        u_impulseR: (custom.u_impulseR as number) ?? 0.09,\n      });\n      drawFullscreen(gl);\n      store.velocity.read = 1 - store.velocity.read;\n\n      // --- Pass 2: pigment advection + deposit ---\n      gl.useProgram(store.pigProgram);\n      gl.bindFramebuffer(\n        gl.FRAMEBUFFER,\n        store.pigment.framebuffers[1 - store.pigment.read],\n      );\n      gl.viewport(0, 0, sw, sh);\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, store.pigment.textures[store.pigment.read]);\n      gl.uniform1i(gl.getUniformLocation(store.pigProgram, \"u_pig\"), 0);\n      gl.activeTexture(gl.TEXTURE1);\n      gl.bindTexture(\n        gl.TEXTURE_2D,\n        store.velocity.textures[store.velocity.read],\n      );\n      gl.uniform1i(gl.getUniformLocation(store.pigProgram, \"u_vel\"), 1);\n      setUniforms(gl, store.pigProgram, store.pigLoc, {\n        u_texel: [1 / sw, 1 / sh],\n        u_dt: dt,\n        u_diffuse: (custom.u_diffuse as number) ?? 0,\n        u_keep: (custom.u_keep as number) ?? 0.9998,\n        u_pointer: pointerUv,\n        u_pointerIn: surface.pointer.inside ? 1 : 0,\n        u_pointerDown: surface.pointer.down ? 1 : 0,\n        u_brush: (custom.u_brush as number) ?? -1,\n        u_brushR: (custom.u_brushR as number) ?? 0.05,\n        u_auto: (custom.u_auto as number) ?? 0,\n        u_time: frame.t,\n      });\n      drawFullscreen(gl);\n      store.pigment.read = 1 - store.pigment.read;\n\n      // --- Pass 3: Kubelka–Munk composite into the surface viewport ---\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      const vp = frame.viewport ?? [0, 0, surface.width, surface.height];\n      gl.viewport(vp[0], vp[1], vp[2], vp[3]);\n      gl.useProgram(store.compProgram);\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, store.pigment.textures[store.pigment.read]);\n      gl.uniform1i(gl.getUniformLocation(store.compProgram, \"u_pig\"), 0);\n      setUniforms(gl, store.compProgram, store.compLoc, {\n        u_texel: [1 / sw, 1 / sh],\n        u_time: frame.t,\n        u_paper: readTokenSrgb(\"--ds-bg-primary\", [0.96, 0.95, 0.93]),\n        u_ks0: (custom.u_ks0 as number[]) ?? [0.5, 0.5, 0.5],\n        u_ks1: (custom.u_ks1 as number[]) ?? [0.5, 0.5, 0.5],\n        u_ks2: (custom.u_ks2 as number[]) ?? [0.5, 0.5, 0.5],\n        u_gloss: (custom.u_gloss as number) ?? 0.3,\n        u_grain: (custom.u_grain as number) ?? 0.03,\n      });\n      drawFullscreen(gl);\n    },\n\n    dispose(gl: WebGL2RenderingContext, surface: OpticalSurfaceState): void {\n      const store = surface.store.get(\"pigment\") as PigmentStore | undefined;\n      if (!store) return;\n      gl.deleteProgram(store.velProgram);\n      gl.deleteProgram(store.pigProgram);\n      gl.deleteProgram(store.compProgram);\n      disposePingPong(gl, store.velocity);\n      disposePingPong(gl, store.pigment);\n      surface.store.delete(\"pigment\");\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/optics/renderers/pigment.ts"
    }
  ],
  "meta": {
    "model": "K/S = (1 − R)² / 2R; R∞ = 1 + K/S − sqrt((K/S)² + 2·K/S)",
    "citation": "Kubelka, P., & Munk, F. (1931). Ein Beitrag zur Optik der Farbanstriche. Zeitschrift für technische Physik, 12, 593–601."
  },
  "author": "The Chromologium",
  "homepage": "https://chromologium.com",
  "license": "MIT"
}
