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