your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { colorToHue, getCSSVar, getHexOfCardColor } from '../helper';
3 import type { ContentComponentProps } from '../types';
4 import { onMount, onDestroy, tick } from 'svelte';
5 let { item }: ContentComponentProps = $props();
6
7 let container: HTMLDivElement;
8 let fluidCanvas: HTMLCanvasElement;
9 let maskCanvas: HTMLCanvasElement;
10 let animationId: number;
11 let splatIntervalId: ReturnType<typeof setInterval>;
12 let maskDrawRaf = 0;
13 let maskReady = false;
14 let isInitialized = $state(false);
15 let resizeObserver: ResizeObserver | null = null;
16
17 // Pure hash function for shader keyword caching
18 function hashCode(s: string) {
19 if (s.length === 0) return 0;
20 let hash = 0;
21 for (let i = 0; i < s.length; i++) {
22 hash = (hash << 5) - hash + s.charCodeAt(i);
23 hash |= 0;
24 }
25 return hash;
26 }
27
28 // WebGL helper types
29 type CompileShaderFn = (type: number, source: string, keywords?: string[]) => WebGLShader;
30 type CreateProgramFn = (vs: WebGLShader, fs: WebGLShader) => WebGLProgram;
31 type GetUniformsFn = (program: WebGLProgram) => Record<string, WebGLUniformLocation | null>;
32
33 // Material class for shader programs with keyword variants
34 class Material {
35 private gl: WebGL2RenderingContext;
36 private compileShader: CompileShaderFn;
37 private createProgramFn: CreateProgramFn;
38 private getUniformsFn: GetUniformsFn;
39 vertexShader: WebGLShader;
40 fragmentShaderSource: string;
41 programs: Record<number, WebGLProgram> = {};
42 activeProgram: WebGLProgram | null = null;
43 uniforms: Record<string, WebGLUniformLocation | null> = {};
44
45 constructor(
46 gl: WebGL2RenderingContext,
47 vertexShader: WebGLShader,
48 fragmentShaderSource: string,
49 compileShader: CompileShaderFn,
50 createProgramFn: CreateProgramFn,
51 getUniformsFn: GetUniformsFn
52 ) {
53 this.gl = gl;
54 this.compileShader = compileShader;
55 this.createProgramFn = createProgramFn;
56 this.getUniformsFn = getUniformsFn;
57 this.vertexShader = vertexShader;
58 this.fragmentShaderSource = fragmentShaderSource;
59 }
60
61 setKeywords(keywords: string[]) {
62 let hash = 0;
63 for (let i = 0; i < keywords.length; i++) hash += hashCode(keywords[i]);
64
65 let program = this.programs[hash];
66 if (!program) {
67 const fragmentShader = this.compileShader(
68 this.gl.FRAGMENT_SHADER,
69 this.fragmentShaderSource,
70 keywords
71 );
72 program = this.createProgramFn(this.vertexShader, fragmentShader);
73 this.programs[hash] = program;
74 }
75
76 if (program === this.activeProgram) return;
77
78 this.uniforms = this.getUniformsFn(program);
79 this.activeProgram = program;
80 }
81
82 bind() {
83 this.gl.useProgram(this.activeProgram);
84 }
85 }
86
87 // Program class for simple shader programs
88 class Program {
89 private gl: WebGL2RenderingContext;
90 uniforms: Record<string, WebGLUniformLocation | null> = {};
91 program: WebGLProgram;
92
93 constructor(
94 gl: WebGL2RenderingContext,
95 vertexShader: WebGLShader,
96 fragmentShader: WebGLShader,
97 createProgramFn: CreateProgramFn,
98 getUniformsFn: GetUniformsFn
99 ) {
100 this.gl = gl;
101 this.program = createProgramFn(vertexShader, fragmentShader);
102 this.uniforms = getUniformsFn(this.program);
103 }
104
105 bind() {
106 this.gl.useProgram(this.program);
107 }
108 }
109
110 // Get text from card data
111 const text = $derived((item.cardData?.text as string) || 'hello');
112 const fontWeight = '900';
113 const fontFamily = 'Arial';
114 const fontSize = $derived(parseFloat(item.cardData?.fontSize) || 0.33);
115
116 // Draw text mask on overlay canvas
117 function drawOverlayCanvas() {
118 if (!maskCanvas || !container) return;
119
120 const width = container.clientWidth;
121 const height = container.clientHeight;
122 if (width === 0 || height === 0) return;
123
124 const dpr = window.devicePixelRatio || 1;
125
126 maskCanvas.width = width * dpr;
127 maskCanvas.height = height * dpr;
128
129 const ctx = maskCanvas.getContext('2d')!;
130 ctx.setTransform(1, 0, 0, 1, 0, 0);
131 ctx.globalCompositeOperation = 'source-over';
132 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
133 ctx.scale(dpr, dpr);
134
135 //const color = getCSSVar('--color-base-900');
136
137 ctx.fillStyle = 'black';
138 ctx.fillRect(0, 0, width, height);
139
140 // Font size as percentage of container width
141 const textFontSize = Math.round(width * fontSize);
142 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`;
143
144 ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
145 ctx.lineWidth = 2;
146 ctx.textAlign = 'center';
147
148 const metrics = ctx.measureText(text);
149 let textY = height / 2;
150 if (
151 metrics.actualBoundingBoxAscent !== undefined &&
152 metrics.actualBoundingBoxDescent !== undefined
153 ) {
154 ctx.textBaseline = 'alphabetic';
155 textY = (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2;
156 } else {
157 ctx.textBaseline = 'middle';
158 }
159
160 ctx.strokeText(text, width / 2, textY);
161 ctx.globalCompositeOperation = 'destination-out';
162 ctx.fillText(text, width / 2, textY);
163 ctx.globalCompositeOperation = 'source-over';
164 maskReady = true;
165 }
166
167 function scheduleMaskDraw() {
168 const width = container?.clientWidth ?? 0;
169 const height = container?.clientHeight ?? 0;
170 if (width > 0 && height > 0) {
171 drawOverlayCanvas();
172 return;
173 }
174 if (maskDrawRaf) return;
175 maskDrawRaf = requestAnimationFrame(() => {
176 maskDrawRaf = 0;
177 const nextWidth = container?.clientWidth ?? 0;
178 const nextHeight = container?.clientHeight ?? 0;
179 if (nextWidth === 0 || nextHeight === 0) {
180 scheduleMaskDraw();
181 return;
182 }
183 drawOverlayCanvas();
184 });
185 }
186
187 // Redraw overlay when text settings change (only after initialization)
188 $effect(() => {
189 // Access all reactive values to track them
190 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
191 text;
192 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
193 fontSize;
194 // Only redraw if already initialized
195 if (isInitialized) {
196 scheduleMaskDraw();
197 }
198 });
199
200 onMount(async () => {
201 // Wait for layout to settle
202 await tick();
203
204 const computedColor = getHexOfCardColor(item);
205 const hue = colorToHue(computedColor) / 360;
206 console.log(computedColor, hue);
207
208 // Wait for a frame to ensure dimensions are set
209 requestAnimationFrame(() => {
210 initFluidSimulation(hue, hue - 0.3);
211 });
212
213 if (document.fonts?.ready) {
214 document.fonts.ready.then(() => {
215 if (isInitialized) scheduleMaskDraw();
216 });
217 }
218 });
219
220 onDestroy(() => {
221 if (animationId) cancelAnimationFrame(animationId);
222 if (splatIntervalId) clearInterval(splatIntervalId);
223 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf);
224 if (resizeObserver) resizeObserver.disconnect();
225 });
226
227 function initFluidSimulation(startHue: number, endHue: number) {
228 if (!fluidCanvas || !maskCanvas || !container) return;
229
230 maskReady = false;
231 scheduleMaskDraw();
232
233 // Simulation config
234 const config = {
235 SIM_RESOLUTION: 128,
236 DYE_RESOLUTION: 1024,
237 CAPTURE_RESOLUTION: 512,
238 DENSITY_DISSIPATION: 1.0,
239 VELOCITY_DISSIPATION: 0.1,
240 PRESSURE: 0.8,
241 PRESSURE_ITERATIONS: 20,
242 CURL: 30,
243 SPLAT_RADIUS: 0.25,
244 SPLAT_FORCE: 1000,
245 SHADING: true,
246 COLORFUL: true,
247 COLOR_UPDATE_SPEED: 10,
248 PAUSED: false,
249 BACK_COLOR: { r: 0, g: 0, b: 0 },
250 TRANSPARENT: false,
251 BLOOM: false,
252 BLOOM_ITERATIONS: 8,
253 BLOOM_RESOLUTION: 256,
254 BLOOM_INTENSITY: 0.8,
255 BLOOM_THRESHOLD: 0.8,
256 BLOOM_SOFT_KNEE: 0.7,
257 SUNRAYS: true,
258 SUNRAYS_RESOLUTION: 196,
259 SUNRAYS_WEIGHT: 1.0,
260 START_HUE: startHue,
261 END_HUE: endHue,
262 RENDER_SPEED: 0.4
263 };
264
265 function PointerPrototype() {
266 return {
267 id: -1,
268 texcoordX: 0,
269 texcoordY: 0,
270 prevTexcoordX: 0,
271 prevTexcoordY: 0,
272 deltaX: 0,
273 deltaY: 0,
274 down: false,
275 moved: false,
276 color: [0, 0, 0] as [number, number, number]
277 };
278 }
279
280 type Pointer = ReturnType<typeof PointerPrototype>;
281 let pointers: Pointer[] = [PointerPrototype()];
282 let splatStack: number[] = [];
283
284 const { gl: glMaybeNull, ext } = getWebGLContext(fluidCanvas);
285 if (!glMaybeNull) return;
286 const gl = glMaybeNull;
287
288 if (isMobile()) {
289 config.DYE_RESOLUTION = 512;
290 }
291 if (!ext.supportLinearFiltering) {
292 config.DYE_RESOLUTION = 512;
293 config.SHADING = false;
294 config.BLOOM = false;
295 config.SUNRAYS = false;
296 }
297
298 function getWebGLContext(canvas: HTMLCanvasElement) {
299 const params = {
300 alpha: true,
301 depth: false,
302 stencil: true,
303 antialias: false,
304 preserveDrawingBuffer: false
305 };
306
307 let gl = canvas.getContext('webgl2', params) as WebGL2RenderingContext | null;
308 const isWebGL2 = !!gl;
309 if (!isWebGL2) {
310 gl = (canvas.getContext('webgl', params) ||
311 canvas.getContext('experimental-webgl', params)) as WebGL2RenderingContext | null;
312 }
313
314 if (!gl) return { gl: null, ext: { supportLinearFiltering: false } as any };
315
316 let halfFloat: any;
317 let supportLinearFiltering = false;
318 if (isWebGL2) {
319 gl.getExtension('EXT_color_buffer_float');
320 supportLinearFiltering = !!gl.getExtension('OES_texture_float_linear');
321 } else {
322 halfFloat = gl.getExtension('OES_texture_half_float');
323 supportLinearFiltering = !!gl.getExtension('OES_texture_half_float_linear');
324 }
325
326 gl.clearColor(0.0, 0.0, 0.0, 1.0);
327
328 let halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat?.HALF_FLOAT_OES;
329 let fallbackToUnsignedByte = false;
330 if (!halfFloatTexType) {
331 halfFloatTexType = gl.UNSIGNED_BYTE;
332 supportLinearFiltering = true;
333 fallbackToUnsignedByte = true;
334 }
335 let formatRGBA: any;
336 let formatRG: any;
337 let formatR: any;
338
339 if (isWebGL2) {
340 if (fallbackToUnsignedByte) {
341 formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA };
342 formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA };
343 formatR = { internalFormat: gl.RGBA8, format: gl.RGBA };
344 } else {
345 formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType);
346 formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType);
347 formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType);
348 if (!formatRGBA) formatRGBA = { internalFormat: gl.RGBA8, format: gl.RGBA };
349 if (!formatRG) formatRG = { internalFormat: gl.RGBA8, format: gl.RGBA };
350 if (!formatR) formatR = { internalFormat: gl.RGBA8, format: gl.RGBA };
351 }
352 } else {
353 formatRGBA = { internalFormat: gl.RGBA, format: gl.RGBA };
354 formatRG = { internalFormat: gl.RGBA, format: gl.RGBA };
355 formatR = { internalFormat: gl.RGBA, format: gl.RGBA };
356 if (!fallbackToUnsignedByte) {
357 formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRGBA;
358 formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatRG;
359 formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType) ?? formatR;
360 }
361 }
362
363 return {
364 gl,
365 ext: {
366 formatRGBA,
367 formatRG,
368 formatR,
369 halfFloatTexType,
370 supportLinearFiltering
371 }
372 };
373 }
374
375 function getSupportedFormat(
376 gl: WebGL2RenderingContext,
377 internalFormat: number,
378 format: number,
379 type: number
380 ): { internalFormat: number; format: number } | null {
381 if (!supportRenderTextureFormat(gl, internalFormat, format, type)) {
382 switch (internalFormat) {
383 case gl.R16F:
384 return getSupportedFormat(gl, gl.RG16F, gl.RG, type);
385 case gl.RG16F:
386 return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type);
387 default:
388 return null;
389 }
390 }
391 return { internalFormat, format };
392 }
393
394 function supportRenderTextureFormat(
395 gl: WebGL2RenderingContext,
396 internalFormat: number,
397 format: number,
398 type: number
399 ) {
400 if (!type) return false;
401 const texture = gl.createTexture();
402 gl.bindTexture(gl.TEXTURE_2D, texture);
403 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
404 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
405 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
406 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
407 gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);
408
409 const fbo = gl.createFramebuffer();
410 gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
411 gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
412
413 const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
414 return status === gl.FRAMEBUFFER_COMPLETE;
415 }
416
417 function isMobile() {
418 return /Mobi|Android/i.test(navigator.userAgent);
419 }
420
421 function createWebGLProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader) {
422 const program = gl.createProgram()!;
423 gl.attachShader(program, vertexShader);
424 gl.attachShader(program, fragmentShader);
425 gl.linkProgram(program);
426
427 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
428 console.trace(gl.getProgramInfoLog(program));
429 }
430
431 return program;
432 }
433
434 function getUniforms(program: WebGLProgram) {
435 const uniforms: Record<string, WebGLUniformLocation | null> = {};
436 const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
437 for (let i = 0; i < uniformCount; i++) {
438 const uniformName = gl.getActiveUniform(program, i)!.name;
439 uniforms[uniformName] = gl.getUniformLocation(program, uniformName);
440 }
441 return uniforms;
442 }
443
444 function compileShader(type: number, source: string, keywords?: string[]) {
445 source = addKeywords(source, keywords);
446
447 const shader = gl.createShader(type)!;
448 gl.shaderSource(shader, source);
449 gl.compileShader(shader);
450
451 if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
452 console.trace(gl.getShaderInfoLog(shader));
453 }
454
455 return shader;
456 }
457
458 function addKeywords(source: string, keywords?: string[]) {
459 if (!keywords) return source;
460 let keywordsString = '';
461 keywords.forEach((keyword) => {
462 keywordsString += '#define ' + keyword + '\n';
463 });
464 return keywordsString + source;
465 }
466
467 const baseVertexShader = compileShader(
468 gl.VERTEX_SHADER,
469 `
470 precision highp float;
471 attribute vec2 aPosition;
472 varying vec2 vUv;
473 varying vec2 vL;
474 varying vec2 vR;
475 varying vec2 vT;
476 varying vec2 vB;
477 uniform vec2 texelSize;
478 void main () {
479 vUv = aPosition * 0.5 + 0.5;
480 vL = vUv - vec2(texelSize.x, 0.0);
481 vR = vUv + vec2(texelSize.x, 0.0);
482 vT = vUv + vec2(0.0, texelSize.y);
483 vB = vUv - vec2(0.0, texelSize.y);
484 gl_Position = vec4(aPosition, 0.0, 1.0);
485 }
486 `
487 );
488
489 const blurVertexShader = compileShader(
490 gl.VERTEX_SHADER,
491 `
492 precision highp float;
493 attribute vec2 aPosition;
494 varying vec2 vUv;
495 varying vec2 vL;
496 varying vec2 vR;
497 uniform vec2 texelSize;
498 void main () {
499 vUv = aPosition * 0.5 + 0.5;
500 float offset = 1.33333333;
501 vL = vUv - texelSize * offset;
502 vR = vUv + texelSize * offset;
503 gl_Position = vec4(aPosition, 0.0, 1.0);
504 }
505 `
506 );
507
508 const blurShader = compileShader(
509 gl.FRAGMENT_SHADER,
510 `
511 precision mediump float;
512 precision mediump sampler2D;
513 varying vec2 vUv;
514 varying vec2 vL;
515 varying vec2 vR;
516 uniform sampler2D uTexture;
517 void main () {
518 vec4 sum = texture2D(uTexture, vUv) * 0.29411764;
519 sum += texture2D(uTexture, vL) * 0.35294117;
520 sum += texture2D(uTexture, vR) * 0.35294117;
521 gl_FragColor = sum;
522 }
523 `
524 );
525
526 const copyShader = compileShader(
527 gl.FRAGMENT_SHADER,
528 `
529 precision mediump float;
530 precision mediump sampler2D;
531 varying highp vec2 vUv;
532 uniform sampler2D uTexture;
533 void main () {
534 gl_FragColor = texture2D(uTexture, vUv);
535 }
536 `
537 );
538
539 const clearShader = compileShader(
540 gl.FRAGMENT_SHADER,
541 `
542 precision mediump float;
543 precision mediump sampler2D;
544 varying highp vec2 vUv;
545 uniform sampler2D uTexture;
546 uniform float value;
547 void main () {
548 gl_FragColor = value * texture2D(uTexture, vUv);
549 }
550 `
551 );
552
553 const colorShader = compileShader(
554 gl.FRAGMENT_SHADER,
555 `
556 precision mediump float;
557 uniform vec4 color;
558 void main () {
559 gl_FragColor = color;
560 }
561 `
562 );
563
564 const displayShaderSource = `
565 precision highp float;
566 precision highp sampler2D;
567 varying vec2 vUv;
568 varying vec2 vL;
569 varying vec2 vR;
570 varying vec2 vT;
571 varying vec2 vB;
572 uniform sampler2D uTexture;
573 uniform sampler2D uBloom;
574 uniform sampler2D uSunrays;
575 uniform sampler2D uDithering;
576 uniform vec2 ditherScale;
577 uniform vec2 texelSize;
578 vec3 linearToGamma (vec3 color) {
579 color = max(color, vec3(0));
580 return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0));
581 }
582 void main () {
583 vec3 c = texture2D(uTexture, vUv).rgb;
584 #ifdef SHADING
585 vec3 lc = texture2D(uTexture, vL).rgb;
586 vec3 rc = texture2D(uTexture, vR).rgb;
587 vec3 tc = texture2D(uTexture, vT).rgb;
588 vec3 bc = texture2D(uTexture, vB).rgb;
589 float dx = length(rc) - length(lc);
590 float dy = length(tc) - length(bc);
591 vec3 n = normalize(vec3(dx, dy, length(texelSize)));
592 vec3 l = vec3(0.0, 0.0, 1.0);
593 float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
594 c *= diffuse;
595 #endif
596 #ifdef BLOOM
597 vec3 bloom = texture2D(uBloom, vUv).rgb;
598 #endif
599 #ifdef SUNRAYS
600 float sunrays = texture2D(uSunrays, vUv).r;
601 c *= sunrays;
602 #ifdef BLOOM
603 bloom *= sunrays;
604 #endif
605 #endif
606 #ifdef BLOOM
607 float noise = texture2D(uDithering, vUv * ditherScale).r;
608 noise = noise * 2.0 - 1.0;
609 bloom += noise / 255.0;
610 bloom = linearToGamma(bloom);
611 c += bloom;
612 #endif
613 float a = max(c.r, max(c.g, c.b));
614 gl_FragColor = vec4(c, a);
615 }
616 `;
617
618 const splatShader = compileShader(
619 gl.FRAGMENT_SHADER,
620 `
621 precision highp float;
622 precision highp sampler2D;
623 varying vec2 vUv;
624 uniform sampler2D uTarget;
625 uniform float aspectRatio;
626 uniform vec3 color;
627 uniform vec2 point;
628 uniform float radius;
629 void main () {
630 vec2 p = vUv - point.xy;
631 p.x *= aspectRatio;
632 vec3 splat = exp(-dot(p, p) / radius) * color;
633 vec3 base = texture2D(uTarget, vUv).xyz;
634 gl_FragColor = vec4(base + splat, 1.0);
635 }
636 `
637 );
638
639 const advectionShader = compileShader(
640 gl.FRAGMENT_SHADER,
641 `
642 precision highp float;
643 precision highp sampler2D;
644 varying vec2 vUv;
645 uniform sampler2D uVelocity;
646 uniform sampler2D uSource;
647 uniform vec2 texelSize;
648 uniform vec2 dyeTexelSize;
649 uniform float dt;
650 uniform float dissipation;
651 vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
652 vec2 st = uv / tsize - 0.5;
653 vec2 iuv = floor(st);
654 vec2 fuv = fract(st);
655 vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
656 vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
657 vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
658 vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
659 return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
660 }
661 void main () {
662 #ifdef MANUAL_FILTERING
663 vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
664 vec4 result = bilerp(uSource, coord, dyeTexelSize);
665 #else
666 vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
667 vec4 result = texture2D(uSource, coord);
668 #endif
669 float decay = 1.0 + dissipation * dt;
670 gl_FragColor = result / decay;
671 }`,
672 ext.supportLinearFiltering ? undefined : ['MANUAL_FILTERING']
673 );
674
675 const divergenceShader = compileShader(
676 gl.FRAGMENT_SHADER,
677 `
678 precision mediump float;
679 precision mediump sampler2D;
680 varying highp vec2 vUv;
681 varying highp vec2 vL;
682 varying highp vec2 vR;
683 varying highp vec2 vT;
684 varying highp vec2 vB;
685 uniform sampler2D uVelocity;
686 void main () {
687 float L = texture2D(uVelocity, vL).x;
688 float R = texture2D(uVelocity, vR).x;
689 float T = texture2D(uVelocity, vT).y;
690 float B = texture2D(uVelocity, vB).y;
691 vec2 C = texture2D(uVelocity, vUv).xy;
692 if (vL.x < 0.0) { L = -C.x; }
693 if (vR.x > 1.0) { R = -C.x; }
694 if (vT.y > 1.0) { T = -C.y; }
695 if (vB.y < 0.0) { B = -C.y; }
696 float div = 0.5 * (R - L + T - B);
697 gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
698 }
699 `
700 );
701
702 const curlShader = compileShader(
703 gl.FRAGMENT_SHADER,
704 `
705 precision mediump float;
706 precision mediump sampler2D;
707 varying highp vec2 vUv;
708 varying highp vec2 vL;
709 varying highp vec2 vR;
710 varying highp vec2 vT;
711 varying highp vec2 vB;
712 uniform sampler2D uVelocity;
713 void main () {
714 float L = texture2D(uVelocity, vL).y;
715 float R = texture2D(uVelocity, vR).y;
716 float T = texture2D(uVelocity, vT).x;
717 float B = texture2D(uVelocity, vB).x;
718 float vorticity = R - L - T + B;
719 gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);
720 }
721 `
722 );
723
724 const vorticityShader = compileShader(
725 gl.FRAGMENT_SHADER,
726 `
727 precision highp float;
728 precision highp sampler2D;
729 varying vec2 vUv;
730 varying vec2 vL;
731 varying vec2 vR;
732 varying vec2 vT;
733 varying vec2 vB;
734 uniform sampler2D uVelocity;
735 uniform sampler2D uCurl;
736 uniform float curl;
737 uniform float dt;
738 void main () {
739 float L = texture2D(uCurl, vL).x;
740 float R = texture2D(uCurl, vR).x;
741 float T = texture2D(uCurl, vT).x;
742 float B = texture2D(uCurl, vB).x;
743 float C = texture2D(uCurl, vUv).x;
744 vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
745 force /= length(force) + 0.0001;
746 force *= curl * C;
747 force.y *= -1.0;
748 vec2 velocity = texture2D(uVelocity, vUv).xy;
749 velocity += force * dt;
750 velocity = min(max(velocity, -1000.0), 1000.0);
751 gl_FragColor = vec4(velocity, 0.0, 1.0);
752 }
753 `
754 );
755
756 const pressureShader = compileShader(
757 gl.FRAGMENT_SHADER,
758 `
759 precision mediump float;
760 precision mediump sampler2D;
761 varying highp vec2 vUv;
762 varying highp vec2 vL;
763 varying highp vec2 vR;
764 varying highp vec2 vT;
765 varying highp vec2 vB;
766 uniform sampler2D uPressure;
767 uniform sampler2D uDivergence;
768 void main () {
769 float L = texture2D(uPressure, vL).x;
770 float R = texture2D(uPressure, vR).x;
771 float T = texture2D(uPressure, vT).x;
772 float B = texture2D(uPressure, vB).x;
773 float C = texture2D(uPressure, vUv).x;
774 float divergence = texture2D(uDivergence, vUv).x;
775 float pressure = (L + R + B + T - divergence) * 0.25;
776 gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);
777 }
778 `
779 );
780
781 const gradientSubtractShader = compileShader(
782 gl.FRAGMENT_SHADER,
783 `
784 precision mediump float;
785 precision mediump sampler2D;
786 varying highp vec2 vUv;
787 varying highp vec2 vL;
788 varying highp vec2 vR;
789 varying highp vec2 vT;
790 varying highp vec2 vB;
791 uniform sampler2D uPressure;
792 uniform sampler2D uVelocity;
793 void main () {
794 float L = texture2D(uPressure, vL).x;
795 float R = texture2D(uPressure, vR).x;
796 float T = texture2D(uPressure, vT).x;
797 float B = texture2D(uPressure, vB).x;
798 vec2 velocity = texture2D(uVelocity, vUv).xy;
799 velocity.xy -= vec2(R - L, T - B);
800 gl_FragColor = vec4(velocity, 0.0, 1.0);
801 }
802 `
803 );
804
805 const sunraysMaskShader = compileShader(
806 gl.FRAGMENT_SHADER,
807 `
808 precision highp float;
809 precision highp sampler2D;
810 varying vec2 vUv;
811 uniform sampler2D uTexture;
812 void main () {
813 vec4 c = texture2D(uTexture, vUv);
814 float br = max(c.r, max(c.g, c.b));
815 c.a = 1.0 - min(max(br * 20.0, 0.0), 0.8);
816 gl_FragColor = c;
817 }
818 `
819 );
820
821 const sunraysShader = compileShader(
822 gl.FRAGMENT_SHADER,
823 `
824 precision highp float;
825 precision highp sampler2D;
826 varying vec2 vUv;
827 uniform sampler2D uTexture;
828 uniform float weight;
829 #define ITERATIONS 16
830 void main () {
831 float Density = 0.3;
832 float Decay = 0.95;
833 float Exposure = 0.7;
834 vec2 coord = vUv;
835 vec2 dir = vUv - 0.5;
836 dir *= 1.0 / float(ITERATIONS) * Density;
837 float illuminationDecay = 1.0;
838 float color = texture2D(uTexture, vUv).a;
839 for (int i = 0; i < ITERATIONS; i++) {
840 coord -= dir;
841 float col = texture2D(uTexture, coord).a;
842 color += col * illuminationDecay * weight;
843 illuminationDecay *= Decay;
844 }
845 gl_FragColor = vec4(color * Exposure, 0.0, 0.0, 1.0);
846 }
847 `
848 );
849
850 // Setup blit
851 gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
852 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
853 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
854 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
855 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
856 gl.enableVertexAttribArray(0);
857
858 type FBO = {
859 texture: WebGLTexture;
860 fbo: WebGLFramebuffer;
861 width: number;
862 height: number;
863 texelSizeX: number;
864 texelSizeY: number;
865 attach: (id: number) => number;
866 };
867
868 type DoubleFBO = {
869 width: number;
870 height: number;
871 texelSizeX: number;
872 texelSizeY: number;
873 read: FBO;
874 write: FBO;
875 swap: () => void;
876 };
877
878 function blit(target: FBO | null, clear = false) {
879 if (target === null) {
880 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
881 gl.bindFramebuffer(gl.FRAMEBUFFER, null);
882 } else {
883 gl.viewport(0, 0, target.width, target.height);
884 gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
885 }
886 if (clear) {
887 gl.clearColor(0.0, 0.0, 0.0, 1.0);
888 gl.clear(gl.COLOR_BUFFER_BIT);
889 }
890 gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
891 }
892
893 let dye: DoubleFBO;
894 let velocity: DoubleFBO;
895 let divergence: FBO;
896 let curl: FBO;
897 let pressure: DoubleFBO;
898 let sunrays: FBO;
899 let sunraysTemp: FBO;
900
901 const blurProgram = new Program(
902 gl,
903 blurVertexShader,
904 blurShader,
905 createWebGLProgram,
906 getUniforms
907 );
908 const copyProgram = new Program(
909 gl,
910 baseVertexShader,
911 copyShader,
912 createWebGLProgram,
913 getUniforms
914 );
915 const clearProgram = new Program(
916 gl,
917 baseVertexShader,
918 clearShader,
919 createWebGLProgram,
920 getUniforms
921 );
922 const colorProgram = new Program(
923 gl,
924 baseVertexShader,
925 colorShader,
926 createWebGLProgram,
927 getUniforms
928 );
929 const splatProgram = new Program(
930 gl,
931 baseVertexShader,
932 splatShader,
933 createWebGLProgram,
934 getUniforms
935 );
936 const advectionProgram = new Program(
937 gl,
938 baseVertexShader,
939 advectionShader,
940 createWebGLProgram,
941 getUniforms
942 );
943 const divergenceProgram = new Program(
944 gl,
945 baseVertexShader,
946 divergenceShader,
947 createWebGLProgram,
948 getUniforms
949 );
950 const curlProgram = new Program(
951 gl,
952 baseVertexShader,
953 curlShader,
954 createWebGLProgram,
955 getUniforms
956 );
957 const vorticityProgram = new Program(
958 gl,
959 baseVertexShader,
960 vorticityShader,
961 createWebGLProgram,
962 getUniforms
963 );
964 const pressureProgram = new Program(
965 gl,
966 baseVertexShader,
967 pressureShader,
968 createWebGLProgram,
969 getUniforms
970 );
971 const gradienSubtractProgram = new Program(
972 gl,
973 baseVertexShader,
974 gradientSubtractShader,
975 createWebGLProgram,
976 getUniforms
977 );
978 const sunraysMaskProgram = new Program(
979 gl,
980 baseVertexShader,
981 sunraysMaskShader,
982 createWebGLProgram,
983 getUniforms
984 );
985 const sunraysProgram = new Program(
986 gl,
987 baseVertexShader,
988 sunraysShader,
989 createWebGLProgram,
990 getUniforms
991 );
992
993 const displayMaterial = new Material(
994 gl,
995 baseVertexShader,
996 displayShaderSource,
997 compileShader,
998 createWebGLProgram,
999 getUniforms
1000 );
1001
1002 function getResolution(resolution: number) {
1003 let aspectRatio = gl.drawingBufferWidth / gl.drawingBufferHeight;
1004 if (aspectRatio < 1) aspectRatio = 1.0 / aspectRatio;
1005 const min = Math.round(resolution);
1006 const max = Math.round(resolution * aspectRatio);
1007 if (gl.drawingBufferWidth > gl.drawingBufferHeight) return { width: max, height: min };
1008 else return { width: min, height: max };
1009 }
1010
1011 function createFBO(
1012 w: number,
1013 h: number,
1014 internalFormat: number,
1015 format: number,
1016 type: number,
1017 param: number
1018 ): FBO {
1019 gl.activeTexture(gl.TEXTURE0);
1020 const texture = gl.createTexture()!;
1021 gl.bindTexture(gl.TEXTURE_2D, texture);
1022 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);
1023 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);
1024 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1025 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1026 gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null);
1027
1028 const fbo = gl.createFramebuffer()!;
1029 gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1030 gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
1031 gl.viewport(0, 0, w, h);
1032 gl.clear(gl.COLOR_BUFFER_BIT);
1033
1034 const texelSizeX = 1.0 / w;
1035 const texelSizeY = 1.0 / h;
1036
1037 return {
1038 texture,
1039 fbo,
1040 width: w,
1041 height: h,
1042 texelSizeX,
1043 texelSizeY,
1044 attach(id: number) {
1045 gl.activeTexture(gl.TEXTURE0 + id);
1046 gl.bindTexture(gl.TEXTURE_2D, texture);
1047 return id;
1048 }
1049 };
1050 }
1051
1052 function createDoubleFBO(
1053 w: number,
1054 h: number,
1055 internalFormat: number,
1056 format: number,
1057 type: number,
1058 param: number
1059 ): DoubleFBO {
1060 let fbo1 = createFBO(w, h, internalFormat, format, type, param);
1061 let fbo2 = createFBO(w, h, internalFormat, format, type, param);
1062
1063 return {
1064 width: w,
1065 height: h,
1066 texelSizeX: fbo1.texelSizeX,
1067 texelSizeY: fbo1.texelSizeY,
1068 get read() {
1069 return fbo1;
1070 },
1071 set read(value) {
1072 fbo1 = value;
1073 },
1074 get write() {
1075 return fbo2;
1076 },
1077 set write(value) {
1078 fbo2 = value;
1079 },
1080 swap() {
1081 const temp = fbo1;
1082 fbo1 = fbo2;
1083 fbo2 = temp;
1084 }
1085 };
1086 }
1087
1088 function resizeFBO(
1089 target: FBO,
1090 w: number,
1091 h: number,
1092 internalFormat: number,
1093 format: number,
1094 type: number,
1095 param: number
1096 ) {
1097 const newFBO = createFBO(w, h, internalFormat, format, type, param);
1098 copyProgram.bind();
1099 gl.uniform1i(copyProgram.uniforms.uTexture, target.attach(0));
1100 blit(newFBO);
1101 return newFBO;
1102 }
1103
1104 function resizeDoubleFBO(
1105 target: DoubleFBO,
1106 w: number,
1107 h: number,
1108 internalFormat: number,
1109 format: number,
1110 type: number,
1111 param: number
1112 ) {
1113 if (target.width === w && target.height === h) return target;
1114 target.read = resizeFBO(target.read, w, h, internalFormat, format, type, param);
1115 target.write = createFBO(w, h, internalFormat, format, type, param);
1116 target.width = w;
1117 target.height = h;
1118 target.texelSizeX = 1.0 / w;
1119 target.texelSizeY = 1.0 / h;
1120 return target;
1121 }
1122
1123 function initFramebuffers() {
1124 const simRes = getResolution(config.SIM_RESOLUTION);
1125 const dyeRes = getResolution(config.DYE_RESOLUTION);
1126
1127 const texType = ext.halfFloatTexType;
1128 const rgba = ext.formatRGBA;
1129 const rg = ext.formatRG;
1130 const r = ext.formatR;
1131 const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
1132
1133 gl.disable(gl.BLEND);
1134
1135 if (!dye) {
1136 dye = createDoubleFBO(
1137 dyeRes.width,
1138 dyeRes.height,
1139 rgba.internalFormat,
1140 rgba.format,
1141 texType,
1142 filtering
1143 );
1144 } else {
1145 dye = resizeDoubleFBO(
1146 dye,
1147 dyeRes.width,
1148 dyeRes.height,
1149 rgba.internalFormat,
1150 rgba.format,
1151 texType,
1152 filtering
1153 );
1154 }
1155
1156 if (!velocity) {
1157 velocity = createDoubleFBO(
1158 simRes.width,
1159 simRes.height,
1160 rg.internalFormat,
1161 rg.format,
1162 texType,
1163 filtering
1164 );
1165 } else {
1166 velocity = resizeDoubleFBO(
1167 velocity,
1168 simRes.width,
1169 simRes.height,
1170 rg.internalFormat,
1171 rg.format,
1172 texType,
1173 filtering
1174 );
1175 }
1176
1177 divergence = createFBO(
1178 simRes.width,
1179 simRes.height,
1180 r.internalFormat,
1181 r.format,
1182 texType,
1183 gl.NEAREST
1184 );
1185 curl = createFBO(
1186 simRes.width,
1187 simRes.height,
1188 r.internalFormat,
1189 r.format,
1190 texType,
1191 gl.NEAREST
1192 );
1193 pressure = createDoubleFBO(
1194 simRes.width,
1195 simRes.height,
1196 r.internalFormat,
1197 r.format,
1198 texType,
1199 gl.NEAREST
1200 );
1201
1202 initSunraysFramebuffers();
1203 }
1204
1205 function initSunraysFramebuffers() {
1206 const res = getResolution(config.SUNRAYS_RESOLUTION);
1207 const texType = ext.halfFloatTexType;
1208 const r = ext.formatR;
1209 const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
1210
1211 sunrays = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering);
1212 sunraysTemp = createFBO(
1213 res.width,
1214 res.height,
1215 r.internalFormat,
1216 r.format,
1217 texType,
1218 filtering
1219 );
1220 }
1221
1222 function updateKeywords() {
1223 const displayKeywords: string[] = [];
1224 if (config.SHADING) displayKeywords.push('SHADING');
1225 if (config.SUNRAYS) displayKeywords.push('SUNRAYS');
1226 displayMaterial.setKeywords(displayKeywords);
1227 }
1228
1229 function scaleByPixelRatio(input: number) {
1230 const pixelRatio = window.devicePixelRatio || 1;
1231 return Math.floor(input * pixelRatio);
1232 }
1233
1234 function resizeCanvas() {
1235 const width = scaleByPixelRatio(fluidCanvas.clientWidth);
1236 const height = scaleByPixelRatio(fluidCanvas.clientHeight);
1237 if (fluidCanvas.width !== width || fluidCanvas.height !== height) {
1238 fluidCanvas.width = width;
1239 fluidCanvas.height = height;
1240 scheduleMaskDraw();
1241 return true;
1242 }
1243 return false;
1244 }
1245
1246 function HSVtoRGB(h: number, s: number, v: number) {
1247 let r = 0,
1248 g = 0,
1249 b = 0;
1250 const i = Math.floor(h * 6);
1251 const f = h * 6 - i;
1252 const p = v * (1 - s);
1253 const q = v * (1 - f * s);
1254 const t = v * (1 - (1 - f) * s);
1255
1256 switch (i % 6) {
1257 case 0:
1258 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1259 ((r = v), (g = t), (b = p));
1260 break;
1261 case 1:
1262 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1263 ((r = q), (g = v), (b = p));
1264 break;
1265 case 2:
1266 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1267 ((r = p), (g = v), (b = t));
1268 break;
1269 case 3:
1270 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1271 ((r = p), (g = q), (b = v));
1272 break;
1273 case 4:
1274 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1275 ((r = t), (g = p), (b = v));
1276 break;
1277 case 5:
1278 // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1279 ((r = v), (g = p), (b = q));
1280 break;
1281 }
1282
1283 return { r, g, b };
1284 }
1285
1286 function generateColor() {
1287 const c = HSVtoRGB(
1288 Math.random() * (config.END_HUE - config.START_HUE) + config.START_HUE,
1289 1.0,
1290 1.0
1291 );
1292 c.r *= 0.15;
1293 c.g *= 0.15;
1294 c.b *= 0.15;
1295 return c;
1296 }
1297
1298 function correctRadius(radius: number) {
1299 const aspectRatio = fluidCanvas.width / fluidCanvas.height;
1300 if (aspectRatio > 1) radius *= aspectRatio;
1301 return radius;
1302 }
1303
1304 function splat(
1305 x: number,
1306 y: number,
1307 dx: number,
1308 dy: number,
1309 color: { r: number; g: number; b: number }
1310 ) {
1311 splatProgram.bind();
1312 gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
1313 gl.uniform1f(splatProgram.uniforms.aspectRatio, fluidCanvas.width / fluidCanvas.height);
1314 gl.uniform2f(splatProgram.uniforms.point, x, y);
1315 gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0);
1316 gl.uniform1f(splatProgram.uniforms.radius, correctRadius(config.SPLAT_RADIUS / 100.0));
1317 blit(velocity.write);
1318 velocity.swap();
1319
1320 gl.uniform1i(splatProgram.uniforms.uTarget, dye.read.attach(0));
1321 gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);
1322 blit(dye.write);
1323 dye.swap();
1324 }
1325
1326 function multipleSplats(amount: number) {
1327 for (let i = 0; i < amount; i++) {
1328 const color = generateColor();
1329 color.r *= 10.0;
1330 color.g *= 10.0;
1331 color.b *= 10.0;
1332 const x = Math.random();
1333 const y = Math.random() < 0.5 ? 0.95 : 0.05;
1334 const dx = 300 * (Math.random() - 0.5);
1335 const dy = 3000 * (Math.random() - 0.5);
1336 splat(x, y, dx, dy, color);
1337 }
1338 }
1339
1340 function splatPointer(pointer: Pointer) {
1341 const dx = pointer.deltaX * config.SPLAT_FORCE * 12;
1342 const dy = pointer.deltaY * config.SPLAT_FORCE * 12;
1343 splat(pointer.texcoordX, pointer.texcoordY, dx, dy, {
1344 r: pointer.color[0],
1345 g: pointer.color[1],
1346 b: pointer.color[2]
1347 });
1348 }
1349
1350 function step(dt: number) {
1351 gl.disable(gl.BLEND);
1352
1353 curlProgram.bind();
1354 gl.uniform2f(curlProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
1355 gl.uniform1i(curlProgram.uniforms.uVelocity, velocity.read.attach(0));
1356 blit(curl);
1357
1358 vorticityProgram.bind();
1359 gl.uniform2f(vorticityProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
1360 gl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.read.attach(0));
1361 gl.uniform1i(vorticityProgram.uniforms.uCurl, curl.attach(1));
1362 gl.uniform1f(vorticityProgram.uniforms.curl, config.CURL);
1363 gl.uniform1f(vorticityProgram.uniforms.dt, dt);
1364 blit(velocity.write);
1365 velocity.swap();
1366
1367 divergenceProgram.bind();
1368 gl.uniform2f(divergenceProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
1369 gl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.read.attach(0));
1370 blit(divergence);
1371
1372 clearProgram.bind();
1373 gl.uniform1i(clearProgram.uniforms.uTexture, pressure.read.attach(0));
1374 gl.uniform1f(clearProgram.uniforms.value, config.PRESSURE);
1375 blit(pressure.write);
1376 pressure.swap();
1377
1378 pressureProgram.bind();
1379 gl.uniform2f(pressureProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
1380 gl.uniform1i(pressureProgram.uniforms.uDivergence, divergence.attach(0));
1381 for (let i = 0; i < config.PRESSURE_ITERATIONS; i++) {
1382 gl.uniform1i(pressureProgram.uniforms.uPressure, pressure.read.attach(1));
1383 blit(pressure.write);
1384 pressure.swap();
1385 }
1386
1387 gradienSubtractProgram.bind();
1388 gl.uniform2f(
1389 gradienSubtractProgram.uniforms.texelSize,
1390 velocity.texelSizeX,
1391 velocity.texelSizeY
1392 );
1393 gl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressure.read.attach(0));
1394 gl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.read.attach(1));
1395 blit(velocity.write);
1396 velocity.swap();
1397
1398 advectionProgram.bind();
1399 gl.uniform2f(advectionProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
1400 if (!ext.supportLinearFiltering) {
1401 gl.uniform2f(
1402 advectionProgram.uniforms.dyeTexelSize,
1403 velocity.texelSizeX,
1404 velocity.texelSizeY
1405 );
1406 }
1407 const velocityId = velocity.read.attach(0);
1408 gl.uniform1i(advectionProgram.uniforms.uVelocity, velocityId);
1409 gl.uniform1i(advectionProgram.uniforms.uSource, velocityId);
1410 gl.uniform1f(advectionProgram.uniforms.dt, dt);
1411 gl.uniform1f(advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION);
1412 blit(velocity.write);
1413 velocity.swap();
1414
1415 if (!ext.supportLinearFiltering) {
1416 gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, dye.texelSizeX, dye.texelSizeY);
1417 }
1418 gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.read.attach(0));
1419 gl.uniform1i(advectionProgram.uniforms.uSource, dye.read.attach(1));
1420 gl.uniform1f(advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION);
1421 blit(dye.write);
1422 dye.swap();
1423 }
1424
1425 function blur(target: FBO, temp: FBO, iterations: number) {
1426 blurProgram.bind();
1427 for (let i = 0; i < iterations; i++) {
1428 gl.uniform2f(blurProgram.uniforms.texelSize, target.texelSizeX, 0.0);
1429 gl.uniform1i(blurProgram.uniforms.uTexture, target.attach(0));
1430 blit(temp);
1431
1432 gl.uniform2f(blurProgram.uniforms.texelSize, 0.0, target.texelSizeY);
1433 gl.uniform1i(blurProgram.uniforms.uTexture, temp.attach(0));
1434 blit(target);
1435 }
1436 }
1437
1438 function applySunrays(source: FBO, mask: FBO, destination: FBO) {
1439 gl.disable(gl.BLEND);
1440 sunraysMaskProgram.bind();
1441 gl.uniform1i(sunraysMaskProgram.uniforms.uTexture, source.attach(0));
1442 blit(mask);
1443
1444 sunraysProgram.bind();
1445 gl.uniform1f(sunraysProgram.uniforms.weight, config.SUNRAYS_WEIGHT);
1446 gl.uniform1i(sunraysProgram.uniforms.uTexture, mask.attach(0));
1447 blit(destination);
1448 }
1449
1450 function drawColor(target: FBO | null, color: { r: number; g: number; b: number }) {
1451 colorProgram.bind();
1452 gl.uniform4f(colorProgram.uniforms.color, color.r, color.g, color.b, 1);
1453 blit(target);
1454 }
1455
1456 function drawDisplay(target: FBO | null) {
1457 const width = target === null ? gl.drawingBufferWidth : target.width;
1458 const height = target === null ? gl.drawingBufferHeight : target.height;
1459
1460 displayMaterial.bind();
1461 if (config.SHADING) {
1462 gl.uniform2f(displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height);
1463 }
1464 gl.uniform1i(displayMaterial.uniforms.uTexture, dye.read.attach(0));
1465 if (config.SUNRAYS) {
1466 gl.uniform1i(displayMaterial.uniforms.uSunrays, sunrays.attach(3));
1467 }
1468 blit(target);
1469 }
1470
1471 function render(target: FBO | null) {
1472 if (config.SUNRAYS) {
1473 applySunrays(dye.read, dye.write, sunrays);
1474 blur(sunrays, sunraysTemp, 1);
1475 }
1476
1477 if (target === null || !config.TRANSPARENT) {
1478 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1479 gl.enable(gl.BLEND);
1480 } else {
1481 gl.disable(gl.BLEND);
1482 }
1483
1484 if (!config.TRANSPARENT) {
1485 drawColor(target, {
1486 r: config.BACK_COLOR.r / 255,
1487 g: config.BACK_COLOR.g / 255,
1488 b: config.BACK_COLOR.b / 255
1489 });
1490 }
1491 drawDisplay(target);
1492 }
1493
1494 function wrap(value: number, min: number, max: number) {
1495 const range = max - min;
1496 if (range === 0) return min;
1497 return ((value - min) % range) + min;
1498 }
1499
1500 let lastUpdateTime = Date.now();
1501 let colorUpdateTimer = 0.0;
1502
1503 function updateColors(dt: number) {
1504 if (!config.COLORFUL) return;
1505 colorUpdateTimer += dt * config.COLOR_UPDATE_SPEED;
1506 if (colorUpdateTimer >= 1) {
1507 colorUpdateTimer = wrap(colorUpdateTimer, 0, 1);
1508 pointers.forEach((p) => {
1509 const c = generateColor();
1510 p.color = [c.r, c.g, c.b];
1511 });
1512 }
1513 }
1514
1515 function applyInputs() {
1516 if (splatStack.length > 0) multipleSplats(splatStack.pop()!);
1517
1518 pointers.forEach((p) => {
1519 if (p.moved) {
1520 p.moved = false;
1521 splatPointer(p);
1522 }
1523 });
1524 }
1525
1526 function calcDeltaTime() {
1527 const now = Date.now();
1528 let dt = (now - lastUpdateTime) / 1000;
1529 // Allow up to ~30fps worth of time to pass per frame to handle browser throttling
1530 // This prevents the simulation from appearing to slow down when RAF is throttled
1531 dt = Math.min(dt, 0.033);
1532 lastUpdateTime = now;
1533 return dt;
1534 }
1535
1536 function update() {
1537 const dt = calcDeltaTime() * (config.RENDER_SPEED ?? 1.0);
1538 if (resizeCanvas()) initFramebuffers();
1539 if (!maskReady) {
1540 scheduleMaskDraw();
1541 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
1542 gl.clearColor(0.0, 0.0, 0.0, 1.0);
1543 gl.clear(gl.COLOR_BUFFER_BIT);
1544 animationId = requestAnimationFrame(update);
1545 return;
1546 }
1547 updateColors(dt);
1548 applyInputs();
1549 if (!config.PAUSED) step(dt);
1550 render(null);
1551 animationId = requestAnimationFrame(update);
1552 }
1553
1554 function correctDeltaX(delta: number) {
1555 const aspectRatio = fluidCanvas.width / fluidCanvas.height;
1556 if (aspectRatio < 1) delta *= aspectRatio;
1557 return delta;
1558 }
1559
1560 function correctDeltaY(delta: number) {
1561 const aspectRatio = fluidCanvas.width / fluidCanvas.height;
1562 if (aspectRatio > 1) delta /= aspectRatio;
1563 return delta;
1564 }
1565
1566 function updatePointerDownData(pointer: Pointer, id: number, posX: number, posY: number) {
1567 pointer.id = id;
1568 pointer.down = true;
1569 pointer.moved = false;
1570 pointer.texcoordX = posX / fluidCanvas.width;
1571 pointer.texcoordY = 1.0 - posY / fluidCanvas.height;
1572 pointer.prevTexcoordX = pointer.texcoordX;
1573 pointer.prevTexcoordY = pointer.texcoordY;
1574 pointer.deltaX = 0;
1575 pointer.deltaY = 0;
1576 const c = generateColor();
1577 pointer.color = [c.r, c.g, c.b];
1578 }
1579
1580 function updatePointerMoveData(pointer: Pointer, posX: number, posY: number) {
1581 pointer.prevTexcoordX = pointer.texcoordX;
1582 pointer.prevTexcoordY = pointer.texcoordY;
1583 pointer.texcoordX = posX / fluidCanvas.width;
1584 pointer.texcoordY = 1.0 - posY / fluidCanvas.height;
1585 pointer.deltaX = correctDeltaX(pointer.texcoordX - pointer.prevTexcoordX);
1586 pointer.deltaY = correctDeltaY(pointer.texcoordY - pointer.prevTexcoordY);
1587 pointer.moved = Math.abs(pointer.deltaX) > 0 || Math.abs(pointer.deltaY) > 0;
1588 }
1589
1590 function updatePointerUpData(pointer: Pointer) {
1591 pointer.down = false;
1592 }
1593
1594 // Event handlers - use container so events work over both canvases
1595 container.addEventListener('mouseenter', (e) => {
1596 // Create a small burst when mouse enters the card
1597 const rect = container.getBoundingClientRect();
1598 const posX = scaleByPixelRatio(e.clientX - rect.left);
1599 const posY = scaleByPixelRatio(e.clientY - rect.top);
1600 const x = posX / fluidCanvas.width;
1601 const y = 1.0 - posY / fluidCanvas.height;
1602 const color = generateColor();
1603 color.r *= 10.0;
1604 color.g *= 10.0;
1605 color.b *= 10.0;
1606 splat(x, y, 300 * (Math.random() - 0.5), 300 * (Math.random() - 0.5), color);
1607 });
1608
1609 container.addEventListener('mousedown', (e) => {
1610 const rect = container.getBoundingClientRect();
1611 const posX = scaleByPixelRatio(e.clientX - rect.left);
1612 const posY = scaleByPixelRatio(e.clientY - rect.top);
1613 let pointer = pointers.find((p) => p.id === -1);
1614 if (!pointer) pointer = PointerPrototype();
1615 updatePointerDownData(pointer, -1, posX, posY);
1616 });
1617
1618 container.addEventListener('mousemove', (e) => {
1619 const pointer = pointers[0];
1620 const rect = container.getBoundingClientRect();
1621 const posX = scaleByPixelRatio(e.clientX - rect.left);
1622 const posY = scaleByPixelRatio(e.clientY - rect.top);
1623 updatePointerMoveData(pointer, posX, posY);
1624 // Always create swish effect on hover
1625 if (pointer.moved) {
1626 pointer.moved = false;
1627 // Generate a new color for visual interest
1628 const c = generateColor();
1629 pointer.color = [c.r, c.g, c.b];
1630 splat(
1631 pointer.texcoordX,
1632 pointer.texcoordY,
1633 pointer.deltaX * config.SPLAT_FORCE * 12,
1634 pointer.deltaY * config.SPLAT_FORCE * 12,
1635 {
1636 r: pointer.color[0],
1637 g: pointer.color[1],
1638 b: pointer.color[2]
1639 }
1640 );
1641 }
1642 });
1643
1644 container.addEventListener('mouseup', () => {
1645 updatePointerUpData(pointers[0]);
1646 });
1647
1648 container.addEventListener('touchstart', (e) => {
1649 e.preventDefault();
1650 const touches = e.targetTouches;
1651 while (touches.length >= pointers.length) pointers.push(PointerPrototype());
1652 for (let i = 0; i < touches.length; i++) {
1653 const rect = container.getBoundingClientRect();
1654 const posX = scaleByPixelRatio(touches[i].clientX - rect.left);
1655 const posY = scaleByPixelRatio(touches[i].clientY - rect.top);
1656 updatePointerDownData(pointers[i + 1], touches[i].identifier, posX, posY);
1657 }
1658 });
1659
1660 container.addEventListener('touchmove', (e) => {
1661 e.preventDefault();
1662 const touches = e.targetTouches;
1663 for (let i = 0; i < touches.length; i++) {
1664 const pointer = pointers[i + 1];
1665 if (!pointer.down) continue;
1666 const rect = container.getBoundingClientRect();
1667 const posX = scaleByPixelRatio(touches[i].clientX - rect.left);
1668 const posY = scaleByPixelRatio(touches[i].clientY - rect.top);
1669 updatePointerMoveData(pointer, posX, posY);
1670 }
1671 });
1672
1673 container.addEventListener('touchend', (e) => {
1674 const touches = e.changedTouches;
1675 for (let i = 0; i < touches.length; i++) {
1676 const pointer = pointers.find((p) => p.id === touches[i].identifier);
1677 if (pointer) updatePointerUpData(pointer);
1678 }
1679 });
1680
1681 // Initialize
1682 updateKeywords();
1683 initFramebuffers();
1684 multipleSplats(25);
1685 update();
1686
1687 // Auto splat interval
1688 splatIntervalId = setInterval(() => {
1689 multipleSplats(5);
1690 }, 500);
1691
1692 // Resize observer - also triggers initial draw
1693 resizeObserver = new ResizeObserver(() => {
1694 resizeCanvas();
1695 maskReady = false;
1696 scheduleMaskDraw();
1697 });
1698 resizeObserver.observe(container);
1699
1700 // Mark as initialized after first resize callback
1701 isInitialized = true;
1702 }
1703</script>
1704
1705<div bind:this={container} class="relative h-full w-full overflow-hidden bg-black">
1706 <canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas>
1707 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas>
1708</div>