PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / AutoLaunch / functions / shader-canvas.js

shader-canvas.js in Extendify 3.2.1, at src/AutoLaunch/functions/shader-canvas.js

438 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Authoring contract: `vec4 render(vec2 uv, float t)`, with everything in
3 * PRELUDE in scope. `uv` is centered and normalized by the shorter axis, `t` is
4 * seconds already scaled by the speed token. The canvas is transparent, so a
5 * shader only paints what it wants over the page color.
6 */
7
8 // The one shader the flow draws when a design names none.
9 export const DEFAULT_SHADER = `vec4 render(vec2 uv, float t) {
10 vec2 drift = vec2(sin(t * 0.26), cos(t * 0.26));
11 float dist = length(uv - drift * 0.55);
12 float falloff = smoothstep(0.95 * u_scale, 0.0, dist);
13 return vec4(u_color1, falloff * 0.32 * u_intensity);
14 }`;
15
16 const VERTEX = `#version 300 es
17 in vec2 a_position;
18 void main() {
19 gl_Position = vec4(a_position, 0.0, 1.0);
20 }`;
21
22 export const PRELUDE = `#version 300 es
23 precision highp float;
24
25 uniform vec2 u_resolution;
26 uniform vec2 u_offset;
27 uniform float u_time;
28 uniform float u_speed;
29 uniform float u_scale;
30 uniform float u_intensity;
31 uniform vec3 u_color1;
32 uniform vec3 u_color2;
33 uniform vec3 u_color3;
34 uniform sampler2D u_prev;
35 uniform float u_frame;
36
37 out vec4 fragColor;
38
39 vec2 screenUV() {
40 return gl_FragCoord.xy / u_resolution;
41 }
42
43 // The frame before this one: alpha is the shader's state, so Intensity is
44 // applied on the way to the screen and not by the shader.
45 vec4 prev(vec2 at) {
46 return texture(u_prev, at);
47 }
48
49 float hash21(vec2 p) {
50 p = fract(p * vec2(123.34, 456.21));
51 p += dot(p, p + 45.32);
52 return fract(p.x * p.y);
53 }
54
55 float noise(vec2 p) {
56 vec2 i = floor(p);
57 vec2 f = fract(p);
58 vec2 u = f * f * (3.0 - 2.0 * f);
59 return mix(
60 mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),
61 mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),
62 u.y
63 );
64 }
65
66 float fbm(vec2 p) {
67 float total = 0.0;
68 float amplitude = 0.5;
69 for (int i = 0; i < 5; i++) {
70 total += noise(p) * amplitude;
71 p *= 2.0;
72 amplitude *= 0.5;
73 }
74 return total;
75 }
76 `;
77
78 const MAIN = `
79 void main() {
80 vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y) - u_offset;
81 fragColor = render(uv, u_time * u_speed);
82 }
83 `;
84
85 const UNIFORMS = [
86 'u_resolution',
87 'u_offset',
88 'u_time',
89 'u_speed',
90 'u_scale',
91 'u_intensity',
92 'u_color1',
93 'u_color2',
94 'u_color3',
95 'u_prev',
96 'u_frame',
97 ];
98
99 // A board held between frames runs at cell resolution, not at canvas resolution.
100 const BOARD_COLUMNS = 90;
101
102 const COPY_FRAGMENT = `#version 300 es
103 precision highp float;
104 uniform sampler2D u_source;
105 uniform vec2 u_resolution;
106 uniform vec2 u_offset;
107 uniform float u_intensity;
108 out vec4 fragColor;
109 void main() {
110 fragColor = texture(u_source, gl_FragCoord.xy / u_resolution - u_offset) * vec4(1.0, 1.0, 1.0, u_intensity);
111 }
112 `;
113
114 // Full resolution: at half, a point-sized detail upscales into a smudge.
115 const RENDER_SCALE = 1;
116 const MAX_FPS = 30;
117 // No static check proves a shader is cheap enough for a given GPU, so watch it run.
118 const SLOW_FRAME_MS = (1000 / MAX_FPS) * 4;
119 const SLOW_FRAME_LIMIT = 30;
120
121 const compile = (gl, type, source) => {
122 const shader = gl.createShader(type);
123 gl.shaderSource(shader, source);
124 gl.compileShader(shader);
125 if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return { shader };
126 const log = gl.getShaderInfoLog(shader);
127 gl.deleteShader(shader);
128 return { error: log || 'Shader failed to compile.' };
129 };
130
131 export const createShaderCanvas = (canvas) => {
132 const gl = canvas.getContext('webgl2', {
133 alpha: true,
134 antialias: false,
135 depth: false,
136 stencil: false,
137 premultipliedAlpha: false,
138 powerPreference: 'low-power',
139 });
140 if (!gl) return null;
141
142 const quad = gl.createBuffer();
143 gl.bindBuffer(gl.ARRAY_BUFFER, quad);
144 gl.bufferData(
145 gl.ARRAY_BUFFER,
146 new Float32Array([-1, -1, 3, -1, -1, 3]),
147 gl.STATIC_DRAW,
148 );
149
150 let program = null;
151 let locations = {};
152 let board = null;
153 let copy = null;
154 let frames = 0;
155 let frame = null;
156 let lastDraw = 0;
157 let paused = false;
158 let still = false;
159 let slowFrames = 0;
160 const start = performance.now();
161 let values = {
162 speed: 1,
163 scale: 1,
164 intensity: 1,
165 offset: [0, 0],
166 colors: [
167 [1, 1, 1],
168 [1, 1, 1],
169 [1, 1, 1],
170 ],
171 };
172
173 const buildProgram = (fragmentSource) => {
174 const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
175 const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
176 if (vertex.error || fragment.error) {
177 return { error: vertex.error || fragment.error };
178 }
179 const next = gl.createProgram();
180 gl.attachShader(next, vertex.shader);
181 gl.attachShader(next, fragment.shader);
182 gl.bindAttribLocation(next, 0, 'a_position');
183 gl.linkProgram(next);
184 gl.deleteShader(vertex.shader);
185 gl.deleteShader(fragment.shader);
186 if (gl.getProgramParameter(next, gl.LINK_STATUS)) return { program: next };
187 const log = gl.getProgramInfoLog(next);
188 gl.deleteProgram(next);
189 return { error: log || 'Shader failed to link.' };
190 };
191
192 // Wrapped, so a shape leaving one edge of the board arrives at the other.
193 const makeSurface = (width, height) => {
194 const texture = gl.createTexture();
195 gl.bindTexture(gl.TEXTURE_2D, texture);
196 gl.texImage2D(
197 gl.TEXTURE_2D,
198 0,
199 gl.RGBA,
200 width,
201 height,
202 0,
203 gl.RGBA,
204 gl.UNSIGNED_BYTE,
205 null,
206 );
207 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
208 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
209 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
210 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
211 const buffer = gl.createFramebuffer();
212 gl.bindFramebuffer(gl.FRAMEBUFFER, buffer);
213 gl.framebufferTexture2D(
214 gl.FRAMEBUFFER,
215 gl.COLOR_ATTACHMENT0,
216 gl.TEXTURE_2D,
217 texture,
218 0,
219 );
220 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
221 return { texture, buffer };
222 };
223
224 const dropBoard = () => {
225 if (!board) return;
226 for (const surface of board.surfaces) {
227 gl.deleteTexture(surface.texture);
228 gl.deleteFramebuffer(surface.buffer);
229 }
230 board = null;
231 };
232
233 // Scale is the cell size, so changing it or the canvas starts the board over.
234 const ensureBoard = () => {
235 const columns = Math.round(
236 Math.min(260, Math.max(24, BOARD_COLUMNS * values.scale)),
237 );
238 const rows = Math.max(
239 1,
240 Math.round((columns * canvas.height) / Math.max(canvas.width, 1)),
241 );
242 if (board && board.width === columns && board.height === rows) return;
243 dropBoard();
244 board = {
245 width: columns,
246 height: rows,
247 surfaces: [makeSurface(columns, rows), makeSurface(columns, rows)],
248 index: 0,
249 };
250 frames = 0;
251 };
252
253 const resize = () => {
254 const dpr = Math.min(window.devicePixelRatio || 1, 2) * RENDER_SCALE;
255 const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
256 const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
257 if (canvas.width === width && canvas.height === height) return;
258 canvas.width = width;
259 canvas.height = height;
260 gl.viewport(0, 0, width, height);
261 };
262
263 const setUniforms = (width, height) => {
264 gl.uniform2f(locations.u_resolution, width, height);
265 gl.uniform1f(
266 locations.u_time,
267 still ? 0 : (performance.now() - start) / 1000,
268 );
269 gl.uniform1f(locations.u_speed, values.speed);
270 gl.uniform1f(locations.u_scale, values.scale);
271 gl.uniform1f(locations.u_intensity, values.intensity);
272 gl.uniform2f(locations.u_offset, values.offset[0], values.offset[1]);
273 gl.uniform3fv(locations.u_color1, values.colors[0]);
274 gl.uniform3fv(locations.u_color2, values.colors[1]);
275 gl.uniform3fv(locations.u_color3, values.colors[2]);
276 gl.uniform1f(locations.u_frame, frames);
277 };
278
279 // Intensity lands on the copy: the shader spends its alpha carrying state.
280 const drawBoard = () => {
281 ensureBoard();
282 const read = board.surfaces[board.index];
283 const write = board.surfaces[1 - board.index];
284
285 gl.bindFramebuffer(gl.FRAMEBUFFER, write.buffer);
286 gl.viewport(0, 0, board.width, board.height);
287 // biome-ignore lint/correctness/useHookAtTopLevel: WebGL, not a React hook.
288 gl.useProgram(program);
289 setUniforms(board.width, board.height);
290 gl.activeTexture(gl.TEXTURE0);
291 gl.bindTexture(gl.TEXTURE_2D, read.texture);
292 gl.uniform1i(locations.u_prev, 0);
293 gl.drawArrays(gl.TRIANGLES, 0, 3);
294
295 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
296 gl.viewport(0, 0, canvas.width, canvas.height);
297 gl.clearColor(0, 0, 0, 0);
298 gl.clear(gl.COLOR_BUFFER_BIT);
299 // biome-ignore lint/correctness/useHookAtTopLevel: WebGL, not a React hook.
300 gl.useProgram(copy.program);
301 gl.uniform2f(copy.resolution, canvas.width, canvas.height);
302 gl.uniform2f(copy.offset, values.offset[0], values.offset[1]);
303 gl.uniform1f(copy.intensity, values.intensity);
304 gl.activeTexture(gl.TEXTURE0);
305 gl.bindTexture(gl.TEXTURE_2D, write.texture);
306 gl.uniform1i(copy.source, 0);
307 gl.drawArrays(gl.TRIANGLES, 0, 3);
308
309 board.index = 1 - board.index;
310 frames += 1;
311 };
312
313 const draw = () => {
314 if (!program) return;
315 resize();
316 if (copy) {
317 drawBoard();
318 return;
319 }
320 gl.clearColor(0, 0, 0, 0);
321 gl.clear(gl.COLOR_BUFFER_BIT);
322 // biome-ignore lint/correctness/useHookAtTopLevel: WebGL, not a React hook.
323 gl.useProgram(program);
324 setUniforms(canvas.width, canvas.height);
325 gl.drawArrays(gl.TRIANGLES, 0, 3);
326 frames += 1;
327 };
328
329 const loop = (now) => {
330 frame = requestAnimationFrame(loop);
331 if (paused || still) return;
332 if (now - lastDraw < 1000 / MAX_FPS) return;
333 // First frame has no interval to measure, and would read as a stall.
334 const elapsed = lastDraw ? now - lastDraw : 0;
335 lastDraw = now;
336 draw();
337
338 slowFrames = elapsed > SLOW_FRAME_MS ? slowFrames + 1 : 0;
339 if (slowFrames < SLOW_FRAME_LIMIT) return;
340 // Freezing keeps the look the partner chose; an error would reach nobody.
341 still = true;
342 };
343
344 const ensureCopy = () => {
345 if (copy) return true;
346 const built = buildProgram(COPY_FRAGMENT);
347 if (built.error) return false;
348 copy = {
349 program: built.program,
350 source: gl.getUniformLocation(built.program, 'u_source'),
351 resolution: gl.getUniformLocation(built.program, 'u_resolution'),
352 offset: gl.getUniformLocation(built.program, 'u_offset'),
353 intensity: gl.getUniformLocation(built.program, 'u_intensity'),
354 };
355 return true;
356 };
357
358 const dropCopy = () => {
359 if (!copy) return;
360 gl.deleteProgram(copy.program);
361 copy = null;
362 dropBoard();
363 };
364
365 const setSource = (body) => {
366 const built = buildProgram(PRELUDE + body + MAIN);
367 if (built.error) return false;
368
369 // Only a shader that reads the frame before pays for the second pass.
370 const held = /\bprev\s*\(/.test(body);
371 if (held && !ensureCopy()) {
372 gl.deleteProgram(built.program);
373 return false;
374 }
375 if (!held) dropCopy();
376
377 if (program) gl.deleteProgram(program);
378 program = built.program;
379 frames = 0;
380 locations = Object.fromEntries(
381 UNIFORMS.map((name) => [name, gl.getUniformLocation(program, name)]),
382 );
383 // biome-ignore lint/correctness/useHookAtTopLevel: WebGL, not a React hook.
384 gl.useProgram(program);
385 gl.enableVertexAttribArray(0);
386 gl.bindBuffer(gl.ARRAY_BUFFER, quad);
387 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
388 slowFrames = 0;
389 draw();
390 return true;
391 };
392
393 const setValues = (next) => {
394 values = { ...values, ...next };
395 if (still || paused) draw();
396 };
397
398 // Reduced motion gets one frame rather than a blank background.
399 const setStill = (value) => {
400 still = value;
401 if (value) draw();
402 };
403
404 const controller = new AbortController();
405 const { signal } = controller;
406 document.addEventListener(
407 'visibilitychange',
408 () => {
409 paused = document.hidden;
410 },
411 { signal },
412 );
413 canvas.addEventListener(
414 'webglcontextlost',
415 (event) => {
416 event.preventDefault();
417 paused = true;
418 },
419 { signal },
420 );
421
422 frame = requestAnimationFrame(loop);
423
424 return {
425 setSource,
426 setValues,
427 setStill,
428 destroy() {
429 controller.abort();
430 if (frame) cancelAnimationFrame(frame);
431 if (program) gl.deleteProgram(program);
432 dropCopy();
433 gl.deleteBuffer(quad);
434 gl.getExtension('WEBGL_lose_context')?.loseContext();
435 },
436 };
437 };
438