Guides

GPU and three.js interop

Write exact, unclamped floats in a renderer's color space, one color at a time or packed into one typed array.

Floats, not strings

Renderers take color as flat floats in a known space, usually linear sRGB: a three.js Color or BufferAttribute, a WebGL uniform3fv, a WebGPU storage buffer. A CSS string is the wrong bridge. toCss and toRgb quantize to 8 bits and clip each channel to sRGB, which bands smooth ramps and shifts the hue of wide-gamut colors. @pbroom/color-kit/interop writes the exact values instead.

One color

to<Space>Array(color, out?, offset?, options?) writes three channels; to<Space>Array4 adds alpha as a fourth, matching vec3 and vec4. Spaces are linearSrgb, srgb, linearP3, display-p3, oklab and oklch. from<Space>Array(array, offset?) reads them back. Pass a typed array as out and nothing is allocated.

one.ts
import { parse, toSrgbGamut } from '@pbroom/color-kit';
import { toLinearSrgbArray } from '@pbroom/color-kit/interop';

const green = parse('oklch(0.7 0.25 150)'); // outside sRGB
toLinearSrgbArray(green); // → ≈ [-0.0937, 0.5598, 0.0385]
toLinearSrgbArray(toSrgbGamut(green)); // → ≈ [0.0000484, 0.5137, 0.0970]

Output is unclamped, which HDR and linear-light pipelines expect; the negative red above is the real color. { clamp: true } clips RGB channels to [0, 1], which shifts hue and lightness. To fit a gamut and keep the hue, compose a mapping instead, as on the second line.

Many colors

packColors writes a list into one Float32Array (or your own array), with alpha, stride and offset options for interleaved layouts. unpackColors reads it back. Float32 storage rounds each channel to single precision, which is well below anything visible; pass a Float64Array as out when you need doubles.

three.js

THREE.Color stores linear sRGB (its default working color space is 'srgb-linear'), so linear floats go straight in, exact and unclamped:

three.ts
import * as THREE from 'three';
import {
  fromLinearSrgbArray,
  packColors,
  toLinearSrgbArray,
} from '@pbroom/color-kit/interop';

material.color.fromArray(toLinearSrgbArray(color));
const back = fromLinearSrgbArray(material.color.toArray());

// Per-vertex colors: one buffer for the whole mesh.
geometry.setAttribute(
  'color',
  new THREE.BufferAttribute(packColors(colors, 'linearSrgb'), 3),
);

material.color.setStyle(toCss(color)) still works, but goes through an 8-bit, clipped sRGB string.

WebGL and WebGPU

gpu.ts
import { packColors, toLinearSrgbArray } from '@pbroom/color-kit/interop';

// WebGL: reuse one scratch array per uniform.
const scratch = new Float32Array(3);
gl.uniform3fv(location, toLinearSrgbArray(color, scratch));

// WebGPU: array<vec4f> has a 16-byte stride, so pack four floats per color.
device.queue.writeBuffer(
  buffer,
  0,
  packColors(colors, 'linearSrgb', undefined, { alpha: true }),
);

A WGSL array<vec3f> also has a 16-byte stride; pack it with stride: 4. For shaders that redo the conversion math, the root package exports the CSS Color 4 matrices: OKLAB_TO_LMS, LMS_TO_LINEAR_SRGB and LINEAR_SRGB_TO_LINEAR_P3.

Try it

Three colors packed in each space: pure red, a green outside sRGB, and a half-transparent blue. Switch to mapped to sRGB to see the green's negative channel disappear without clipping.

Float32Array(9), stride 3

inputpackedunpacked
oklch(0.628 0.2577 29.23)1.0000 0.0000 0.0000oklch(0.628 0.2577 29.23)
oklch(0.7 0.25 150)-0.0937 0.5598 0.0385oklch(0.7 0.25 150)
oklch(0.6231 0.188 259.81 / 0.502)0.0437 0.2232 0.9216oklch(0.6231 0.188 259.81)
pack-inspector.tsx
import { useState } from 'react';
import { parse, toCss, toSrgbGamut } from '@pbroom/color-kit';
import {
  packColors,
  unpackColors,
  type ArraySpace,
} from '@pbroom/color-kit/interop';
import { Toggle } from './controls';

const SPACES = [
  'linearSrgb',
  'srgb',
  'linearP3',
  'display-p3',
  'oklab',
  'oklch',
] as const satisfies readonly ArraySpace[];
const MODES = ['as requested', 'mapped to sRGB'] as const;

const COLORS = [
  parse('#ff0000'),
  parse('oklch(0.7 0.25 150)'), // outside sRGB
  parse('#3b82f680'),
];

/** Fixed-point, without a sign on rounded zeros (`-0.0000`). */
const fixed = (value: number) => value.toFixed(4).replace(/^-(0\.0+)$/, '$1');

export default function PackInspector() {
  const [space, setSpace] = useState<ArraySpace>('linearSrgb');
  const [mode, setMode] = useState<(typeof MODES)[number]>('as requested');
  const [alpha, setAlpha] = useState(false);

  // Gamut mapping is composed, not an option: map first, then pack.
  const colors =
    mode === 'mapped to sRGB' ? COLORS.map((c) => toSrgbGamut(c)) : COLORS;
  const packed = packColors(colors, space, undefined, { alpha });
  const stride = alpha ? 4 : 3;
  const roundTrip = unpackColors(packed, space, { alpha });

  return (
    <div className="grid gap-3">
      <Toggle
        label="Space"
        options={SPACES}
        value={space as (typeof SPACES)[number]}
        onChange={setSpace}
      />
      <div className="flex flex-wrap gap-3">
        <Toggle
          label="Mapping"
          options={MODES}
          value={mode}
          onChange={setMode}
        />
        <label className="flex items-center gap-2 text-[13px]">
          <input
            type="checkbox"
            checked={alpha}
            onChange={(e) => setAlpha(e.currentTarget.checked)}
          />
          alpha (vec4)
        </label>
      </div>
      <p className="m-0 [font-family:var(--font-mono)] text-[13px] text-muted-foreground">
        {packed.constructor.name}({packed.length}), stride {stride}
      </p>
      <div className="overflow-x-auto">
        <table className="w-full [font-family:var(--font-mono)] text-[12px]">
          <thead>
            <tr className="text-left text-muted-foreground">
              <th className="font-[400]">input</th>
              <th className="font-[400]">packed</th>
              <th className="font-[400]">unpacked</th>
            </tr>
          </thead>
          <tbody>
            {colors.map((color, i) => (
              <tr key={toCss(COLORS[i], 'oklch')}>
                <td>{toCss(color, 'oklch')}</td>
                <td className="tnum">
                  {Array.from(
                    packed.subarray(i * stride, (i + 1) * stride),
                    fixed,
                  ).join(' ')}
                </td>
                <td>{toCss(roundTrip[i], 'oklch')}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}