your personal website on atproto - mirror blento.app

Merge pull request #13 from flo-bit/fluid-text

Fluid text

authored by

Florian and committed by
GitHub
ad046b23 90662d92

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

This is a binary file and will not be displayed.

+28
src/lib/cards/FluidTextCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateFluidTextCardModal from './CreateFluidTextCardModal.svelte'; 3 + import EditingFluidTextCard from './EditingFluidTextCard.svelte'; 4 + import FluidTextCard from './FluidTextCard.svelte'; 5 + import FluidTextCardSettings from './FluidTextCardSettings.svelte'; 6 + 7 + export const FluidTextCardDefinition = { 8 + type: 'fluid-text', 9 + contentComponent: FluidTextCard, 10 + editingContentComponent: EditingFluidTextCard, 11 + createNew: (card) => { 12 + card.cardType = 'fluid-text'; 13 + card.cardData = { 14 + text: '' 15 + }; 16 + card.w = 4; 17 + card.h = 2; 18 + card.mobileW = 4; 19 + card.mobileH = 2; 20 + }, 21 + creationModalComponent: CreateFluidTextCardModal, 22 + settingsComponent: FluidTextCardSettings, 23 + sidebarButtonText: 'Fluid Text', 24 + defaultColor: 'transparent', 25 + allowSetColor: false, 26 + minW: 2, 27 + minH: 2 28 + } as CardDefinition & { type: 'fluid-text' };
+2
src/lib/cards/index.ts
··· 18 18 import { YoutubeCardDefinition } from './YoutubeVideoCard'; 19 19 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 20 20 import { GithubProfileCardDefitition } from './GitHubProfileCard'; 21 + import { FluidTextCardDefinition } from './FluidTextCard'; 21 22 import { PopfeedReviewsCardDefinition } from './PopfeedReviews'; 22 23 import { TealFMPlaysCardDefinition } from './TealFMPlaysCard'; 23 24 import { PhotoGalleryCardDefinition } from './PhotoGalleryCard'; ··· 43 44 BlueskyProfileCardDefinition, 44 45 GithubProfileCardDefitition, 45 46 TetrisCardDefinition, 47 + FluidTextCardDefinition, 46 48 PopfeedReviewsCardDefinition, 47 49 TealFMPlaysCardDefinition, 48 50 PhotoGalleryCardDefinition,