instantshader 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1660,6 +1660,1264 @@ const whorl = {
1660
1660
  }
1661
1661
  };
1662
1662
 
1663
+ //#endregion
1664
+ //#region src/effects/pixelate.ts
1665
+ const CELL_FRAGMENT$3 = `
1666
+ uniform float u_levels;
1667
+
1668
+ void main() {
1669
+ vec3 c = cellSource().rgb;
1670
+ // Posterize per channel. Rounds to the nearest level, so black and white
1671
+ // stay put. levels = 1 would divide by zero and means nothing; 0 is "off".
1672
+ if (u_levels >= 2.0) {
1673
+ float n = u_levels - 1.0;
1674
+ c = floor(c * n + 0.5) / n;
1675
+ }
1676
+ gl_FragColor = vec4(c, 1.0);
1677
+ }
1678
+ `;
1679
+ const DRAW_FRAGMENT$2 = `
1680
+ uniform float u_gap;
1681
+ uniform vec3 u_gapColor;
1682
+
1683
+ void main() {
1684
+ vec3 color = coverage();
1685
+ if (u_gap > 0.0) {
1686
+ // A gap line is u_gap of a cell wide, centred on every cell boundary.
1687
+ // Its weight is the exact share of this output pixel it covers, and the
1688
+ // blend happens in linear light, for the same reason coverage() works
1689
+ // that way: a smoothstep edge in gamma space draws sub-pixel lines far
1690
+ // too heavy, so a small preview would look darker than its own export.
1691
+ vec2 px = cellPx();
1692
+ vec2 d = min(cellLocal(), 1.0 - cellLocal()) * px; // px to nearest boundary
1693
+ vec2 hw = 0.5 * u_gap * px; // line half-width in px
1694
+ vec2 cov = clamp(min(hw, d + 0.5) - max(-hw, d - 0.5), 0.0, 1.0);
1695
+ float m = 1.0 - (1.0 - cov.x) * (1.0 - cov.y);
1696
+ color = toSrgb(mix(toLinear(color), toLinear(u_gapColor), m));
1697
+ }
1698
+ gl_FragColor = vec4(color, 1.0);
1699
+ }
1700
+ `;
1701
+ const pixelate = {
1702
+ id: "pixelate",
1703
+ label: "Pixelate",
1704
+ fragment: DRAW_FRAGMENT$2,
1705
+ grid: {
1706
+ cell: (p) => [p.size, p.size],
1707
+ fragment: CELL_FRAGMENT$3
1708
+ },
1709
+ params: [
1710
+ {
1711
+ key: "size",
1712
+ label: "Size",
1713
+ min: 2,
1714
+ max: 160,
1715
+ step: 1,
1716
+ default: 24
1717
+ },
1718
+ {
1719
+ key: "levels",
1720
+ label: "Levels",
1721
+ min: 0,
1722
+ max: 16,
1723
+ step: 1,
1724
+ default: 0
1725
+ },
1726
+ {
1727
+ key: "gap",
1728
+ label: "Gap",
1729
+ min: 0,
1730
+ max: .4,
1731
+ step: .01,
1732
+ default: 0
1733
+ },
1734
+ {
1735
+ key: "gapColor",
1736
+ label: "Gap color",
1737
+ type: "color",
1738
+ default: "#000000"
1739
+ }
1740
+ ],
1741
+ randomParams(rand) {
1742
+ return {
1743
+ size: Math.round(10 + rand() * 50),
1744
+ levels: rand() < .5 ? 0 : Math.round(3 + rand() * 5),
1745
+ gap: rand() < .6 ? 0 : .05 + rand() * .15,
1746
+ gapColor: "#000000"
1747
+ };
1748
+ }
1749
+ };
1750
+
1751
+ //#endregion
1752
+ //#region src/effects/chunks.ts
1753
+ /** sRGB transfer functions. Blending and averaging must happen in linear
1754
+ * light: a fine black/white checkerboard averaged in gamma space comes out
1755
+ * sRGB 128 where the eye (and a downscaled export) sees 188. */
1756
+ const COLOR_MATH = `
1757
+ vec3 toLinear(vec3 c) {
1758
+ c = max(c, vec3(0.0));
1759
+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
1760
+ }
1761
+ vec3 toSrgb(vec3 c) {
1762
+ c = max(c, vec3(0.0));
1763
+ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
1764
+ }
1765
+ `;
1766
+ /**
1767
+ * Available to every effect stage.
1768
+ *
1769
+ * - `loopStep(rate)`: integer step counter for motion that jumps instead of
1770
+ * gliding. Exactly periodic when looping, because it counts a whole number
1771
+ * of steps per loop and u_time itself wraps at u_loop.
1772
+ * - `hash12` / `hash22`: Dave Hoskins' sine-free hashes. The classic
1773
+ * fract(sin(x) * 43758.5) returns different values on different GPUs,
1774
+ * which would make a saved design animate differently per machine.
1775
+ * - `luma`: Rec.709 weights on GAMMA-ENCODED sRGB, on purpose. Thresholding
1776
+ * in gamma space is what every dither tool does and what users compare
1777
+ * against; see SPEC-effects.md 9.1.
1778
+ */
1779
+ const EFFECT_COMMON = `
1780
+ uniform vec2 u_refSize; // frame size in reference pixels (16:9 = 1920x1080)
1781
+ uniform float u_outputScale; // device pixels per reference pixel
1782
+ ${COLOR_MATH}
1783
+ float loopStep(float rate) {
1784
+ if (rate <= 0.0) return 0.0;
1785
+ if (u_loop <= 0.0) return floor(u_time * rate);
1786
+ float n = max(1.0, floor(rate * u_loop + 0.5));
1787
+ return min(floor(u_time / u_loop * n), n - 1.0);
1788
+ }
1789
+ float hash12(vec2 p) {
1790
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
1791
+ p3 += dot(p3, p3.yzx + 33.33);
1792
+ return fract((p3.x + p3.y) * p3.z);
1793
+ }
1794
+ vec2 hash22(vec2 p) {
1795
+ vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
1796
+ p3 += dot(p3, p3.yzx + 33.33);
1797
+ return fract((p3.xx + p3.yz) * p3.zy);
1798
+ }
1799
+ float luma(vec3 c) {
1800
+ return dot(c, vec3(0.2126, 0.7152, 0.0722));
1801
+ }
1802
+ `;
1803
+ /** Soft effects: one fragment per output pixel, reading the layer below. */
1804
+ const SOFT_PREAMBLE = `
1805
+ uniform sampler2D u_source; // the layer below, frame size
1806
+ vec4 source(vec2 uv) { return texture2D(u_source, uv); }
1807
+ // This pixel in reference pixels, origin at the frame centre.
1808
+ vec2 refCoord() { return (gl_FragCoord.xy - 0.5 * u_resolution) / u_outputScale; }
1809
+ // Reference pixels back to 0-1 frame UV, for sampling u_source.
1810
+ vec2 refToUv(vec2 r) { return r * u_outputScale / u_resolution + 0.5; }
1811
+ `;
1812
+ /** Cell stage of a grid effect: one fragment per CELL. */
1813
+ const CELL_PREAMBLE = `
1814
+ uniform sampler2D u_source; // cell source: one texel per cell, already averaged
1815
+ uniform vec2 u_gridSize; // cols, rows
1816
+ uniform vec2 u_cellRef; // cell size in reference pixels
1817
+ uniform float u_gridAngle; // radians; 0 unless the effect uses a lattice
1818
+ // Integer cell coordinates with the origin at the grid (= frame) centre.
1819
+ // Index patterns by this, never by gl_FragCoord of the output.
1820
+ vec2 cellIndex() { return floor(gl_FragCoord.xy) - 0.5 * u_gridSize; }
1821
+ // The input picture averaged over this cell.
1822
+ vec4 cellSource() { return texture2D(u_source, (floor(gl_FragCoord.xy) + 0.5) / u_gridSize); }
1823
+ `;
1824
+ /**
1825
+ * Draw stage of a grid effect: one fragment per output pixel, reading the
1826
+ * cell buffer.
1827
+ *
1828
+ * coverage() is the default draw stage: exact area coverage of this output
1829
+ * pixel's footprint over the cells it touches, blended in linear light. It
1830
+ * is libretro's "pixellate" filter with gamma-correct blending. When a cell
1831
+ * spans a whole number of output pixels the footprint sits inside one cell
1832
+ * and the texel is returned untouched, so 1080p and 4K exports are exact
1833
+ * nearest-neighbour. 3x3 taps cover footprints up to 3 cells wide, i.e.
1834
+ * down to 0.5 output pixels per cell.
1835
+ */
1836
+ const DRAW_PREAMBLE = `
1837
+ uniform sampler2D u_cells; // cell buffer, NEAREST, sRGB-encoded
1838
+ uniform vec2 u_gridSize;
1839
+ uniform vec2 u_cellRef;
1840
+ uniform float u_gridAngle; // radians; 0 unless the effect uses a lattice
1841
+ vec2 cellPx() { return u_cellRef * u_outputScale; }
1842
+ // Continuous cell coordinate of this output pixel: integer part is the cell
1843
+ // (0-based from the grid's bottom-left), fraction is the position inside it.
1844
+ // The angle branch is skipped for ordinary grids so their arithmetic, and
1845
+ // with it the exactness of whole-pixel cells, is untouched.
1846
+ vec2 cellCoordAt(vec2 fragCoord) {
1847
+ vec2 p = fragCoord - 0.5 * u_resolution;
1848
+ if (u_gridAngle != 0.0) {
1849
+ float c = cos(u_gridAngle);
1850
+ float s = sin(u_gridAngle);
1851
+ p = vec2(c * p.x + s * p.y, -s * p.x + c * p.y);
1852
+ }
1853
+ return p / cellPx() + 0.5 * u_gridSize;
1854
+ }
1855
+ vec2 cellCoord() { return cellCoordAt(gl_FragCoord.xy); }
1856
+ vec2 cellIndexAt() { return floor(cellCoord()) - 0.5 * u_gridSize; }
1857
+ vec2 cellLocal() { return fract(cellCoord()); }
1858
+ // Raw cell-buffer texel for a centred cell index (as cellIndexAt returns).
1859
+ vec4 cellValue(vec2 index) {
1860
+ vec2 k = clamp(index + 0.5 * u_gridSize, vec2(0.0), u_gridSize - 1.0);
1861
+ return texture2D(u_cells, (k + 0.5) / u_gridSize);
1862
+ }
1863
+ vec3 coverage() {
1864
+ vec2 c = cellCoord();
1865
+ vec2 h = 0.5 / cellPx();
1866
+ vec2 lo = c - h;
1867
+ vec2 hi = c + h;
1868
+ vec2 base = floor(c);
1869
+ vec3 acc = vec3(0.0);
1870
+ float wsum = 0.0;
1871
+ vec3 best = vec3(0.0);
1872
+ float bestW = -1.0;
1873
+ for (int j = -1; j <= 1; j++) {
1874
+ for (int i = -1; i <= 1; i++) {
1875
+ vec2 k = base + vec2(float(i), float(j));
1876
+ vec2 ov = max(vec2(0.0), min(hi, k + 1.0) - max(lo, k));
1877
+ float w = ov.x * ov.y;
1878
+ if (w <= 0.0) continue;
1879
+ vec2 kk = clamp(k, vec2(0.0), u_gridSize - 1.0);
1880
+ vec3 v = texture2D(u_cells, (kk + 0.5) / u_gridSize).rgb;
1881
+ if (w > bestW) { bestW = w; best = v; }
1882
+ acc += w * toLinear(v);
1883
+ wsum += w;
1884
+ }
1885
+ }
1886
+ // Footprint (almost) entirely inside one cell: return the texel itself
1887
+ // rather than a decode/encode round trip of it.
1888
+ if (bestW >= 0.9999 * 4.0 * h.x * h.y) return best;
1889
+ return toSrgb(acc / wsum);
1890
+ }
1891
+ `;
1892
+ /** Draw stage used when a grid effect does not supply its own. */
1893
+ const COVERAGE_DRAW = `
1894
+ void main() {
1895
+ gl_FragColor = vec4(coverage(), 1.0);
1896
+ }
1897
+ `;
1898
+ /**
1899
+ * Color modes, shared by Dither, Halftone and ASCII so the params and the
1900
+ * behaviour are identical across them:
1901
+ *
1902
+ * - source: keep the source's colors (the effect still quantizes/masks them)
1903
+ * - duotone: tone 0 -> ink, tone 1 -> paper
1904
+ * - palette: tone -> the stack's palette ramp
1905
+ *
1906
+ * toneOf() is what "tone" means. Normally it is luma. In palette mode
1907
+ * directly over a GENERATOR it is instead the color's position on the
1908
+ * palette ramp, recovered by nearest-color search. A generator's colors came
1909
+ * from that ramp, so this hands each cell back the exact ramp coordinate the
1910
+ * generator used and the effect reproduces the gradient's own color layout.
1911
+ * Luma would only do that for palettes that happen to run dark to light; a
1912
+ * pink -> blue -> yellow palette would come out rearranged. Over media, or
1913
+ * over another effect, there is no ramp coordinate to recover and tone stays
1914
+ * luma (a gradient map).
1915
+ *
1916
+ * The search is 33 coarse + 17 fine ramp samples. That is affordable
1917
+ * because tone is evaluated per cell or per lattice point, not per pixel.
1918
+ * The ends snap to exactly 0 and 1: generators clamp their coordinate, so
1919
+ * large flat areas sit at an end stop, and 8-bit rounding would otherwise
1920
+ * leave them a hair inside the ramp and sprinkle them with the next level.
1921
+ */
1922
+ const COLOR_MODE = `
1923
+ uniform float u_colorMode;
1924
+ uniform vec3 u_ink;
1925
+ uniform vec3 u_paper;
1926
+ uniform float u_invert;
1927
+ uniform float u_sourceIsRamp; // 1 when this layer reads a generator directly
1928
+ float rampPosition(vec3 c) {
1929
+ float best = 0.0;
1930
+ float bd = 1e9;
1931
+ for (int i = 0; i <= 32; i++) {
1932
+ float t = float(i) / 32.0;
1933
+ vec3 d = palette(t) - c;
1934
+ float dd = dot(d, d);
1935
+ if (dd < bd) { bd = dd; best = t; }
1936
+ }
1937
+ float lo = best - 1.0 / 32.0;
1938
+ for (int i = 0; i <= 16; i++) {
1939
+ float t = clamp(lo + float(i) / 256.0, 0.0, 1.0);
1940
+ vec3 d = palette(t) - c;
1941
+ float dd = dot(d, d);
1942
+ if (dd < bd) { bd = dd; best = t; }
1943
+ }
1944
+ if (best > 1.0 - 1.5 / 256.0) return 1.0;
1945
+ if (best < 1.5 / 256.0) return 0.0;
1946
+ return best;
1947
+ }
1948
+ bool toneIsRamp() { return u_colorMode > 1.5 && u_sourceIsRamp > 0.5; }
1949
+ float toneOf(vec3 c) {
1950
+ float t = toneIsRamp() ? rampPosition(c) : luma(c);
1951
+ return u_invert > 0.5 ? 1.0 - t : t;
1952
+ }
1953
+ vec3 colorize(float tone, vec3 src) {
1954
+ if (u_colorMode < 0.5) return src;
1955
+ if (u_colorMode < 1.5) return mix(u_ink, u_paper, tone);
1956
+ return palette(tone);
1957
+ }
1958
+ `;
1959
+ /** The param defs that go with COLOR_MODE. `paperAlways` is for effects
1960
+ * where paper is a visible ground in every mode (ASCII, Halftone). */
1961
+ function colorModeParams(opts) {
1962
+ return [
1963
+ {
1964
+ key: "colorMode",
1965
+ label: "Colors",
1966
+ type: "enum",
1967
+ options: [
1968
+ {
1969
+ value: "source",
1970
+ label: "Source"
1971
+ },
1972
+ {
1973
+ value: "duotone",
1974
+ label: "Duotone"
1975
+ },
1976
+ {
1977
+ value: "palette",
1978
+ label: "Palette"
1979
+ }
1980
+ ],
1981
+ default: opts.mode
1982
+ },
1983
+ {
1984
+ key: "ink",
1985
+ label: "Ink",
1986
+ type: "color",
1987
+ default: opts.ink ?? "#111111",
1988
+ when: {
1989
+ key: "colorMode",
1990
+ in: ["duotone"]
1991
+ }
1992
+ },
1993
+ {
1994
+ key: "paper",
1995
+ label: "Paper",
1996
+ type: "color",
1997
+ default: opts.paper ?? "#f4f1ea",
1998
+ ...opts.paperAlways ? {} : { when: {
1999
+ key: "colorMode",
2000
+ in: ["duotone"]
2001
+ } }
2002
+ },
2003
+ {
2004
+ key: "invert",
2005
+ label: "Invert",
2006
+ type: "bool",
2007
+ default: false
2008
+ }
2009
+ ];
2010
+ }
2011
+
2012
+ //#endregion
2013
+ //#region src/effects/blueNoise.ts
2014
+ const BLUE_NOISE_SIZE = 128;
2015
+ const BASE64 = "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";
2016
+ let cached = null;
2017
+ /** The tile's bytes, row-major, decoded once. */
2018
+ function blueNoiseBytes() {
2019
+ if (cached) return cached;
2020
+ const bin = atob(BASE64);
2021
+ const out = new Uint8Array(bin.length);
2022
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2023
+ cached = out;
2024
+ return out;
2025
+ }
2026
+
2027
+ //#endregion
2028
+ //#region src/effects/dither.ts
2029
+ const CELL_FRAGMENT$2 = `
2030
+ uniform float u_pattern;
2031
+ uniform float u_levels;
2032
+ uniform float u_bias;
2033
+ uniform float u_linear;
2034
+ uniform float u_shimmer;
2035
+ uniform sampler2D u_noise;
2036
+ ${COLOR_MODE}
2037
+
2038
+ // Recursive Bayer matrices without bit operations (GLSL ES 1.0 has none).
2039
+ // bayer2 yields M/4 for the canonical 2x2 matrix [0 2; 3 1]; each larger
2040
+ // size interleaves a half-resolution copy. Verified against the canonical
2041
+ // 4x4 and 8x8 matrices value for value. The mod() keeps the square term
2042
+ // exact at any cell index and makes negative indices (the grid is centred on
2043
+ // the frame) tile correctly.
2044
+ float bayer2(vec2 a) {
2045
+ a = mod(floor(a), 2.0);
2046
+ return fract(a.x / 2.0 + a.y * a.y * 0.75);
2047
+ }
2048
+ #define bayer4(a) (bayer2(0.5 * (a)) * 0.25 + bayer2(a))
2049
+ #define bayer8(a) (bayer4(0.5 * (a)) * 0.25 + bayer2(a))
2050
+
2051
+ // Centred thresholds: (M + 0.5) / N^2, strictly inside (0,1). The noise
2052
+ // texture gets the same treatment, since its raw 0 would otherwise never
2053
+ // fire and its raw 255 (= 1.0) would light pure black.
2054
+ float threshold(vec2 idx) {
2055
+ if (u_pattern < 0.5) return bayer2(idx) + 0.5 / 4.0;
2056
+ if (u_pattern < 1.5) return bayer4(idx) + 0.5 / 16.0;
2057
+ if (u_pattern < 2.5) return bayer8(idx) + 0.5 / 64.0;
2058
+ float v = texture2D(u_noise, (mod(idx, ${BLUE_NOISE_SIZE}.0) + 0.5) / ${BLUE_NOISE_SIZE}.0).r;
2059
+ return (floor(v * 255.0 + 0.5) + 0.5) / 256.0;
2060
+ }
2061
+
2062
+ // gammaTone: v is a gamma-encoded brightness, so the linear-light option
2063
+ // applies. A ramp position (see toneOf) is not a brightness and skips it.
2064
+ float quantize(float v, float t, bool gammaTone) {
2065
+ float n = max(u_levels, 2.0) - 1.0;
2066
+ v = clamp(v + u_bias, 0.0, 1.0);
2067
+ // Optional linear-light thresholding: physically correct brightness, at
2068
+ // the cost of starving dark gradients (sRGB 0-0.25 is linear 0-0.05, where
2069
+ // a 4x4 matrix has no level at all). Off by default; see SPEC 9.1.
2070
+ bool lin = gammaTone && u_linear > 0.5;
2071
+ if (lin) v = toLinear(vec3(v)).r;
2072
+ float q = floor(v * n + t) / n;
2073
+ if (lin) q = toSrgb(vec3(q)).r;
2074
+ return q;
2075
+ }
2076
+
2077
+ void main() {
2078
+ vec3 c = cellSource().rgb;
2079
+
2080
+ // Shimmer: every step, slide the whole pattern by a hashed whole number of
2081
+ // cells. Each frame stays spatially identical (still Bayer, still blue
2082
+ // noise); only its position jumps, which reads as film grain rather than
2083
+ // as a crawling pattern. loopStep makes it exactly periodic.
2084
+ vec2 idx = cellIndex();
2085
+ if (u_shimmer > 0.0) {
2086
+ idx += floor(hash22(vec2(loopStep(u_shimmer), u_seed) + 0.5) * ${BLUE_NOISE_SIZE}.0);
2087
+ }
2088
+ float t = threshold(idx);
2089
+
2090
+ vec3 color;
2091
+ if (u_colorMode < 0.5) {
2092
+ // Source colors: one shared threshold, quantized per channel.
2093
+ if (u_invert > 0.5) c = 1.0 - c;
2094
+ color = vec3(quantize(c.r, t, true), quantize(c.g, t, true), quantize(c.b, t, true));
2095
+ } else {
2096
+ color = colorize(quantize(toneOf(c), t, !toneIsRamp()), c);
2097
+ }
2098
+ gl_FragColor = vec4(color, 1.0);
2099
+ }
2100
+ `;
2101
+ const dither = {
2102
+ id: "dither",
2103
+ label: "Dither",
2104
+ grid: {
2105
+ cell: (p) => [p.size, p.size],
2106
+ fragment: CELL_FRAGMENT$2
2107
+ },
2108
+ textures: [{
2109
+ uniform: "u_noise",
2110
+ key: () => "blue-noise",
2111
+ build: () => ({
2112
+ source: {
2113
+ data: blueNoiseBytes(),
2114
+ width: BLUE_NOISE_SIZE,
2115
+ height: BLUE_NOISE_SIZE
2116
+ },
2117
+ filter: "nearest",
2118
+ wrap: "repeat"
2119
+ })
2120
+ }],
2121
+ params: [
2122
+ {
2123
+ key: "pattern",
2124
+ label: "Pattern",
2125
+ type: "enum",
2126
+ options: [
2127
+ {
2128
+ value: "bayer2",
2129
+ label: "Bayer 2x2"
2130
+ },
2131
+ {
2132
+ value: "bayer4",
2133
+ label: "Bayer 4x4"
2134
+ },
2135
+ {
2136
+ value: "bayer8",
2137
+ label: "Bayer 8x8"
2138
+ },
2139
+ {
2140
+ value: "blueNoise",
2141
+ label: "Blue noise"
2142
+ }
2143
+ ],
2144
+ default: "bayer4"
2145
+ },
2146
+ {
2147
+ key: "size",
2148
+ label: "Size",
2149
+ min: 1,
2150
+ max: 16,
2151
+ step: 1,
2152
+ default: 4
2153
+ },
2154
+ {
2155
+ key: "levels",
2156
+ label: "Levels",
2157
+ min: 2,
2158
+ max: 8,
2159
+ step: 1,
2160
+ default: 2
2161
+ },
2162
+ {
2163
+ key: "bias",
2164
+ label: "Bias",
2165
+ min: -.5,
2166
+ max: .5,
2167
+ step: .01,
2168
+ default: 0
2169
+ },
2170
+ {
2171
+ key: "linear",
2172
+ label: "Linear light",
2173
+ type: "bool",
2174
+ default: false
2175
+ },
2176
+ {
2177
+ key: "shimmer",
2178
+ label: "Shimmer",
2179
+ min: 0,
2180
+ max: 12,
2181
+ step: 1,
2182
+ default: 0
2183
+ },
2184
+ ...colorModeParams({ mode: "source" })
2185
+ ],
2186
+ randomParams(rand) {
2187
+ const patterns = [
2188
+ "bayer2",
2189
+ "bayer4",
2190
+ "bayer8",
2191
+ "blueNoise"
2192
+ ];
2193
+ const modes = [
2194
+ "source",
2195
+ "duotone",
2196
+ "palette"
2197
+ ];
2198
+ return {
2199
+ pattern: patterns[Math.floor(rand() * patterns.length)],
2200
+ size: Math.round(2 + rand() * 6),
2201
+ levels: Math.round(2 + rand() * 3),
2202
+ bias: 0,
2203
+ linear: false,
2204
+ shimmer: 0,
2205
+ colorMode: modes[Math.floor(rand() * modes.length)],
2206
+ ink: "#111111",
2207
+ paper: "#f4f1ea",
2208
+ invert: false
2209
+ };
2210
+ }
2211
+ };
2212
+
2213
+ //#endregion
2214
+ //#region src/effects/halftone.ts
2215
+ const ROW = .8660254;
2216
+ const CELL_FRAGMENT$1 = `
2217
+ uniform float u_contrast;
2218
+ ${COLOR_MODE}
2219
+
2220
+ void main() {
2221
+ vec3 sc = cellSource().rgb;
2222
+
2223
+ // Ink amount. Halftone sizes its shapes by brightness in every color mode,
2224
+ // so this is luma even where toneOf() would return a ramp position.
2225
+ float tone = luma(sc);
2226
+ if (u_invert > 0.5) tone = 1.0 - tone;
2227
+ float ink = clamp((1.0 - tone - 0.5) * u_contrast + 0.5, 0.0, 1.0);
2228
+
2229
+ // Ink color. Duotone inks every shape with u_ink (colorize() would mix
2230
+ // toward paper by tone, which is dither's meaning of duotone). Palette
2231
+ // colors by the un-inverted tone, so Invert flips sizes and leaves colors.
2232
+ vec3 color = sc;
2233
+ if (u_colorMode > 1.5) color = palette(toneIsRamp() ? rampPosition(sc) : luma(sc));
2234
+ else if (u_colorMode > 0.5) color = u_ink;
2235
+
2236
+ gl_FragColor = vec4(color, ink);
2237
+ }
2238
+ `;
2239
+ const DRAW_FRAGMENT$1 = `
2240
+ uniform float u_grid;
2241
+ uniform float u_shape;
2242
+ uniform float u_radius;
2243
+ uniform float u_softness;
2244
+ uniform float u_pulse;
2245
+ ${COLOR_MODE}
2246
+
2247
+ const float ROW = ${ROW};
2248
+
2249
+ vec4 lattice(vec2 k) {
2250
+ vec2 kk = clamp(k, vec2(0.0), u_gridSize - 1.0);
2251
+ return texture2D(u_cells, (kk + 0.5) / u_gridSize);
2252
+ }
2253
+
2254
+ // The shape covering lattice coordinate g most, blended over paper, in
2255
+ // linear light. \`aa\` is the width, in lattice units, edges are ramped over.
2256
+ vec3 shade(vec2 g, float aa, float w) {
2257
+ bool hex = u_grid > 0.5;
2258
+ bool line = u_shape > 0.5 && u_shape < 1.5;
2259
+ // Texel units -> lattice units (1 = the pitch the user set as Size).
2260
+ vec2 unit = hex ? vec2(0.5, ROW) : vec2(1.0);
2261
+ float stride = hex ? 2.0 : 1.0;
2262
+ float j0 = floor(g.y);
2263
+ float bestMask = 0.0;
2264
+ vec3 bestInk = vec3(0.0);
2265
+
2266
+ for (int dj = -1; dj <= 1; dj++) {
2267
+ float j = j0 + float(dj);
2268
+ // Hex: lattice points sit where column + row is even.
2269
+ float par = hex ? mod(j, 2.0) : 0.0;
2270
+ float dy = (g.y - (j + 0.5)) * unit.y;
2271
+
2272
+ if (line) {
2273
+ // Lines run along the lattice rows. Their width is interpolated between
2274
+ // the two lattice points that bracket this pixel, so it varies smoothly
2275
+ // instead of stepping once per cell.
2276
+ float kl = stride * floor((g.x - 0.5 - par) / stride) + par;
2277
+ float f = (g.x - 0.5 - kl) / stride;
2278
+ vec4 v = mix(lattice(vec2(kl, j)), lattice(vec2(kl + stride, j)), f);
2279
+ float swell = 1.0 + 0.25 * u_pulse * sin(w * u_time + 0.22 * (j - 0.5 * u_gridSize.y) + u_seed);
2280
+ float extent = min(0.5 * v.a * unit.y * u_radius * swell, 1.0);
2281
+ float ramp = aa + u_softness * extent;
2282
+ float mask = extent <= 0.0 ? 0.0 : clamp((extent - abs(dy)) / ramp + 0.5, 0.0, 1.0);
2283
+ if (mask > bestMask) { bestMask = mask; bestInk = v.rgb; }
2284
+ continue;
2285
+ }
2286
+
2287
+ float k0 = stride * floor((g.x - 0.5 - par) / stride + 0.5) + par;
2288
+ for (int di = -1; di <= 1; di++) {
2289
+ vec2 k = vec2(k0 + stride * float(di), j);
2290
+ vec4 v = lattice(k);
2291
+ vec2 d = vec2((g.x - (k.x + 0.5)) * unit.x, dy);
2292
+ vec2 id = k - 0.5 * u_gridSize;
2293
+ float swell = 1.0 + 0.25 * u_pulse * sin(w * u_time + dot(id * unit, vec2(0.35, 0.22)) + u_seed);
2294
+
2295
+ float dist;
2296
+ float extent;
2297
+ if (u_shape < 0.5) {
2298
+ // Dot: area pi * rho^2 out of a cell of area 1 (square) or ROW (hex).
2299
+ dist = length(d);
2300
+ extent = sqrt(v.a * (hex ? ROW : 1.0) / 3.14159265);
2301
+ } else {
2302
+ dist = max(abs(d.x), abs(d.y));
2303
+ extent = 0.5 * sqrt(v.a);
2304
+ }
2305
+ extent = min(extent * u_radius * swell, 1.0);
2306
+
2307
+ // Coverage of a straight edge by a box is linear in the distance to
2308
+ // it. Softness widens that ramp into a blur.
2309
+ float ramp = aa + u_softness * extent;
2310
+ float mask = extent <= 0.0 ? 0.0 : clamp((extent - dist) / ramp + 0.5, 0.0, 1.0);
2311
+ if (mask > bestMask) { bestMask = mask; bestInk = v.rgb; }
2312
+ }
2313
+ }
2314
+ return mix(toLinear(u_paper), toLinear(bestInk), bestMask);
2315
+ }
2316
+
2317
+ void main() {
2318
+ float pitchRef = u_grid > 0.5 ? u_cellRef.x * 2.0 : u_cellRef.x;
2319
+ // One output pixel, in lattice units.
2320
+ float px = 1.0 / (pitchRef * u_outputScale);
2321
+
2322
+ // Pulse: every shape swells and shrinks, phase-shifted across the lattice
2323
+ // so the swell travels through the frame instead of throbbing in lockstep.
2324
+ // Snapped to whole cycles per loop and floored at one: unlike a generator's
2325
+ // slow secondary swell, this is the effect's only motion, so freezing it at
2326
+ // short loops (loopFreq's choice) would read as the knob being broken.
2327
+ float w = 0.8;
2328
+ if (u_loop > 0.0) w = TAU * max(1.0, floor(w * u_loop / TAU + 0.5)) / u_loop;
2329
+
2330
+ // Supersampled: n x n samples per pixel, each ramping its edges over 1/n
2331
+ // of a pixel. One sample with a one-pixel linear ramp is exact only for a
2332
+ // straight edge parallel to the pixel grid; on a 5px preview dot every
2333
+ // edge pixel is curved and diagonal, and the error showed up as the
2334
+ // preview disagreeing with its own downscaled export. Small shapes get
2335
+ // 4x4 (they are the ones that need it, and a frame of small shapes is a
2336
+ // small frame or a cheap one); large shapes get 2x2. Averaged in linear
2337
+ // light, so fine dots in a small preview have the brightness of the export
2338
+ // seen from afar.
2339
+ float n = pitchRef * u_outputScale < 20.0 ? 4.0 : 2.0;
2340
+ vec3 acc = vec3(0.0);
2341
+ for (int sy = 0; sy < 4; sy++) {
2342
+ for (int sx = 0; sx < 4; sx++) {
2343
+ if (float(sx) >= n || float(sy) >= n) continue;
2344
+ vec2 o = (vec2(float(sx), float(sy)) + 0.5) / n - 0.5;
2345
+ acc += shade(cellCoordAt(gl_FragCoord.xy + o), px / n, w);
2346
+ }
2347
+ }
2348
+ gl_FragColor = vec4(toSrgb(acc / (n * n)), 1.0);
2349
+ }
2350
+ `;
2351
+ function cellOf$1(p) {
2352
+ const size = p.size;
2353
+ return p.grid === "hex" ? [size / 2, size * ROW] : [size, size];
2354
+ }
2355
+ const halftone = {
2356
+ id: "halftone",
2357
+ label: "Halftone",
2358
+ fragment: DRAW_FRAGMENT$1,
2359
+ grid: {
2360
+ cell: cellOf$1,
2361
+ lattice: (p) => ({ angle: p.angle }),
2362
+ fragment: CELL_FRAGMENT$1
2363
+ },
2364
+ params: [
2365
+ {
2366
+ key: "grid",
2367
+ label: "Grid",
2368
+ type: "enum",
2369
+ options: [{
2370
+ value: "square",
2371
+ label: "Square"
2372
+ }, {
2373
+ value: "hex",
2374
+ label: "Hex"
2375
+ }],
2376
+ default: "square"
2377
+ },
2378
+ {
2379
+ key: "shape",
2380
+ label: "Shape",
2381
+ type: "enum",
2382
+ options: [
2383
+ {
2384
+ value: "dot",
2385
+ label: "Dot"
2386
+ },
2387
+ {
2388
+ value: "line",
2389
+ label: "Line"
2390
+ },
2391
+ {
2392
+ value: "square",
2393
+ label: "Square"
2394
+ }
2395
+ ],
2396
+ default: "dot"
2397
+ },
2398
+ {
2399
+ key: "size",
2400
+ label: "Size",
2401
+ min: 6,
2402
+ max: 160,
2403
+ step: .5,
2404
+ default: 28
2405
+ },
2406
+ {
2407
+ key: "angle",
2408
+ label: "Angle",
2409
+ min: 0,
2410
+ max: 180,
2411
+ step: 1,
2412
+ default: 45
2413
+ },
2414
+ {
2415
+ key: "radius",
2416
+ label: "Radius",
2417
+ min: .2,
2418
+ max: 1.5,
2419
+ step: .01,
2420
+ default: 1
2421
+ },
2422
+ {
2423
+ key: "softness",
2424
+ label: "Softness",
2425
+ min: 0,
2426
+ max: 1,
2427
+ step: .01,
2428
+ default: 0
2429
+ },
2430
+ {
2431
+ key: "contrast",
2432
+ label: "Contrast",
2433
+ min: 0,
2434
+ max: 2,
2435
+ step: .01,
2436
+ default: 1
2437
+ },
2438
+ {
2439
+ key: "pulse",
2440
+ label: "Pulse",
2441
+ min: 0,
2442
+ max: 1,
2443
+ step: .01,
2444
+ default: 0
2445
+ },
2446
+ ...colorModeParams({
2447
+ mode: "duotone",
2448
+ paperAlways: true
2449
+ })
2450
+ ],
2451
+ randomParams(rand) {
2452
+ const shapes = [
2453
+ "dot",
2454
+ "dot",
2455
+ "line",
2456
+ "square"
2457
+ ];
2458
+ const modes = [
2459
+ "source",
2460
+ "duotone",
2461
+ "palette"
2462
+ ];
2463
+ return {
2464
+ grid: rand() < .5 ? "square" : "hex",
2465
+ shape: shapes[Math.floor(rand() * shapes.length)],
2466
+ size: Math.round(14 + rand() * 50),
2467
+ angle: Math.round(rand() * 180),
2468
+ radius: .8 + rand() * .5,
2469
+ softness: rand() < .7 ? 0 : rand() * .5,
2470
+ contrast: 1,
2471
+ pulse: 0,
2472
+ colorMode: modes[Math.floor(rand() * modes.length)],
2473
+ ink: "#111111",
2474
+ paper: "#f4f1ea",
2475
+ invert: false
2476
+ };
2477
+ }
2478
+ };
2479
+
2480
+ //#endregion
2481
+ //#region src/effects/asciiAtlas.ts
2482
+ /** Cell width / height. Close to a monospace font's advance at line-height 1.2. */
2483
+ const CELL_ASPECT = .6;
2484
+ /** Glyphs are measured, and by default rasterized, at this cell height. */
2485
+ const MEASURE_HEIGHT = 64;
2486
+ const MAX_GLYPH_HEIGHT = 256;
2487
+ const CHARSETS = {
2488
+ standard: " .:-=+*#%@",
2489
+ dense: " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
2490
+ minimal: " .:oO@",
2491
+ binary: " 01",
2492
+ katakana: " ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン",
2493
+ blocks: "012345"
2494
+ };
2495
+ const BLOCK_LEVELS = [
2496
+ 0,
2497
+ .12,
2498
+ .28,
2499
+ .48,
2500
+ .72,
2501
+ 1
2502
+ ];
2503
+ function cellWidth(height) {
2504
+ return Math.max(2, Math.round(height * CELL_ASPECT));
2505
+ }
2506
+ /** Font size and baseline that fit the font's full line box (not any single
2507
+ * glyph's bounding box) into a cell. One shared baseline for every glyph:
2508
+ * centring each glyph on its own bounds would float "." and "_" to mid-cell. */
2509
+ function fitFont(ctx, fontFamily, width, height) {
2510
+ ctx.font = `100px ${fontFamily}`;
2511
+ const m = ctx.measureText("M");
2512
+ const ascent = (m.fontBoundingBoxAscent || 80) / 100;
2513
+ const descent = (m.fontBoundingBoxDescent || 20) / 100;
2514
+ const advance = (m.width || 60) / 100;
2515
+ const fontPx = Math.min(height / (ascent + descent), width / advance);
2516
+ return {
2517
+ fontPx,
2518
+ baseline: (height - (ascent + descent) * fontPx) / 2 + ascent * fontPx
2519
+ };
2520
+ }
2521
+ /** Draws one glyph, white on transparent, into the cell at (x, y). A glyph
2522
+ * wider than the cell (a fallback font for katakana, say) is squeezed to fit
2523
+ * rather than clipped. */
2524
+ function drawGlyph(ctx, info, index, x, y, w, h, metrics) {
2525
+ ctx.fillStyle = "#ffffff";
2526
+ if (info.procedural) {
2527
+ const k = Math.sqrt(BLOCK_LEVELS[index]);
2528
+ ctx.fillRect(x + w * (1 - k) / 2, y + h * (1 - k) / 2, w * k, h * k);
2529
+ return;
2530
+ }
2531
+ const ch = info.glyphs[index];
2532
+ const advance = ctx.measureText(ch).width;
2533
+ ctx.save();
2534
+ ctx.beginPath();
2535
+ ctx.rect(x, y, w, h);
2536
+ ctx.clip();
2537
+ ctx.translate(x + w / 2, y + metrics.baseline);
2538
+ if (advance > w) ctx.scale(w / advance, 1);
2539
+ ctx.textAlign = "center";
2540
+ ctx.textBaseline = "alphabetic";
2541
+ ctx.fillText(ch, 0, 0);
2542
+ ctx.restore();
2543
+ }
2544
+ /** A transparent canvas. Text drawn on an OPAQUE canvas can get LCD subpixel
2545
+ * antialiasing, which would bake color fringes into the atlas; on a
2546
+ * transparent one browsers fall back to grayscale, and we read alpha only. */
2547
+ function makeCanvas(width, height) {
2548
+ const canvas = document.createElement("canvas");
2549
+ canvas.width = width;
2550
+ canvas.height = height;
2551
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
2552
+ if (!ctx) throw new Error("[instantshader] 2D context unavailable for the ASCII atlas");
2553
+ return [canvas, ctx];
2554
+ }
2555
+ /** Sorts by coverage, drops near-duplicates, normalizes to the densest.
2556
+ * Exported for tests. */
2557
+ function orderByCoverage(glyphs, raw) {
2558
+ const order = glyphs.map((g, i) => ({
2559
+ g,
2560
+ c: raw[i]
2561
+ })).sort((a, b) => a.c - b.c);
2562
+ const max = Math.max(order[order.length - 1]?.c ?? 0, 1e-6);
2563
+ const out = [];
2564
+ for (const e of order) {
2565
+ const c = e.c / max;
2566
+ if (out.length > 0 && c - out[out.length - 1].c < .004) continue;
2567
+ out.push({
2568
+ g: e.g,
2569
+ c
2570
+ });
2571
+ }
2572
+ return {
2573
+ glyphs: out.map((e) => e.g),
2574
+ coverage: out.map((e) => e.c)
2575
+ };
2576
+ }
2577
+ const infoCache = /* @__PURE__ */ new Map();
2578
+ /** The charset's glyphs ordered by measured ink coverage. Cached per
2579
+ * charset + font: this is the frozen ordering the spec asks for. */
2580
+ function charsetInfo(charset, fontFamily) {
2581
+ const key = `${charset}|${fontFamily}`;
2582
+ const hit = infoCache.get(key);
2583
+ if (hit) return hit;
2584
+ let info;
2585
+ if (charset === "blocks") info = {
2586
+ glyphs: CHARSETS.blocks.split(""),
2587
+ coverage: BLOCK_LEVELS.slice(),
2588
+ procedural: true
2589
+ };
2590
+ else {
2591
+ const chars = Array.from(new Set(Array.from(CHARSETS[charset] ?? CHARSETS.standard)));
2592
+ const h = MEASURE_HEIGHT;
2593
+ const w = cellWidth(h);
2594
+ const [, ctx] = makeCanvas(w, h);
2595
+ const metrics = fitFont(ctx, fontFamily, w, h);
2596
+ ctx.font = `${metrics.fontPx}px ${fontFamily}`;
2597
+ const probe = {
2598
+ glyphs: chars,
2599
+ coverage: [],
2600
+ procedural: false
2601
+ };
2602
+ const alphaOf = (index) => {
2603
+ ctx.clearRect(0, 0, w, h);
2604
+ drawGlyph(ctx, probe, index, 0, 0, w, h, metrics);
2605
+ const rgba = ctx.getImageData(0, 0, w, h).data;
2606
+ const a = new Uint8ClampedArray(w * h);
2607
+ for (let i = 0; i < a.length; i++) a[i] = rgba[i * 4 + 3];
2608
+ return a;
2609
+ };
2610
+ probe.glyphs = [...chars, "￿"];
2611
+ const tofu = alphaOf(chars.length);
2612
+ probe.glyphs = chars;
2613
+ const kept = [];
2614
+ const raw = [];
2615
+ chars.forEach((ch, i) => {
2616
+ const a = alphaOf(i);
2617
+ let sum = 0;
2618
+ let same = true;
2619
+ for (let p = 0; p < a.length; p++) {
2620
+ sum += a[p];
2621
+ if (a[p] !== tofu[p]) same = false;
2622
+ }
2623
+ if (same && ch !== " " && sum > 0) return;
2624
+ kept.push(ch);
2625
+ raw.push(sum / (255 * a.length));
2626
+ });
2627
+ if (kept.length < 2) info = charset === "standard" ? {
2628
+ glyphs: [" ", "#"],
2629
+ coverage: [0, 1],
2630
+ procedural: false
2631
+ } : charsetInfo("standard", fontFamily);
2632
+ else info = {
2633
+ ...orderByCoverage(kept, raw),
2634
+ procedural: false
2635
+ };
2636
+ }
2637
+ infoCache.set(key, info);
2638
+ return info;
2639
+ }
2640
+ /**
2641
+ * The tone -> glyph lookup: 256x1 RGBA, one texel per 8-bit tone.
2642
+ * R = index of the densest glyph not denser than the tone, G = the next
2643
+ * glyph up, B = how far the tone sits between the two. Placing glyphs by
2644
+ * their measured coverage, not by their position in the list, is what keeps
2645
+ * tone even when a ramp's steps are not. Exported for tests.
2646
+ */
2647
+ function toneLookup(coverage) {
2648
+ const out = new Uint8Array(256 * 4);
2649
+ const last = coverage.length - 1;
2650
+ let lo = 0;
2651
+ for (let i = 0; i < 256; i++) {
2652
+ const v = i / 255;
2653
+ while (lo < last && coverage[lo + 1] <= v) lo++;
2654
+ const hi = Math.min(lo + 1, last);
2655
+ const span = coverage[hi] - coverage[lo];
2656
+ const f = span > 0 ? Math.min(1, Math.max(0, (v - coverage[lo]) / span)) : 0;
2657
+ out[i * 4] = lo;
2658
+ out[i * 4 + 1] = hi;
2659
+ out[i * 4 + 2] = Math.round(f * 255);
2660
+ out[i * 4 + 3] = 255;
2661
+ }
2662
+ return out;
2663
+ }
2664
+ /** Height glyphs are rasterized at for a given output cell height. Cells
2665
+ * under 64px all share the 64px atlas and the shader minifies it with a box
2666
+ * of taps, so a small preview shows the SAME letterforms as the export,
2667
+ * filtered, instead of separately hinted 11px ones. */
2668
+ function atlasGlyphHeight(cellPxHeight) {
2669
+ if (cellPxHeight <= MEASURE_HEIGHT) return MEASURE_HEIGHT;
2670
+ return Math.min(MAX_GLYPH_HEIGHT, Math.round(cellPxHeight));
2671
+ }
2672
+ function buildAtlas(charset, fontFamily, glyphHeight$1) {
2673
+ const info = charsetInfo(charset, fontFamily);
2674
+ const count = info.glyphs.length;
2675
+ const gh = glyphHeight$1;
2676
+ const gw = cellWidth(gh);
2677
+ const cw = gw + 2;
2678
+ const chh = gh + 2;
2679
+ const cols = Math.ceil(Math.sqrt(count));
2680
+ const rows = Math.ceil(count / cols);
2681
+ const [canvas, ctx] = makeCanvas(cols * cw, rows * chh);
2682
+ const metrics = fitFont(ctx, fontFamily, gw, gh);
2683
+ ctx.font = `${metrics.fontPx}px ${fontFamily}`;
2684
+ for (let i = 0; i < count; i++) {
2685
+ const x = i % cols * cw + 1;
2686
+ const y = Math.floor(i / cols) * chh + 1;
2687
+ drawGlyph(ctx, info, i, x, y, gw, gh, metrics);
2688
+ }
2689
+ return {
2690
+ source: canvas,
2691
+ filter: "linear",
2692
+ wrap: "clamp",
2693
+ uniforms: {
2694
+ u_atlasGrid: [cols, rows],
2695
+ u_atlasSize: [canvas.width, canvas.height],
2696
+ u_glyphPx: [gw, gh],
2697
+ u_glyphCount: count
2698
+ }
2699
+ };
2700
+ }
2701
+
2702
+ //#endregion
2703
+ //#region src/effects/ascii.ts
2704
+ const CELL_FRAGMENT = `
2705
+ uniform float u_smooth;
2706
+ uniform float u_cycle;
2707
+ uniform float u_cycleAmount;
2708
+ uniform float u_glyphCount;
2709
+ uniform sampler2D u_lut;
2710
+ ${COLOR_MODE}
2711
+
2712
+ void main() {
2713
+ vec3 sc = cellSource().rgb;
2714
+ float tone = luma(sc);
2715
+ if (u_invert > 0.5) tone = 1.0 - tone;
2716
+
2717
+ // The lookup places glyphs by measured ink coverage: R = the glyph just
2718
+ // below this tone, G = the one just above, B = how far between them.
2719
+ vec4 l = texture2D(u_lut, vec2((floor(tone * 255.0 + 0.5) + 0.5) / 256.0, 0.5));
2720
+ float lo = floor(l.r * 255.0 + 0.5);
2721
+ float hi = floor(l.g * 255.0 + 0.5);
2722
+ // Smooth: dither between the two neighbouring glyphs, so a gradient does
2723
+ // not band into flat fields of one character. The threshold is a per-cell
2724
+ // hash, not a Bayer matrix: with only ~10 glyphs an ordered pattern shows
2725
+ // up as mechanical "%=%=%=" stripes, where noise reads as hand-set type.
2726
+ float t = u_smooth > 0.5 ? 0.02 + 0.96 * hash12(cellIndex() + 0.5) : 0.5;
2727
+ float glyph = l.b > t ? hi : lo;
2728
+
2729
+ // Cycle: every step each cell re-rolls its glyph among neighbours of
2730
+ // similar weight, so the picture flickers but stays readable. Blank cells
2731
+ // stay blank, or dark areas would fill with sparks.
2732
+ if (u_cycle > 0.0 && glyph > 0.5) {
2733
+ float h = hash12(cellIndex() + vec2(37.0, 17.0) * loopStep(u_cycle) + u_seed);
2734
+ float span = u_cycleAmount * u_glyphCount * 0.25;
2735
+ glyph = clamp(glyph + floor((h * 2.0 - 1.0) * span + 0.5), 1.0, u_glyphCount - 1.0);
2736
+ }
2737
+
2738
+ // Glyph color. Tone already lives in the glyph's density, so source colors
2739
+ // are lifted most of the way to full brightness; using them as they are
2740
+ // would darken every dark area twice.
2741
+ vec3 color = sc;
2742
+ if (u_colorMode < 0.5) {
2743
+ float peak = max(max(sc.r, sc.g), max(sc.b, 1e-3));
2744
+ color = mix(sc, sc / peak, 0.75);
2745
+ } else if (u_colorMode < 1.5) {
2746
+ color = u_ink;
2747
+ } else {
2748
+ color = palette(toneIsRamp() ? rampPosition(sc) : luma(sc));
2749
+ }
2750
+
2751
+ gl_FragColor = vec4(color, glyph / 255.0);
2752
+ }
2753
+ `;
2754
+ const DRAW_FRAGMENT = `
2755
+ uniform sampler2D u_atlas;
2756
+ uniform vec2 u_atlasGrid; // glyph columns, rows
2757
+ uniform vec2 u_atlasSize; // atlas size in texels
2758
+ uniform vec2 u_glyphPx; // one glyph's size in atlas texels (without gutter)
2759
+ ${COLOR_MODE}
2760
+
2761
+ void main() {
2762
+ vec4 cell = cellValue(cellIndexAt());
2763
+ float glyph = floor(cell.a * 255.0 + 0.5);
2764
+ vec2 slot = vec2(mod(glyph, u_atlasGrid.x), floor(glyph / u_atlasGrid.x));
2765
+ // Top-left texel of the glyph, inside its one-texel gutter. The atlas is a
2766
+ // canvas uploaded unflipped, so its row 0 is the top.
2767
+ vec2 origin = slot * (u_glyphPx + 2.0) + 1.0;
2768
+
2769
+ // The atlas is rasterized at 64px or more and minified here by a box of
2770
+ // LINEAR taps sized to this output pixel's footprint: a small preview sees
2771
+ // the same letterforms as the export, filtered, rather than separately
2772
+ // hinted tiny ones. One tap per atlas texel the pixel spans, up to 6x6.
2773
+ vec2 local = cellLocal();
2774
+ vec2 texelsPerPx = u_glyphPx / cellPx();
2775
+ float n = clamp(ceil(max(texelsPerPx.x, texelsPerPx.y)), 1.0, 6.0);
2776
+ float a = 0.0;
2777
+ for (int j = 0; j < 6; j++) {
2778
+ for (int i = 0; i < 6; i++) {
2779
+ if (float(i) >= n || float(j) >= n) continue;
2780
+ vec2 o = ((vec2(float(i), float(j)) + 0.5) / n - 0.5) / cellPx();
2781
+ vec2 q = clamp(local + o, 0.0, 1.0);
2782
+ vec2 texel = origin + vec2(q.x, 1.0 - q.y) * u_glyphPx;
2783
+ a += texture2D(u_atlas, texel / u_atlasSize).a;
2784
+ }
2785
+ }
2786
+ a /= n * n;
2787
+
2788
+ vec3 color = toSrgb(mix(toLinear(u_paper), toLinear(cell.rgb), a));
2789
+ gl_FragColor = vec4(color, 1.0);
2790
+ }
2791
+ `;
2792
+ function cellOf(p) {
2793
+ const size = p.size;
2794
+ return [Math.max(1, Math.round(size * CELL_ASPECT)), size];
2795
+ }
2796
+ function glyphHeight(env) {
2797
+ return atlasGlyphHeight(cellOf(env.params)[1] * env.outputScale);
2798
+ }
2799
+ const ascii = {
2800
+ id: "ascii",
2801
+ label: "ASCII",
2802
+ fragment: DRAW_FRAGMENT,
2803
+ grid: {
2804
+ cell: cellOf,
2805
+ fragment: CELL_FRAGMENT
2806
+ },
2807
+ textures: [{
2808
+ uniform: "u_atlas",
2809
+ key: (env) => `${env.params.charset}|${env.fontFamily}|${glyphHeight(env)}`,
2810
+ build: (env) => buildAtlas(env.params.charset, env.fontFamily, glyphHeight(env))
2811
+ }, {
2812
+ uniform: "u_lut",
2813
+ key: (env) => `${env.params.charset}|${env.fontFamily}`,
2814
+ build: (env) => ({
2815
+ source: {
2816
+ data: toneLookup(charsetInfo(env.params.charset, env.fontFamily).coverage),
2817
+ width: 256,
2818
+ height: 1
2819
+ },
2820
+ filter: "nearest",
2821
+ wrap: "clamp"
2822
+ })
2823
+ }],
2824
+ params: [
2825
+ {
2826
+ key: "charset",
2827
+ label: "Characters",
2828
+ type: "enum",
2829
+ options: [
2830
+ {
2831
+ value: "standard",
2832
+ label: "Standard"
2833
+ },
2834
+ {
2835
+ value: "dense",
2836
+ label: "Dense"
2837
+ },
2838
+ {
2839
+ value: "blocks",
2840
+ label: "Blocks"
2841
+ },
2842
+ {
2843
+ value: "minimal",
2844
+ label: "Minimal"
2845
+ },
2846
+ {
2847
+ value: "binary",
2848
+ label: "Binary"
2849
+ },
2850
+ {
2851
+ value: "katakana",
2852
+ label: "Katakana"
2853
+ }
2854
+ ],
2855
+ default: "standard"
2856
+ },
2857
+ {
2858
+ key: "size",
2859
+ label: "Size",
2860
+ min: 8,
2861
+ max: 96,
2862
+ step: 1,
2863
+ default: 24
2864
+ },
2865
+ {
2866
+ key: "smooth",
2867
+ label: "Smooth",
2868
+ type: "bool",
2869
+ default: true
2870
+ },
2871
+ {
2872
+ key: "cycle",
2873
+ label: "Cycle",
2874
+ min: 0,
2875
+ max: 12,
2876
+ step: 1,
2877
+ default: 0
2878
+ },
2879
+ {
2880
+ key: "cycleAmount",
2881
+ label: "Cycle amount",
2882
+ min: 0,
2883
+ max: 1,
2884
+ step: .01,
2885
+ default: .3
2886
+ },
2887
+ ...colorModeParams({
2888
+ mode: "source",
2889
+ ink: "#e8ffe8",
2890
+ paper: "#000000",
2891
+ paperAlways: true
2892
+ })
2893
+ ],
2894
+ randomParams(rand) {
2895
+ const sets = [
2896
+ "standard",
2897
+ "dense",
2898
+ "blocks",
2899
+ "minimal",
2900
+ "binary"
2901
+ ];
2902
+ const modes = [
2903
+ "source",
2904
+ "duotone",
2905
+ "palette"
2906
+ ];
2907
+ return {
2908
+ charset: sets[Math.floor(rand() * sets.length)],
2909
+ size: Math.round(14 + rand() * 30),
2910
+ smooth: true,
2911
+ cycle: 0,
2912
+ cycleAmount: .3,
2913
+ colorMode: modes[Math.floor(rand() * modes.length)],
2914
+ ink: "#e8ffe8",
2915
+ paper: "#000000",
2916
+ invert: false
2917
+ };
2918
+ }
2919
+ };
2920
+
1663
2921
  //#endregion
1664
2922
  //#region src/registry.ts
1665
2923
  const shaders = [
@@ -1675,6 +2933,18 @@ function getShader(id) {
1675
2933
  return shaders.find((s) => s.id === id);
1676
2934
  }
1677
2935
 
2936
+ //#endregion
2937
+ //#region src/effectRegistry.ts
2938
+ const effects = [
2939
+ pixelate,
2940
+ dither,
2941
+ halftone,
2942
+ ascii
2943
+ ];
2944
+ function getEffect(id) {
2945
+ return effects.find((e) => e.id === id);
2946
+ }
2947
+
1678
2948
  //#endregion
1679
2949
  //#region src/palette.ts
1680
2950
  /** Number of texels in the output ramp. Matches a typical 1D LUT texture size:
@@ -1796,7 +3066,7 @@ function lerpHue(h0, c0, h1, c1, t) {
1796
3066
  return start + delta * t;
1797
3067
  }
1798
3068
  /** Parses a "#rrggbb" (or "rrggbb") hex string into 0-1 sRGB components. */
1799
- function hexToRgb(hex) {
3069
+ function hexToRgb$1(hex) {
1800
3070
  const h = hex.replace("#", "");
1801
3071
  return [
1802
3072
  parseInt(h.substring(0, 2), 16) / 255,
@@ -1822,7 +3092,7 @@ function buildPaletteRamp(colors) {
1822
3092
  const stops = colors.length === 1 ? [colors[0], colors[0]] : colors;
1823
3093
  const stopCount = stops.length;
1824
3094
  const stopsOklch = stops.map((hex) => {
1825
- const [r, g, b] = hexToRgb(hex);
3095
+ const [r, g, b] = hexToRgb$1(hex);
1826
3096
  return oklabToOklch(...rgbToOklab(r, g, b));
1827
3097
  });
1828
3098
  const breakpoints = [];
@@ -1855,7 +3125,7 @@ function clamp255(v) {
1855
3125
  }
1856
3126
 
1857
3127
  //#endregion
1858
- //#region src/renderer.ts
3128
+ //#region src/preamble.ts
1859
3129
  /**
1860
3130
  * GLSL preamble prepended to every shader's fragment source. Declares the
1861
3131
  * uniforms/varying every InstantShader shader can rely on, plus two helpers:
@@ -1971,35 +3241,29 @@ float loopFreq(float w) {
1971
3241
  }
1972
3242
  `;
1973
3243
  /** Fullscreen-triangle-strip vertex shader. Four vertices covering [-1,1]^2,
1974
- * with v_uv carrying the matching 0-1 UV for the fragment shader. */
3244
+ * with v_uv carrying the matching 0-1 UV for the fragment shader.
3245
+ *
3246
+ * v_uv = u_uvMatrix * a_position + u_uvOffset. For every ordinary pass that
3247
+ * is (0.5, 0, 0, 0.5) and (0.5, 0.5), i.e. exactly the a_position * 0.5 + 0.5
3248
+ * this shader always computed. The stack changes it only while rendering a
3249
+ * source straight into a grid effect's cell buffer: that grid can overhang
3250
+ * the frame (partial cells at the edges) or be rotated (a halftone lattice),
3251
+ * and each cell has to see its own centre as v_uv. See gridUvTransform(). */
1975
3252
  const VERTEX_SHADER = `attribute vec2 a_position;
3253
+ uniform vec4 u_uvMatrix;
3254
+ uniform vec2 u_uvOffset;
1976
3255
  varying vec2 v_uv;
1977
3256
  void main() {
1978
- v_uv = a_position * 0.5 + 0.5;
3257
+ v_uv = mat2(u_uvMatrix.xy, u_uvMatrix.zw) * a_position + u_uvOffset;
1979
3258
  gl_Position = vec4(a_position, 0.0, 1.0);
1980
3259
  }
1981
3260
  `;
1982
- const QUAD_VERTICES = new Float32Array([
1983
- -1,
1984
- -1,
1985
- 1,
1986
- -1,
1987
- -1,
1988
- 1,
1989
- 1,
1990
- 1
1991
- ]);
1992
- /** Non-negative modulo. JS's `%` is a remainder operator, not a mathematical
1993
- * mod — `-5 % 100` is `-5`, not `95`. u_time/u_seed are documented to land
1994
- * in [0, m), so a negative timeMs (e.g. from an out-of-range seek) or a
1995
- * negative seed must still floor into that range rather than going negative
1996
- * on the GPU. */
1997
- function floorMod(value, m) {
1998
- return (value % m + m) % m;
1999
- }
3261
+
3262
+ //#endregion
3263
+ //#region src/gl.ts
2000
3264
  /** Compiles one shader stage, logging the info log and throwing on failure
2001
- * so a broken ShaderDef fails loudly at mount time instead of rendering a
2002
- * blank canvas. */
3265
+ * so a broken def fails loudly at mount time instead of rendering a blank
3266
+ * canvas. */
2003
3267
  function compileShader(gl, type, source) {
2004
3268
  const shader = gl.createShader(type);
2005
3269
  if (!shader) throw new Error("[instantshader] gl.createShader returned null");
@@ -2020,6 +3284,7 @@ function linkProgram(gl, vertexShader, fragmentShader) {
2020
3284
  if (!program) throw new Error("[instantshader] gl.createProgram returned null");
2021
3285
  gl.attachShader(program, vertexShader);
2022
3286
  gl.attachShader(program, fragmentShader);
3287
+ gl.bindAttribLocation(program, 0, "a_position");
2023
3288
  gl.linkProgram(program);
2024
3289
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
2025
3290
  const info = gl.getProgramInfoLog(program);
@@ -2031,6 +3296,323 @@ function linkProgram(gl, vertexShader, fragmentShader) {
2031
3296
  }
2032
3297
  return program;
2033
3298
  }
3299
+ function createProgram(gl, vertexSource, fragmentSource) {
3300
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
3301
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
3302
+ const program = linkProgram(gl, vs, fs);
3303
+ gl.deleteShader(vs);
3304
+ gl.deleteShader(fs);
3305
+ const locs = /* @__PURE__ */ new Map();
3306
+ return {
3307
+ program,
3308
+ loc(name) {
3309
+ let l = locs.get(name);
3310
+ if (l === void 0) {
3311
+ l = gl.getUniformLocation(program, name);
3312
+ locs.set(name, l);
3313
+ }
3314
+ return l;
3315
+ },
3316
+ dispose() {
3317
+ gl.deleteProgram(program);
3318
+ }
3319
+ };
3320
+ }
3321
+ /**
3322
+ * Creates an RGBA8 render target. RGBA8 is the one format WebGL1 guarantees
3323
+ * is renderable. NPOT sizes are the norm here, which WebGL1 only allows with
3324
+ * CLAMP_TO_EDGE and no mipmaps.
3325
+ */
3326
+ function createTarget(gl, width, height, filter) {
3327
+ const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
3328
+ const maxRb = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE);
3329
+ const limit = Math.min(maxTex, maxRb);
3330
+ if (width > limit || height > limit) throw new Error(`[instantshader] a ${width}x${height} render target exceeds this GPU's limit of ${limit}px (MAX_TEXTURE_SIZE ${maxTex}, MAX_RENDERBUFFER_SIZE ${maxRb})`);
3331
+ const texture = gl.createTexture();
3332
+ const framebuffer = gl.createFramebuffer();
3333
+ if (!texture || !framebuffer) throw new Error("[instantshader] failed to allocate a render target");
3334
+ const f = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
3335
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3336
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3337
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3338
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, f);
3339
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, f);
3340
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
3341
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
3342
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
3343
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
3344
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
3345
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
3346
+ gl.deleteTexture(texture);
3347
+ gl.deleteFramebuffer(framebuffer);
3348
+ throw new Error(`[instantshader] ${width}x${height} framebuffer incomplete (status 0x${status.toString(16)})`);
3349
+ }
3350
+ return {
3351
+ texture,
3352
+ framebuffer,
3353
+ width,
3354
+ height
3355
+ };
3356
+ }
3357
+ function deleteTarget(gl, target) {
3358
+ if (!target) return;
3359
+ gl.deleteTexture(target.texture);
3360
+ gl.deleteFramebuffer(target.framebuffer);
3361
+ }
3362
+
3363
+ //#endregion
3364
+ //#region src/grid.ts
3365
+ const REF_WIDTH = 1920;
3366
+ const REF_HEIGHT = 1080;
3367
+ /** Device pixels per reference pixel. 1 at 1920x1080, 2 at 3840x2160. */
3368
+ function outputScale(width, height) {
3369
+ return Math.max(width / REF_WIDTH, height / REF_HEIGHT);
3370
+ }
3371
+ /**
3372
+ * The frame's size in reference pixels. The dominant axis is returned as the
3373
+ * exact constant rather than as width / (width / 1920): that round trip can
3374
+ * land on 1919.9999999 or 1920.0000001, and gridFor() takes a ceil of it.
3375
+ */
3376
+ function refSize(width, height) {
3377
+ if (width * REF_HEIGHT >= height * REF_WIDTH) return [REF_WIDTH, height * REF_WIDTH / width];
3378
+ return [width * REF_HEIGHT / height, REF_HEIGHT];
3379
+ }
3380
+ /**
3381
+ * The grid a cell size produces on a frame. Centred on the frame with a cell
3382
+ * boundary at the centre, so cols and rows are always even and a different
3383
+ * aspect ratio crops the grid evenly. Depends on the frame's aspect ratio
3384
+ * and the cell size only, never on its pixel dimensions.
3385
+ *
3386
+ * Two flavours:
3387
+ *
3388
+ * - default: cells are whole reference pixels and axis-aligned, so that
3389
+ * 1080p-class and 4K-class exports get whole output pixels per cell.
3390
+ * - `lattice`: for effects that draw soft, antialiased shapes (halftone).
3391
+ * The grid may be rotated and its pitch need not be whole, since nothing
3392
+ * snaps to pixels; it is sized to cover the rotated frame plus a margin of
3393
+ * two cells, because a shape can spill into its neighbours' cells.
3394
+ */
3395
+ function gridFor(width, height, cell, lattice) {
3396
+ const ref = refSize(width, height);
3397
+ const s = outputScale(width, height);
3398
+ if (!lattice) {
3399
+ const nx$1 = Math.max(1, Math.round(cell[0]));
3400
+ const ny$1 = Math.max(1, Math.round(cell[1]));
3401
+ const cols$1 = 2 * Math.max(1, Math.ceil(ref[0] / (2 * nx$1) - 1e-6));
3402
+ const rows$1 = 2 * Math.max(1, Math.ceil(ref[1] / (2 * ny$1) - 1e-6));
3403
+ return {
3404
+ cols: cols$1,
3405
+ rows: rows$1,
3406
+ cell: [nx$1, ny$1],
3407
+ ref,
3408
+ pxPerCell: [nx$1 * s, ny$1 * s],
3409
+ angle: 0,
3410
+ extent: [cols$1 * nx$1 / ref[0], rows$1 * ny$1 / ref[1]]
3411
+ };
3412
+ }
3413
+ const nx = Math.max(.5, cell[0]);
3414
+ const ny = Math.max(.5, cell[1]);
3415
+ const angle = lattice.angle * Math.PI / 180;
3416
+ const c = Math.abs(Math.cos(angle));
3417
+ const sn = Math.abs(Math.sin(angle));
3418
+ const hx = (c * ref[0] + sn * ref[1]) / 2;
3419
+ const hy = (sn * ref[0] + c * ref[1]) / 2;
3420
+ const cols = 2 * (Math.ceil(hx / nx - 1e-6) + 2);
3421
+ const rows = 2 * (Math.ceil(hy / ny - 1e-6) + 2);
3422
+ return {
3423
+ cols,
3424
+ rows,
3425
+ cell: [nx, ny],
3426
+ ref,
3427
+ pxPerCell: [nx * s, ny * s],
3428
+ angle,
3429
+ extent: [cols * nx / ref[0], rows * ny / ref[1]]
3430
+ };
3431
+ }
3432
+ /**
3433
+ * How the vertex shader must map the fullscreen quad so that, while rendering
3434
+ * INTO a grid's cell buffer, v_uv is each cell's centre in 0-1 frame UV:
3435
+ * v_uv = matrix * a_position + offset, with a_position in [-1,1]^2.
3436
+ * `cellU` / `cellV` are one cell's edge vectors in frame UV, for passes that
3437
+ * spread taps across the cell.
3438
+ */
3439
+ function gridUvTransform(grid) {
3440
+ const c = Math.cos(grid.angle);
3441
+ const s = Math.sin(grid.angle);
3442
+ const [nx, ny] = grid.cell;
3443
+ const [rx, ry] = grid.ref;
3444
+ const hx = grid.cols * nx / 2;
3445
+ const hy = grid.rows * ny / 2;
3446
+ return {
3447
+ matrix: [
3448
+ c * hx / rx,
3449
+ s * hx / ry,
3450
+ -s * hy / rx,
3451
+ c * hy / ry
3452
+ ],
3453
+ offset: [.5, .5],
3454
+ cellU: [c * nx / rx, s * nx / ry],
3455
+ cellV: [-s * ny / rx, c * ny / ry]
3456
+ };
3457
+ }
3458
+
3459
+ //#endregion
3460
+ //#region src/effectParams.ts
3461
+ const warned = /* @__PURE__ */ new Set();
3462
+ /** Logs once per distinct problem. A bad value arrives on every slider
3463
+ * drag and every frame otherwise. */
3464
+ function warnOnce(message) {
3465
+ if (warned.has(message)) return;
3466
+ warned.add(message);
3467
+ console.warn(`[instantshader] ${message}`);
3468
+ }
3469
+ const HEX = /^#?[0-9a-fA-F]{6}$/;
3470
+ /** "#rrggbb" to gamma-encoded sRGB in 0-1, or null when malformed. */
3471
+ function hexToRgb(hex) {
3472
+ if (!HEX.test(hex)) return null;
3473
+ const h = hex.replace("#", "");
3474
+ return [
3475
+ parseInt(h.substring(0, 2), 16) / 255,
3476
+ parseInt(h.substring(2, 4), 16) / 255,
3477
+ parseInt(h.substring(4, 6), 16) / 255
3478
+ ];
3479
+ }
3480
+ function isValid(def, value) {
3481
+ switch (def.type) {
3482
+ case "enum": return typeof value === "string" && def.options.some((o) => o.value === value);
3483
+ case "bool": return typeof value === "boolean";
3484
+ case "color": return typeof value === "string" && HEX.test(value);
3485
+ default: return typeof value === "number" && Number.isFinite(value);
3486
+ }
3487
+ }
3488
+ /**
3489
+ * Builds a full param record (every key present) from a possibly partial
3490
+ * override map. A value of the wrong type, an unknown enum value or a
3491
+ * malformed color falls back to the param's default and warns once, so a
3492
+ * stale saved design degrades instead of throwing.
3493
+ */
3494
+ function resolveEffectParams(def, overrides) {
3495
+ const resolved = {};
3496
+ for (const p of def.params) {
3497
+ const value = overrides?.[p.key];
3498
+ if (value === void 0) resolved[p.key] = p.default;
3499
+ else if (isValid(p, value)) resolved[p.key] = value;
3500
+ else {
3501
+ warnOnce(`${def.id}.${p.key}: ignoring invalid value ${JSON.stringify(value)}`);
3502
+ resolved[p.key] = p.default;
3503
+ }
3504
+ }
3505
+ return resolved;
3506
+ }
3507
+ /**
3508
+ * What a resolved param becomes on the GPU: one float for float/enum/bool,
3509
+ * three for a color. Enums upload the option's index, which is what lets
3510
+ * their stored value stay a stable string.
3511
+ */
3512
+ function paramUniform(def, value) {
3513
+ switch (def.type) {
3514
+ case "enum": return Math.max(0, def.options.findIndex((o) => o.value === value));
3515
+ case "bool": return value ? 1 : 0;
3516
+ case "color": return hexToRgb(value) ?? [
3517
+ 0,
3518
+ 0,
3519
+ 0
3520
+ ];
3521
+ default: return value;
3522
+ }
3523
+ }
3524
+
3525
+ //#endregion
3526
+ //#region src/params.ts
3527
+ /** Builds a full param record (every ParamDef.key present) from a possibly
3528
+ * partial override map, falling back to each param's declared default. */
3529
+ function resolveParams(def, overrides) {
3530
+ const resolved = {};
3531
+ for (const paramDef of def.params) {
3532
+ const value = overrides?.[paramDef.key];
3533
+ resolved[paramDef.key] = value === void 0 ? paramDef.default : value;
3534
+ }
3535
+ return resolved;
3536
+ }
3537
+
3538
+ //#endregion
3539
+ //#region src/stack.ts
3540
+ const QUAD_VERTICES = new Float32Array([
3541
+ -1,
3542
+ -1,
3543
+ 1,
3544
+ -1,
3545
+ -1,
3546
+ 1,
3547
+ 1,
3548
+ 1
3549
+ ]);
3550
+ /** Media wider or taller than this is scaled down through a 2D canvas before
3551
+ * upload. 4096 is the texture size ~99.9% of WebGL1 devices support. */
3552
+ const MAX_MEDIA_SIZE = 4096;
3553
+ /**
3554
+ * Maps a 0-1 frame UV to the media and returns its color flattened over the
3555
+ * background. Y is flipped here instead of with UNPACK_FLIP_Y_WEBGL, and
3556
+ * alpha is flattened here instead of with UNPACK_PREMULTIPLY_ALPHA_WEBGL:
3557
+ * both pixel-store flags are unspecified for VideoFrame and deprecated for
3558
+ * non-DOM sources in Firefox.
3559
+ */
3560
+ const MEDIA_SAMPLER = `
3561
+ uniform sampler2D u_media;
3562
+ uniform vec2 u_mediaScale; // frame UV -> media UV, about the centre
3563
+ uniform vec3 u_background;
3564
+ vec3 pictureAt(vec2 uv) {
3565
+ vec2 m = (uv - 0.5) * u_mediaScale + 0.5;
3566
+ float inside = step(0.0, m.x) * step(m.x, 1.0) * step(0.0, m.y) * step(m.y, 1.0);
3567
+ vec4 c = texture2D(u_media, vec2(m.x, 1.0 - m.y));
3568
+ return mix(u_background, c.rgb, c.a * inside);
3569
+ }
3570
+ `;
3571
+ const FRAME_SAMPLER = `
3572
+ uniform sampler2D u_source;
3573
+ vec3 pictureAt(vec2 uv) { return texture2D(u_source, uv).rgb; }
3574
+ `;
3575
+ const MEDIA_FRAME_FRAGMENT = `precision highp float;
3576
+ varying vec2 v_uv;
3577
+ ${MEDIA_SAMPLER}
3578
+ void main() { gl_FragColor = vec4(pictureAt(v_uv), 1.0); }
3579
+ `;
3580
+ /**
3581
+ * Averages a picture over each cell of a grid: the CELL SOURCE for a grid
3582
+ * effect that sits on media or on another effect. v_uv is remapped by the
3583
+ * vertex shader to span the grid's extent, so it is the cell's centre in
3584
+ * frame UV. The taps form a fixed pattern in picture space that does not
3585
+ * depend on the output size, which is what keeps the averages (and therefore
3586
+ * every threshold decision made from them) identical between preview and
3587
+ * export. Averaged in linear light.
3588
+ */
3589
+ function cellAverageFragment(sampler, taps) {
3590
+ return `precision highp float;
3591
+ varying vec2 v_uv;
3592
+ uniform vec2 u_cellU; // one cell's edge vectors in frame UV (the grid
3593
+ uniform vec2 u_cellV; // may be rotated, so they need not be axis-aligned)
3594
+ ${COLOR_MATH}
3595
+ ${sampler}
3596
+ void main() {
3597
+ vec3 acc = vec3(0.0);
3598
+ for (int j = 0; j < ${taps}; j++) {
3599
+ for (int i = 0; i < ${taps}; i++) {
3600
+ vec2 o = (vec2(float(i), float(j)) + 0.5) / ${taps}.0 - 0.5;
3601
+ acc += toLinear(pictureAt(v_uv + o.x * u_cellU + o.y * u_cellV));
3602
+ }
3603
+ }
3604
+ gl_FragColor = vec4(toSrgb(acc / ${taps * taps}.0), 1.0);
3605
+ }
3606
+ `;
3607
+ }
3608
+ /** Non-negative modulo. JS's `%` is a remainder operator, not a mathematical
3609
+ * mod — `-5 % 100` is `-5`, not `95`. u_time/u_seed are documented to land
3610
+ * in [0, m), so a negative timeMs (e.g. from an out-of-range seek) or a
3611
+ * negative seed must still floor into that range rather than going negative
3612
+ * on the GPU. */
3613
+ function floorMod(value, m) {
3614
+ return (value % m + m) % m;
3615
+ }
2034
3616
  /** Normalizes a loop period to the "off" sentinel the GLSL side expects.
2035
3617
  * Non-finite and non-positive values all mean "don't loop", so callers can
2036
3618
  * pass through user input without pre-validating it. */
@@ -2038,30 +3620,32 @@ function normalizeLoop(seconds) {
2038
3620
  if (seconds === void 0 || !Number.isFinite(seconds) || seconds <= 0) return 0;
2039
3621
  return seconds;
2040
3622
  }
2041
- function createRenderer(opts) {
2042
- const { canvas, shader } = opts;
3623
+ function mediaSize(media) {
3624
+ const m = media;
3625
+ return [m.videoWidth || m.naturalWidth || m.displayWidth || m.width || 1, m.videoHeight || m.naturalHeight || m.displayHeight || m.height || 1];
3626
+ }
3627
+ function createStackRenderer(opts) {
3628
+ const { canvas } = opts;
2043
3629
  let colors = opts.colors;
2044
- let params = opts.params;
2045
- const seed = opts.seed;
3630
+ const seed = opts.seed ?? 0;
2046
3631
  let loopSeconds = normalizeLoop(opts.loopSeconds);
3632
+ const background = hexToRgb(opts.background ?? "#000000") ?? [
3633
+ 0,
3634
+ 0,
3635
+ 0
3636
+ ];
3637
+ const fontFamily = opts.fontFamily ?? "ui-monospace, Menlo, Consolas, monospace";
2047
3638
  const glOrNull = canvas.getContext("webgl", {
2048
3639
  preserveDrawingBuffer: true,
2049
3640
  antialias: false
2050
3641
  });
2051
3642
  if (!glOrNull) throw new Error("[instantshader] failed to acquire a WebGL context");
2052
3643
  const gl = glOrNull;
2053
- const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
2054
- const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, BASE_UNIFORMS + shader.fragment);
2055
- const program = linkProgram(gl, vertexShader, fragmentShader);
2056
- gl.deleteShader(vertexShader);
2057
- gl.deleteShader(fragmentShader);
2058
- gl.useProgram(program);
2059
3644
  const quadBuffer = gl.createBuffer();
2060
3645
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
2061
3646
  gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW);
2062
- const positionLoc = gl.getAttribLocation(program, "a_position");
2063
- gl.enableVertexAttribArray(positionLoc);
2064
- gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
3647
+ gl.enableVertexAttribArray(0);
3648
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
2065
3649
  const paletteTexture = gl.createTexture();
2066
3650
  gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
2067
3651
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -2074,85 +3658,444 @@ function createRenderer(opts) {
2074
3658
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, ramp);
2075
3659
  }
2076
3660
  uploadPalette(colors);
2077
- const resolutionLoc = gl.getUniformLocation(program, "u_resolution");
2078
- const timeLoc = gl.getUniformLocation(program, "u_time");
2079
- const seedLoc = gl.getUniformLocation(program, "u_seed");
2080
- const loopLoc = gl.getUniformLocation(program, "u_loop");
2081
- const paletteLoc = gl.getUniformLocation(program, "u_palette");
2082
- const paramLocs = /* @__PURE__ */ new Map();
2083
- for (const paramDef of shader.params) paramLocs.set(paramDef.key, gl.getUniformLocation(program, `u_${paramDef.key}`));
2084
- function applyParams() {
2085
- for (const paramDef of shader.params) {
2086
- const loc = paramLocs.get(paramDef.key);
2087
- if (loc == null) continue;
2088
- const value = params[paramDef.key] ?? paramDef.default;
2089
- gl.uniform1f(loc, value);
3661
+ let source = opts.source;
3662
+ let generatorProgram = null;
3663
+ let generatorParams = {};
3664
+ let mediaTexture = null;
3665
+ let mediaDims = [1, 1];
3666
+ let mediaFrameProgram = null;
3667
+ let mediaCellProgram = null;
3668
+ let downsampleProgram = null;
3669
+ function uploadMedia() {
3670
+ if (source.kind !== "media") return;
3671
+ let media = source.media;
3672
+ let [w, h] = mediaSize(media);
3673
+ if (Math.max(w, h) > MAX_MEDIA_SIZE) {
3674
+ const k = MAX_MEDIA_SIZE / Math.max(w, h);
3675
+ const scaled = document.createElement("canvas");
3676
+ scaled.width = Math.max(1, Math.round(w * k));
3677
+ scaled.height = Math.max(1, Math.round(h * k));
3678
+ const ctx = scaled.getContext("2d");
3679
+ if (!ctx) throw new Error("[instantshader] 2D context unavailable for media downscale");
3680
+ ctx.drawImage(media, 0, 0, scaled.width, scaled.height);
3681
+ media = scaled;
3682
+ w = scaled.width;
3683
+ h = scaled.height;
3684
+ }
3685
+ mediaDims = [w, h];
3686
+ if (!mediaTexture) {
3687
+ mediaTexture = gl.createTexture();
3688
+ gl.bindTexture(gl.TEXTURE_2D, mediaTexture);
3689
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3690
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3691
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3692
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2090
3693
  }
3694
+ gl.bindTexture(gl.TEXTURE_2D, mediaTexture);
3695
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
3696
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
3697
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, media);
2091
3698
  }
2092
- function renderAt(timeMs) {
2093
- gl.viewport(0, 0, canvas.width, canvas.height);
2094
- gl.useProgram(program);
2095
- gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
2096
- const timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
2097
- gl.uniform1f(timeLoc, timeSec);
2098
- gl.uniform1f(seedLoc, floorMod(seed, 100));
2099
- gl.uniform1f(loopLoc, loopSeconds);
2100
- gl.activeTexture(gl.TEXTURE0);
2101
- gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
2102
- gl.uniform1i(paletteLoc, 0);
2103
- applyParams();
2104
- gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
2105
- gl.enableVertexAttribArray(positionLoc);
2106
- gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
3699
+ function applySource(next) {
3700
+ const prev = source;
3701
+ source = next;
3702
+ if (next.kind === "generator") {
3703
+ if (!generatorProgram || prev.kind !== "generator" || prev.shader !== next.shader) {
3704
+ generatorProgram?.dispose();
3705
+ generatorProgram = createProgram(gl, VERTEX_SHADER, BASE_UNIFORMS + next.shader.fragment);
3706
+ }
3707
+ generatorParams = resolveParams(next.shader, next.params);
3708
+ } else uploadMedia();
3709
+ }
3710
+ applySource(opts.source);
3711
+ let layers = [];
3712
+ function effectSource(preamble, fragment) {
3713
+ return BASE_UNIFORMS + EFFECT_COMMON + preamble + fragment;
3714
+ }
3715
+ function disposeLayer(state) {
3716
+ state.soft?.dispose();
3717
+ state.cell?.dispose();
3718
+ state.draw?.dispose();
3719
+ for (const t of state.textures) gl.deleteTexture(t.texture);
3720
+ }
3721
+ function buildLayer(layer) {
3722
+ const def = layer.effect;
3723
+ const state = {
3724
+ def,
3725
+ params: resolveEffectParams(def, layer.params),
3726
+ enabled: layer.enabled ?? true,
3727
+ soft: null,
3728
+ cell: null,
3729
+ draw: null,
3730
+ textures: (def.textures ?? []).map((t) => ({
3731
+ def: t,
3732
+ key: "",
3733
+ texture: null,
3734
+ uniforms: {}
3735
+ }))
3736
+ };
3737
+ if ((def.textures?.length ?? 0) > 4) throw new Error(`[instantshader] effect "${def.id}" declares more than 4 textures`);
3738
+ if (def.grid) {
3739
+ state.cell = createProgram(gl, VERTEX_SHADER, effectSource(CELL_PREAMBLE, def.grid.fragment));
3740
+ state.draw = createProgram(gl, VERTEX_SHADER, effectSource(DRAW_PREAMBLE, def.fragment ?? COVERAGE_DRAW));
3741
+ } else if (def.fragment) state.soft = createProgram(gl, VERTEX_SHADER, effectSource(SOFT_PREAMBLE, def.fragment));
3742
+ else throw new Error(`[instantshader] effect "${def.id}" has neither a fragment nor a grid`);
3743
+ return state;
3744
+ }
3745
+ function applyEffects(next) {
3746
+ const built = [];
3747
+ for (let i = 0; i < next.length; i++) {
3748
+ const old = layers[i];
3749
+ if (old && old.def === next[i].effect) {
3750
+ old.params = resolveEffectParams(old.def, next[i].params);
3751
+ old.enabled = next[i].enabled ?? true;
3752
+ built.push(old);
3753
+ } else built.push(buildLayer(next[i]));
3754
+ }
3755
+ for (let i = 0; i < layers.length; i++) if (!built.includes(layers[i])) disposeLayer(layers[i]);
3756
+ layers = built;
3757
+ }
3758
+ applyEffects(opts.effects ?? []);
3759
+ let frameTargets = [null, null];
3760
+ const cellTargets = /* @__PURE__ */ new Map();
3761
+ const cellTargetsUsed = /* @__PURE__ */ new Set();
3762
+ function frameTarget(i) {
3763
+ const t = frameTargets[i];
3764
+ if (t && t.width === canvas.width && t.height === canvas.height) return t;
3765
+ deleteTarget(gl, t);
3766
+ const created = createTarget(gl, canvas.width, canvas.height, "linear");
3767
+ frameTargets[i] = created;
3768
+ return created;
3769
+ }
3770
+ function cellTargetPair(grid) {
3771
+ const key = `${grid.cols}x${grid.rows}`;
3772
+ cellTargetsUsed.add(key);
3773
+ let pair = cellTargets.get(key);
3774
+ if (!pair) {
3775
+ pair = [createTarget(gl, grid.cols, grid.rows, "nearest"), createTarget(gl, grid.cols, grid.rows, "nearest")];
3776
+ cellTargets.set(key, pair);
3777
+ }
3778
+ return pair;
3779
+ }
3780
+ /** Frees cell buffers the frame just rendered did not use, so dragging a
3781
+ * size slider does not accumulate one pair per grid size visited. */
3782
+ function pruneCellTargets() {
3783
+ for (const [key, pair] of cellTargets) {
3784
+ if (cellTargetsUsed.has(key)) continue;
3785
+ deleteTarget(gl, pair[0]);
3786
+ deleteTarget(gl, pair[1]);
3787
+ cellTargets.delete(key);
3788
+ }
3789
+ cellTargetsUsed.clear();
3790
+ }
3791
+ let timeSec = 0;
3792
+ function bindOutput(target) {
3793
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.framebuffer : null);
3794
+ gl.viewport(0, 0, target ? target.width : canvas.width, target ? target.height : canvas.height);
3795
+ }
3796
+ function bindTexture(unit, texture) {
3797
+ gl.activeTexture(gl.TEXTURE0 + unit);
3798
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3799
+ }
3800
+ const IDENTITY_UV = {
3801
+ matrix: [
3802
+ .5,
3803
+ 0,
3804
+ 0,
3805
+ .5
3806
+ ],
3807
+ offset: [.5, .5]
3808
+ };
3809
+ function useProgram(p, resolution, uv = IDENTITY_UV) {
3810
+ gl.useProgram(p.program);
3811
+ gl.uniform2f(p.loc("u_resolution"), resolution[0], resolution[1]);
3812
+ gl.uniform1f(p.loc("u_time"), timeSec);
3813
+ gl.uniform1f(p.loc("u_seed"), floorMod(seed, 100));
3814
+ gl.uniform1f(p.loc("u_loop"), loopSeconds);
3815
+ gl.uniform4f(p.loc("u_uvMatrix"), uv.matrix[0], uv.matrix[1], uv.matrix[2], uv.matrix[3]);
3816
+ gl.uniform2f(p.loc("u_uvOffset"), uv.offset[0], uv.offset[1]);
3817
+ bindTexture(0, paletteTexture);
3818
+ gl.uniform1i(p.loc("u_palette"), 0);
3819
+ }
3820
+ function draw() {
2107
3821
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
2108
3822
  }
2109
- function setColors(next) {
2110
- colors = next;
2111
- uploadPalette(colors);
3823
+ function setMediaUniforms(p) {
3824
+ if (source.kind !== "media") return;
3825
+ const frameAspect = canvas.width / canvas.height;
3826
+ const mediaAspect = mediaDims[0] / mediaDims[1];
3827
+ const scale = ((source.fit ?? "cover") === "cover" ? frameAspect > mediaAspect : frameAspect < mediaAspect) ? [1, mediaAspect / frameAspect] : [frameAspect / mediaAspect, 1];
3828
+ bindTexture(1, mediaTexture);
3829
+ gl.uniform1i(p.loc("u_media"), 1);
3830
+ gl.uniform2f(p.loc("u_mediaScale"), scale[0], scale[1]);
3831
+ gl.uniform3f(p.loc("u_background"), background[0], background[1], background[2]);
3832
+ }
3833
+ function applyGeneratorParams(p) {
3834
+ if (source.kind !== "generator") return;
3835
+ for (const paramDef of source.shader.params) {
3836
+ const loc = p.loc(`u_${paramDef.key}`);
3837
+ if (loc == null) continue;
3838
+ gl.uniform1f(loc, generatorParams[paramDef.key] ?? paramDef.default);
3839
+ }
3840
+ }
3841
+ /** Source at frame size, into a frame target or (null) the canvas. */
3842
+ function renderSourceToFrame(target) {
3843
+ bindOutput(target);
3844
+ if (source.kind === "generator") {
3845
+ const p = generatorProgram;
3846
+ useProgram(p, [canvas.width, canvas.height]);
3847
+ applyGeneratorParams(p);
3848
+ } else {
3849
+ mediaFrameProgram ??= createProgram(gl, VERTEX_SHADER, MEDIA_FRAME_FRAGMENT);
3850
+ useProgram(mediaFrameProgram, [canvas.width, canvas.height]);
3851
+ setMediaUniforms(mediaFrameProgram);
3852
+ }
3853
+ draw();
3854
+ }
3855
+ /**
3856
+ * Source straight into a cell buffer, skipping the frame-size render.
3857
+ *
3858
+ * A generator is simply evaluated once per cell. It is told the frame is
3859
+ * `ref / cell` pixels large: the aspect ratio is unchanged, so the
3860
+ * composition is identical, and generators that antialias an edge over
3861
+ * "one pixel" now antialias over one cell, which is the correct area
3862
+ * average. That resolution depends on the grid alone, so the cell values
3863
+ * are the same at every output size. Grain is switched off for this pass.
3864
+ */
3865
+ function renderSourceToCells(grid, target) {
3866
+ bindOutput(target);
3867
+ const uv = gridUvTransform(grid);
3868
+ if (source.kind === "generator") {
3869
+ const p = generatorProgram;
3870
+ const n = Math.max(grid.cell[0], grid.cell[1]);
3871
+ useProgram(p, [grid.ref[0] / n, grid.ref[1] / n], uv);
3872
+ applyGeneratorParams(p);
3873
+ gl.uniform1f(p.loc("u_grain"), 0);
3874
+ } else {
3875
+ mediaCellProgram ??= createProgram(gl, VERTEX_SHADER, cellAverageFragment(MEDIA_SAMPLER, 8));
3876
+ const p = mediaCellProgram;
3877
+ useProgram(p, [canvas.width, canvas.height], uv);
3878
+ setMediaUniforms(p);
3879
+ gl.uniform2f(p.loc("u_cellU"), uv.cellU[0], uv.cellU[1]);
3880
+ gl.uniform2f(p.loc("u_cellV"), uv.cellV[0], uv.cellV[1]);
3881
+ }
3882
+ draw();
3883
+ }
3884
+ /** A frame-size layer output averaged into a cell buffer. */
3885
+ function downsampleToCells(input, grid, target) {
3886
+ bindOutput(target);
3887
+ downsampleProgram ??= createProgram(gl, VERTEX_SHADER, cellAverageFragment(FRAME_SAMPLER, 4));
3888
+ const p = downsampleProgram;
3889
+ const uv = gridUvTransform(grid);
3890
+ useProgram(p, [canvas.width, canvas.height], uv);
3891
+ bindTexture(1, input.texture);
3892
+ gl.uniform1i(p.loc("u_source"), 1);
3893
+ gl.uniform2f(p.loc("u_cellU"), uv.cellU[0], uv.cellU[1]);
3894
+ gl.uniform2f(p.loc("u_cellV"), uv.cellV[0], uv.cellV[1]);
3895
+ draw();
2112
3896
  }
2113
- function setParams(next) {
2114
- params = next;
3897
+ function uploadTextureData(texture, data) {
3898
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3899
+ const wrap = data.wrap === "repeat" ? gl.REPEAT : gl.CLAMP_TO_EDGE;
3900
+ const filter = data.filter === "nearest" ? gl.NEAREST : gl.LINEAR;
3901
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
3902
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
3903
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
3904
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
3905
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
3906
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
3907
+ const src = data.source;
3908
+ if ("data" in src && src.data instanceof Uint8Array) {
3909
+ const format = src.data.length === src.width * src.height ? gl.LUMINANCE : gl.RGBA;
3910
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
3911
+ gl.texImage2D(gl.TEXTURE_2D, 0, format, src.width, src.height, 0, format, gl.UNSIGNED_BYTE, src.data);
3912
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
3913
+ } else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
2115
3914
  }
2116
- function setLoopSeconds(seconds) {
2117
- loopSeconds = normalizeLoop(seconds);
3915
+ function layerGrid(state) {
3916
+ const g = state.def.grid;
3917
+ return gridFor(canvas.width, canvas.height, g.cell(state.params), g.lattice?.(state.params));
2118
3918
  }
2119
- function resize(width, height) {
2120
- canvas.width = width;
2121
- canvas.height = height;
3919
+ /** Rebuilds any of the layer's effect textures whose cache key changed. */
3920
+ function syncTextures(state) {
3921
+ if (state.textures.length === 0) return;
3922
+ const env = {
3923
+ params: state.params,
3924
+ width: canvas.width,
3925
+ height: canvas.height,
3926
+ outputScale: outputScale(canvas.width, canvas.height),
3927
+ fontFamily
3928
+ };
3929
+ for (const t of state.textures) {
3930
+ const key = t.def.key(env);
3931
+ if (t.texture && key === t.key) continue;
3932
+ const data = t.def.build(env);
3933
+ t.texture ??= gl.createTexture();
3934
+ uploadTextureData(t.texture, data);
3935
+ t.key = key;
3936
+ t.uniforms = data.uniforms ?? {};
3937
+ }
3938
+ }
3939
+ /** Effect uniforms: reference-frame info, the layer's params, its effect
3940
+ * textures (units 3+) and whatever uniforms those textures declared. */
3941
+ function applyEffectUniforms(p, state, grid, overGenerator) {
3942
+ const ref = refSize(canvas.width, canvas.height);
3943
+ gl.uniform1f(p.loc("u_sourceIsRamp"), overGenerator ? 1 : 0);
3944
+ gl.uniform2f(p.loc("u_refSize"), ref[0], ref[1]);
3945
+ gl.uniform1f(p.loc("u_outputScale"), outputScale(canvas.width, canvas.height));
3946
+ if (grid) {
3947
+ gl.uniform2f(p.loc("u_gridSize"), grid.cols, grid.rows);
3948
+ gl.uniform2f(p.loc("u_cellRef"), grid.cell[0], grid.cell[1]);
3949
+ gl.uniform1f(p.loc("u_gridAngle"), grid.angle);
3950
+ }
3951
+ for (const paramDef of state.def.params) {
3952
+ const loc = p.loc(`u_${paramDef.key}`);
3953
+ if (loc == null) continue;
3954
+ const v = paramUniform(paramDef, state.params[paramDef.key]);
3955
+ if (typeof v === "number") gl.uniform1f(loc, v);
3956
+ else gl.uniform3f(loc, v[0], v[1], v[2]);
3957
+ }
3958
+ state.textures.forEach((t, i) => {
3959
+ bindTexture(3 + i, t.texture);
3960
+ gl.uniform1i(p.loc(t.def.uniform), 3 + i);
3961
+ for (const [name, value] of Object.entries(t.uniforms)) {
3962
+ const loc = p.loc(name);
3963
+ if (loc == null) continue;
3964
+ if (typeof value === "number") gl.uniform1f(loc, value);
3965
+ else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
3966
+ else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
3967
+ else gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
3968
+ }
3969
+ });
3970
+ }
3971
+ function renderAt(timeMs) {
3972
+ timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
3973
+ const active = layers.filter((l) => l.enabled);
3974
+ if (active.length === 0) {
3975
+ renderSourceToFrame(null);
3976
+ return;
3977
+ }
3978
+ const frameSize = [canvas.width, canvas.height];
3979
+ let input = null;
3980
+ active.forEach((state, i) => {
3981
+ const last = i === active.length - 1;
3982
+ const outIndex = input === frameTargets[0] ? 1 : 0;
3983
+ const overGenerator = input === null && source.kind === "generator";
3984
+ syncTextures(state);
3985
+ if (state.def.grid) {
3986
+ const grid = layerGrid(state);
3987
+ const [cellIn, cellOut] = cellTargetPair(grid);
3988
+ if (input === null) renderSourceToCells(grid, cellIn);
3989
+ else downsampleToCells(input, grid, cellIn);
3990
+ const cellProgram = state.cell;
3991
+ bindOutput(cellOut);
3992
+ useProgram(cellProgram, frameSize);
3993
+ applyEffectUniforms(cellProgram, state, grid, overGenerator);
3994
+ bindTexture(1, cellIn.texture);
3995
+ gl.uniform1i(cellProgram.loc("u_source"), 1);
3996
+ draw();
3997
+ const out = last ? null : frameTarget(outIndex);
3998
+ const drawProgram = state.draw;
3999
+ bindOutput(out);
4000
+ useProgram(drawProgram, frameSize);
4001
+ applyEffectUniforms(drawProgram, state, grid, overGenerator);
4002
+ bindTexture(2, cellOut.texture);
4003
+ gl.uniform1i(drawProgram.loc("u_cells"), 2);
4004
+ draw();
4005
+ input = out;
4006
+ } else {
4007
+ if (input === null) {
4008
+ input = frameTarget(0);
4009
+ renderSourceToFrame(input);
4010
+ }
4011
+ const out = last ? null : frameTarget(input === frameTargets[0] ? 1 : 0);
4012
+ const p = state.soft;
4013
+ bindOutput(out);
4014
+ useProgram(p, frameSize);
4015
+ applyEffectUniforms(p, state, null, overGenerator);
4016
+ bindTexture(1, input.texture);
4017
+ gl.uniform1i(p.loc("u_source"), 1);
4018
+ draw();
4019
+ input = out;
4020
+ }
4021
+ });
4022
+ pruneCellTargets();
2122
4023
  }
2123
4024
  function dispose() {
4025
+ for (const state of layers) disposeLayer(state);
4026
+ layers = [];
4027
+ generatorProgram?.dispose();
4028
+ mediaFrameProgram?.dispose();
4029
+ mediaCellProgram?.dispose();
4030
+ downsampleProgram?.dispose();
4031
+ deleteTarget(gl, frameTargets[0]);
4032
+ deleteTarget(gl, frameTargets[1]);
4033
+ frameTargets = [null, null];
4034
+ for (const pair of cellTargets.values()) {
4035
+ deleteTarget(gl, pair[0]);
4036
+ deleteTarget(gl, pair[1]);
4037
+ }
4038
+ cellTargets.clear();
4039
+ gl.deleteTexture(mediaTexture);
2124
4040
  gl.deleteTexture(paletteTexture);
2125
- gl.deleteProgram(program);
2126
4041
  gl.deleteBuffer(quadBuffer);
2127
4042
  gl.getExtension("WEBGL_lose_context")?.loseContext();
2128
4043
  }
2129
4044
  return {
2130
4045
  renderAt,
2131
- setColors,
2132
- setParams,
2133
- setLoopSeconds,
2134
- resize,
4046
+ setColors(next) {
4047
+ colors = next;
4048
+ uploadPalette(colors);
4049
+ },
4050
+ setSource(next) {
4051
+ applySource(next);
4052
+ },
4053
+ setSourceParams(next) {
4054
+ if (source.kind === "generator") generatorParams = next;
4055
+ },
4056
+ refreshMedia() {
4057
+ uploadMedia();
4058
+ },
4059
+ setEffects(next) {
4060
+ applyEffects(next);
4061
+ },
4062
+ setEffectParams(index, params) {
4063
+ const state = layers[index];
4064
+ if (!state) return;
4065
+ state.params = resolveEffectParams(state.def, {
4066
+ ...state.params,
4067
+ ...params
4068
+ });
4069
+ },
4070
+ setLoopSeconds(seconds) {
4071
+ loopSeconds = normalizeLoop(seconds);
4072
+ },
4073
+ resize(width, height) {
4074
+ canvas.width = width;
4075
+ canvas.height = height;
4076
+ },
4077
+ getGridInfo() {
4078
+ const info = [];
4079
+ layers.forEach((state, index) => {
4080
+ if (!state.enabled || !state.def.grid) return;
4081
+ const grid = layerGrid(state);
4082
+ info.push({
4083
+ index,
4084
+ cols: grid.cols,
4085
+ rows: grid.rows,
4086
+ pxPerCell: grid.pxPerCell
4087
+ });
4088
+ });
4089
+ return info;
4090
+ },
2135
4091
  dispose
2136
4092
  };
2137
4093
  }
2138
4094
 
2139
- //#endregion
2140
- //#region src/params.ts
2141
- /** Builds a full param record (every ParamDef.key present) from a possibly
2142
- * partial override map, falling back to each param's declared default. */
2143
- function resolveParams(def, overrides) {
2144
- const resolved = {};
2145
- for (const paramDef of def.params) {
2146
- const value = overrides?.[paramDef.key];
2147
- resolved[paramDef.key] = value === void 0 ? paramDef.default : value;
2148
- }
2149
- return resolved;
2150
- }
2151
-
2152
4095
  //#endregion
2153
4096
  //#region src/mount.ts
2154
4097
  /**
2155
- * Mounts a live, animated gradient into `container` and returns a handle to
4098
+ * Mounts a live, animated stack (a source plus effect layers) into `container` and returns a handle to
2156
4099
  * control it. Owns a canvas (sized to the container via ResizeObserver, DPR
2157
4100
  * capped at 2 to bound fill-rate cost on high-density displays) and a RAF
2158
4101
  * loop that runs ONLY while playing — the same lifecycle used by the
@@ -2167,24 +4110,23 @@ function resolveParams(def, overrides) {
2167
4110
  * callers may mount many simultaneously-paused instances (e.g. a screenshot
2168
4111
  * grid) that must never carry a perpetual 60fps draw loop each.
2169
4112
  */
2170
- function mountGradient(container, opts) {
2171
- const def = opts.shader;
4113
+ function mountStack(container, opts) {
2172
4114
  const canvas = document.createElement("canvas");
2173
4115
  canvas.style.display = "block";
2174
4116
  canvas.style.width = "100%";
2175
4117
  canvas.style.height = "100%";
2176
4118
  container.appendChild(canvas);
2177
- let colors = opts.colors;
2178
- let params = resolveParams(def, opts.params);
4119
+ let source = opts.source;
2179
4120
  let speed = opts.speed ?? 1;
2180
- const seed = opts.seed ?? 0;
2181
- const renderer = createRenderer({
4121
+ const renderer = createStackRenderer({
2182
4122
  canvas,
2183
- shader: def,
2184
- colors,
2185
- params,
2186
- seed,
2187
- loopSeconds: opts.loopSeconds
4123
+ source,
4124
+ effects: opts.effects,
4125
+ colors: opts.colors,
4126
+ seed: opts.seed ?? 0,
4127
+ loopSeconds: opts.loopSeconds,
4128
+ background: opts.background,
4129
+ fontFamily: opts.fontFamily
2188
4130
  });
2189
4131
  let clockMs = 0;
2190
4132
  let epoch = performance.now();
@@ -2211,36 +4153,71 @@ function mountGradient(container, opts) {
2211
4153
  rafId = requestAnimationFrame(tick);
2212
4154
  }
2213
4155
  rafId = requestAnimationFrame(tick);
2214
- function applySize(cssWidth, cssHeight) {
2215
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
2216
- const width = Math.max(1, Math.round(cssWidth * dpr));
2217
- const height = Math.max(1, Math.round(cssHeight * dpr));
4156
+ /** Sets the backing store to an exact device-pixel size (DPR capped at 2). */
4157
+ function applyDeviceSize(deviceWidth, deviceHeight) {
4158
+ const dpr = window.devicePixelRatio || 1;
4159
+ const cap = dpr > 2 ? 2 / dpr : 1;
4160
+ const width = Math.max(1, Math.round(deviceWidth * cap));
4161
+ const height = Math.max(1, Math.round(deviceHeight * cap));
2218
4162
  if (canvas.width !== width || canvas.height !== height) {
2219
4163
  renderer.resize(width, height);
2220
4164
  if (!playing) renderOnce();
2221
4165
  }
2222
4166
  }
4167
+ function applyCssSize(cssWidth, cssHeight) {
4168
+ const dpr = window.devicePixelRatio || 1;
4169
+ applyDeviceSize(cssWidth * dpr, cssHeight * dpr);
4170
+ }
2223
4171
  const initialRect = container.getBoundingClientRect();
2224
- applySize(initialRect.width || 1, initialRect.height || 1);
4172
+ applyCssSize(initialRect.width || 1, initialRect.height || 1);
2225
4173
  const resizeObserver = new ResizeObserver((entries) => {
2226
- for (const entry of entries) applySize(entry.contentRect.width, entry.contentRect.height);
4174
+ for (const entry of entries) {
4175
+ const device = entry.devicePixelContentBoxSize?.[0];
4176
+ if (device) applyDeviceSize(device.inlineSize, device.blockSize);
4177
+ else applyCssSize(entry.contentRect.width, entry.contentRect.height);
4178
+ }
2227
4179
  });
2228
- resizeObserver.observe(container);
4180
+ try {
4181
+ resizeObserver.observe(container, { box: "device-pixel-content-box" });
4182
+ } catch {
4183
+ resizeObserver.observe(container);
4184
+ }
2229
4185
  return {
2230
4186
  canvas,
2231
4187
  setColors(next) {
2232
- colors = next;
2233
- renderer.setColors(colors);
4188
+ renderer.setColors(next);
4189
+ if (!playing) renderOnce();
4190
+ },
4191
+ setSource(next) {
4192
+ source = next;
4193
+ renderer.setSource(next);
2234
4194
  if (!playing) renderOnce();
2235
4195
  },
2236
- setParams(next) {
2237
- params = {
2238
- ...params,
2239
- ...next
4196
+ setSourceParams(next) {
4197
+ if (source.kind !== "generator") return;
4198
+ source = {
4199
+ ...source,
4200
+ params: {
4201
+ ...resolveParams(source.shader, source.params),
4202
+ ...next
4203
+ }
2240
4204
  };
2241
- renderer.setParams(params);
4205
+ renderer.setSourceParams(source.params);
2242
4206
  if (!playing) renderOnce();
2243
4207
  },
4208
+ setEffects(next) {
4209
+ renderer.setEffects(next);
4210
+ if (!playing) renderOnce();
4211
+ },
4212
+ setEffectParams(index, next) {
4213
+ renderer.setEffectParams(index, next);
4214
+ if (!playing) renderOnce();
4215
+ },
4216
+ refreshMedia() {
4217
+ renderer.refreshMedia();
4218
+ if (!playing) renderOnce();
4219
+ },
4220
+ getGridInfo: renderer.getGridInfo,
2244
4221
  setLoopSeconds(seconds) {
2245
4222
  renderer.setLoopSeconds(seconds);
2246
4223
  if (!playing) renderOnce();
@@ -2281,31 +4258,58 @@ function mountGradient(container, opts) {
2281
4258
  }
2282
4259
  };
2283
4260
  }
4261
+ /** mountStack() for the common case of one generator and no effects. */
4262
+ function mountGradient(container, opts) {
4263
+ const handle = mountStack(container, {
4264
+ source: {
4265
+ kind: "generator",
4266
+ shader: opts.shader,
4267
+ params: opts.params
4268
+ },
4269
+ colors: opts.colors,
4270
+ speed: opts.speed,
4271
+ seed: opts.seed,
4272
+ loopSeconds: opts.loopSeconds
4273
+ });
4274
+ return {
4275
+ canvas: handle.canvas,
4276
+ setColors: handle.setColors,
4277
+ setParams: handle.setSourceParams,
4278
+ setSpeed: handle.setSpeed,
4279
+ setLoopSeconds: handle.setLoopSeconds,
4280
+ pause: handle.pause,
4281
+ resume: handle.resume,
4282
+ seek: handle.seek,
4283
+ getTimeMs: handle.getTimeMs,
4284
+ dispose: handle.dispose
4285
+ };
4286
+ }
2284
4287
 
2285
4288
  //#endregion
2286
4289
  //#region src/frame.ts
2287
4290
  /**
2288
- * Renders a single frame into a detached (not-in-DOM) canvas at an exact
2289
- * pixel size, for export/thumbnail use cases that need a synchronous
2290
- * snapshot rather than a live animation.
4291
+ * Renders a single frame of a stack (a source plus effect layers) into a
4292
+ * detached (not-in-DOM) canvas at an exact pixel size, for export/thumbnail
4293
+ * use cases that need a synchronous snapshot rather than a live animation.
2291
4294
  *
2292
4295
  * The returned canvas is NOT disposed automatically — its GL context must
2293
4296
  * stay alive after this function returns so callers can scrape pixels from
2294
4297
  * it (toDataURL/toBlob/getImageData/drawImage). Once the caller is done
2295
4298
  * with it, release the GL context by calling the returned `dispose()`.
2296
4299
  */
2297
- function renderGradientFrame(opts) {
2298
- const def = opts.shader;
4300
+ function renderStackFrame(opts) {
2299
4301
  const canvas = document.createElement("canvas");
2300
4302
  canvas.width = opts.width;
2301
4303
  canvas.height = opts.height;
2302
- const renderer = createRenderer({
4304
+ const renderer = createStackRenderer({
2303
4305
  canvas,
2304
- shader: def,
4306
+ source: opts.source,
4307
+ effects: opts.effects,
2305
4308
  colors: opts.colors,
2306
- params: resolveParams(def, opts.params),
2307
4309
  seed: opts.seed ?? 0,
2308
- loopSeconds: opts.loopSeconds
4310
+ loopSeconds: opts.loopSeconds,
4311
+ background: opts.background,
4312
+ fontFamily: opts.fontFamily
2309
4313
  });
2310
4314
  renderer.renderAt(opts.timeMs ?? 0);
2311
4315
  const gl = canvas.getContext("webgl");
@@ -2318,6 +4322,46 @@ function renderGradientFrame(opts) {
2318
4322
  dispose: renderer.dispose
2319
4323
  };
2320
4324
  }
4325
+ /** renderStackFrame() for the common case of one generator and no effects. */
4326
+ function renderGradientFrame(opts) {
4327
+ return renderStackFrame({
4328
+ source: {
4329
+ kind: "generator",
4330
+ shader: opts.shader,
4331
+ params: opts.params
4332
+ },
4333
+ colors: opts.colors,
4334
+ seed: opts.seed,
4335
+ timeMs: opts.timeMs,
4336
+ loopSeconds: opts.loopSeconds,
4337
+ width: opts.width,
4338
+ height: opts.height
4339
+ });
4340
+ }
4341
+
4342
+ //#endregion
4343
+ //#region src/renderer.ts
4344
+ function createRenderer(opts) {
4345
+ const stack = createStackRenderer({
4346
+ canvas: opts.canvas,
4347
+ source: {
4348
+ kind: "generator",
4349
+ shader: opts.shader,
4350
+ params: opts.params
4351
+ },
4352
+ colors: opts.colors,
4353
+ seed: opts.seed,
4354
+ loopSeconds: opts.loopSeconds
4355
+ });
4356
+ return {
4357
+ renderAt: stack.renderAt,
4358
+ setColors: stack.setColors,
4359
+ setParams: stack.setSourceParams,
4360
+ setLoopSeconds: stack.setLoopSeconds,
4361
+ resize: stack.resize,
4362
+ dispose: stack.dispose
4363
+ };
4364
+ }
2321
4365
 
2322
4366
  //#endregion
2323
- export { beam, bloom, buildPaletteRamp, createRenderer, dune, flow, getShader, halo, mountGradient, renderGradientFrame, shaders, strata, whorl };
4367
+ export { ascii, beam, bloom, buildPaletteRamp, createRenderer, createStackRenderer, dither, dune, effects, flow, getEffect, getShader, gridFor, halftone, halo, mountGradient, mountStack, pixelate, renderGradientFrame, renderStackFrame, shaders, strata, whorl };