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/README.md +44 -6
- package/dist/index.d.ts +275 -5
- package/dist/index.js +2174 -130
- package/package.json +7 -3
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/
|
|
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 =
|
|
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
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
|
2002
|
-
*
|
|
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
|
|
2042
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
2063
|
-
gl.
|
|
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
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
|
2114
|
-
|
|
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
|
|
2117
|
-
|
|
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
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
|
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
|
|
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
|
|
2178
|
-
let params = resolveParams(def, opts.params);
|
|
4119
|
+
let source = opts.source;
|
|
2179
4120
|
let speed = opts.speed ?? 1;
|
|
2180
|
-
const
|
|
2181
|
-
const renderer = createRenderer({
|
|
4121
|
+
const renderer = createStackRenderer({
|
|
2182
4122
|
canvas,
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
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
|
-
|
|
2215
|
-
|
|
2216
|
-
const
|
|
2217
|
-
const
|
|
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
|
-
|
|
4172
|
+
applyCssSize(initialRect.width || 1, initialRect.height || 1);
|
|
2225
4173
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
2226
|
-
for (const entry of entries)
|
|
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
|
-
|
|
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
|
-
|
|
2233
|
-
|
|
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
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
...
|
|
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.
|
|
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
|
|
2289
|
-
* pixel size, for export/thumbnail
|
|
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
|
|
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 =
|
|
4304
|
+
const renderer = createStackRenderer({
|
|
2303
4305
|
canvas,
|
|
2304
|
-
|
|
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 };
|