Last active
May 14, 2023 12:02
-
-
Save AnoRebel/e54d8ea2a6f796d3a6d81847e6c81414 to your computer and use it in GitHub Desktop.
A Vue 3 Typescript composable remade from https://github.com/sirxemic/jquery.ripples
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { type Ref, ref } from "vue"; | |
import type { | |
IConfig, | |
ILocations, | |
IOptions, | |
IRipples, | |
IUniforms, | |
} from "./types"; | |
import { | |
createImageData, | |
extractUrl, | |
getOffset, | |
isDataUri, | |
isPercentage, | |
translateBackgroundPosition, | |
} from "./utils"; | |
let gl: WebGLRenderingContext | null; | |
/** | |
* Load a configuration of GL settings which the browser supports. | |
* For example: | |
* - not all browsers support WebGL | |
* - not all browsers support floating point textures | |
* - not all browsers support linear filtering for floating point textures | |
* - not all browsers support rendering to floating point textures | |
* - some browsers *do* support rendering to half-floating point textures instead. | |
*/ | |
const loadConfig = (): IConfig | null => { | |
const canvas = document.createElement("canvas"); | |
gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); | |
if (!gl) { | |
// Browser does not support WebGL. | |
return null; | |
} | |
// Load extensions | |
const extensions: Map<string, any> = new Map(); | |
[ | |
"OES_texture_float", | |
"OES_texture_half_float", | |
"OES_texture_float_linear", | |
"OES_texture_half_float_linear", | |
].forEach((name) => { | |
const extension = gl.getExtension(name); | |
if (extension) { | |
extensions.set(name, extension); | |
} | |
}); | |
// If no floating point extensions are supported we can bail out early. | |
if (!extensions.has("OES_texture_float")) { | |
return null; | |
} | |
const configs: IConfig[] = []; | |
const createConfig = (type: string, glType: any, arrayType: any) => { | |
const name = "OES_texture_" + type, | |
nameLinear = name + "_linear", | |
linearSupport = extensions.has(nameLinear), | |
configExtensions = [name]; | |
if (linearSupport) { | |
configExtensions.push(nameLinear); | |
} | |
return { | |
type: glType, | |
arrayType: arrayType, | |
linearSupport: linearSupport, | |
extensions: configExtensions, | |
}; | |
}; | |
configs.push( | |
createConfig("float", gl.FLOAT, Float32Array), | |
); | |
if (extensions.has("OES_texture_half_float")) { | |
configs.push( | |
// Array type should be Uint16Array, but at least on iOS that breaks. In that case we | |
// just initialize the textures with data=null, instead of data=new Uint16Array(...). | |
// This makes initialization a tad slower, but it's still negligible. | |
createConfig( | |
"half_float", | |
extensions.get("OES_texture_half_float").HALF_FLOAT_OES, | |
null, | |
), | |
); | |
} | |
// Setup the texture and framebuffer | |
const texture = gl.createTexture(); | |
const framebuffer = gl.createFramebuffer(); | |
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); | |
gl.bindTexture(gl.TEXTURE_2D, texture); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
// Check for each supported texture type if rendering to it is supported | |
let config: IConfig | null = null; | |
for (let i = 0; i < configs.length; i++) { | |
gl.texImage2D( | |
gl.TEXTURE_2D, | |
0, | |
gl.RGBA, | |
32, | |
32, | |
0, | |
gl.RGBA, | |
configs[i].type, | |
null, | |
); | |
gl.framebufferTexture2D( | |
gl.FRAMEBUFFER, | |
gl.COLOR_ATTACHMENT0, | |
gl.TEXTURE_2D, | |
texture, | |
0, | |
); | |
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) { | |
config = configs[i]; | |
break; | |
} | |
} | |
return config; | |
}; | |
const createProgram = ( | |
vertexSource: string, | |
fragmentSource: string, | |
uniformValues = null, | |
): { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; //Map<string, WebGLUniformLocation>; | |
} => { | |
const compileSource = ( | |
type: number, | |
source: string, | |
): WebGLShader => { | |
const shader: WebGLShader = gl.createShader(type); | |
gl.shaderSource(shader, source); | |
gl.compileShader(shader); | |
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
throw new Error("compile error: " + gl.getShaderInfoLog(shader)); | |
} | |
return shader; | |
}; | |
const program: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; //Map<string, WebGLUniformLocation>; | |
} = {}; | |
program.id = gl.createProgram(); | |
gl.attachShader(program.id, compileSource(gl.VERTEX_SHADER, vertexSource)); | |
gl.attachShader( | |
program.id, | |
compileSource(gl.FRAGMENT_SHADER, fragmentSource), | |
); | |
gl.linkProgram(program.id); | |
if (!gl.getProgramParameter(program.id, gl.LINK_STATUS)) { | |
throw new Error("link error: " + gl.getProgramInfoLog(program.id)); | |
} | |
// Fetch the uniform and attribute locations | |
program.uniforms = {}; | |
program.locations = {}; | |
gl.useProgram(program.id); | |
gl.enableVertexAttribArray(0); | |
let match: RegExpExecArray, | |
name: string; | |
const regex = /uniform (\w+) (\w+)/g, | |
shaderCode = vertexSource + fragmentSource; | |
while ((match = regex.exec(shaderCode)) != null) { | |
name = match[2]; | |
program.locations[name] = gl.getUniformLocation(program.id, name); | |
} | |
return program; | |
}; | |
const bindTexture = (texture: WebGLTexture, unit = 0) => { | |
gl.activeTexture(gl.TEXTURE0 + (unit || 0)); | |
gl.bindTexture(gl.TEXTURE_2D, texture); | |
}; | |
const transparentPixels = createImageData(32, 32); | |
// RIPPLES CLASS DEFINITION | |
// ========================= | |
export class Ripples implements IRipples { | |
private element: HTMLElement; | |
private interactive: boolean = true; | |
private resolution: number = 256; | |
private perturbance: number = 0.03; | |
private dropRadius: number = 20; | |
private crossOrigin: string = ""; | |
private imageUrl: string | null; | |
private textureDelta: Float32Array; | |
private canvas: HTMLCanvasElement; | |
private context: WebGLRenderingContext; | |
private textures: WebGLTexture[]; | |
private framebuffers: WebGLFramebuffer[]; | |
private bufferWriteIndex: number; | |
private bufferReadIndex: number; | |
private quad: WebGLBuffer; | |
visible: Ref<boolean>; | |
running: Ref<boolean>; | |
inited: Ref<boolean>; | |
destroyed: Ref<boolean>; | |
config: IConfig | null; | |
private backgroundWidth!: number; | |
private backgroundHeight!: number; | |
private originalCssBackgroundImage!: string; | |
private imageSource!: string; | |
private backgroundTexture!: WebGLTexture; | |
private renderProgram!: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
private updateProgram!: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
private dropProgram!: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
private originalInlineCss!: string; | |
constructor( | |
element: HTMLElement, | |
options: IOptions, | |
) { | |
this.element = element; | |
this.config = loadConfig(); | |
// Init properties from options | |
// Whether mouse clicks and mouse movement triggers the effect. | |
this.interactive = options.interactive || true; | |
// The width and height of the WebGL texture to render to. The larger this value, the smoother the rendering and the slower the ripples will propagate. | |
this.resolution = options.resolution || 256; | |
this.textureDelta = new Float32Array([ | |
1 / this.resolution, | |
1 / this.resolution, | |
]); | |
// Basically the amount of refraction caused by a ripple. 0 means there is no refraction. | |
this.perturbance = options.perturbance || 0.03; | |
// The size (in pixels) of the drop that results by clicking or moving the mouse over the canvas. | |
this.dropRadius = options.dropRadius || 20; | |
// The crossOrigin attribute to use for the affected image. | |
this.crossOrigin = options.crossOrigin || ""; | |
// The URL of the image to use as the background. | |
this.imageUrl = options.imageUrl || null; | |
// Init WebGL canvas | |
this.canvas = document.createElement("canvas"); | |
this.canvas.width = this.element.clientWidth; | |
this.canvas.height = this.element.clientHeight; | |
Object.assign(this.canvas.style, { | |
position: "absolute", | |
left: 0, | |
top: 0, | |
right: 0, | |
bottom: 0, | |
zIndex: -1, | |
}); | |
this.element.style.position = "relative"; | |
this.element.style.zIndex = "0"; | |
this.element.append(this.canvas); | |
this.context = gl = this.canvas.getContext("webgl") || | |
this.canvas.getContext("experimental-webgl"); | |
// Load extensions | |
this.config.extensions.forEach((name: string) => gl.getExtension(name)); | |
// Auto-resize when window size changes. | |
// this.updateSize = this.updateSize.bind(this); | |
window.addEventListener("resize", this.updateSize); | |
// Init rendertargets for ripple data. | |
this.textures = []; | |
this.framebuffers = []; | |
this.bufferWriteIndex = 0; | |
this.bufferReadIndex = 1; | |
const arrayType = this.config.arrayType; | |
const textureData = arrayType | |
? new arrayType(this.resolution * this.resolution * 4) | |
: null; | |
for (let i = 0; i < 2; i++) { | |
const texture = gl.createTexture(); | |
const framebuffer = gl.createFramebuffer(); | |
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); | |
gl.bindTexture(gl.TEXTURE_2D, texture); | |
gl.texParameteri( | |
gl.TEXTURE_2D, | |
gl.TEXTURE_MIN_FILTER, | |
this.config.linearSupport ? gl.LINEAR : gl.NEAREST, | |
); | |
gl.texParameteri( | |
gl.TEXTURE_2D, | |
gl.TEXTURE_MAG_FILTER, | |
this.config.linearSupport ? gl.LINEAR : gl.NEAREST, | |
); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
gl.texImage2D( | |
gl.TEXTURE_2D, | |
0, | |
gl.RGBA, | |
this.resolution, | |
this.resolution, | |
0, | |
gl.RGBA, | |
this.config.type, | |
textureData, | |
); | |
gl.framebufferTexture2D( | |
gl.FRAMEBUFFER, | |
gl.COLOR_ATTACHMENT0, | |
gl.TEXTURE_2D, | |
texture, | |
0, | |
); | |
this.textures.push(texture); | |
this.framebuffers.push(framebuffer); | |
} | |
// Init GL stuff | |
this.quad = gl.createBuffer(); | |
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad); | |
gl.bufferData( | |
gl.ARRAY_BUFFER, | |
new Float32Array([ | |
-1, | |
-1, | |
+1, | |
-1, | |
+1, | |
+1, | |
-1, | |
+1, | |
]), | |
gl.STATIC_DRAW, | |
); | |
this.#initShaders(); | |
this.#initTexture(); | |
this.#setTransparentTexture(); | |
// Load the image either from the options or CSS rules | |
this.#loadImage(); | |
// Set correct clear color and blend mode (regular alpha blending) | |
gl.clearColor(0, 0, 0, 0); | |
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
// Plugin is successfully initialized! | |
this.visible = ref(true); | |
this.running = ref(true); | |
this.inited = ref(true); | |
this.destroyed = ref(false); | |
this.#setupPointerEvents(); | |
// Init animation | |
const step = () => { | |
if (!this.destroyed.value) { | |
this.#step(); | |
requestAnimationFrame(step); | |
} | |
}; | |
requestAnimationFrame(step); | |
} | |
// Set up pointer (mouse + touch) events | |
#setupPointerEvents() { | |
const pointerEventsEnabled = () => | |
this.visible.value && this.running.value && this.interactive; | |
const dropAtPointer = (pointer: Touch | MouseEvent, big = false) => { | |
if (pointerEventsEnabled()) { | |
this.#dropAtPointer( | |
pointer, | |
this.dropRadius * (big ? 1.5 : 1), | |
big ? 0.14 : 0.01, | |
); | |
} | |
}; | |
// Start listening to pointer events | |
// Create regular, small ripples for mouse move and touch events... | |
this.element.addEventListener("mousemove", (e) => dropAtPointer(e)); | |
this.element.addEventListener("touchmove", (e) => { | |
const touches = e.changedTouches; | |
for (let i = 0; i < touches.length; i++) { | |
dropAtPointer(touches[i]); | |
} | |
}); | |
this.element.addEventListener("touchstart", (e) => { | |
const touches = e.changedTouches; | |
for (let i = 0; i < touches.length; i++) { | |
dropAtPointer(touches[i]); | |
} | |
}); | |
// ...and only a big ripple on mouse down events. | |
this.element.addEventListener("mousedown", (e) => dropAtPointer(e, true)); | |
} | |
// Load the image either from the options or the element's CSS rules. | |
#loadImage() { | |
gl = this.context; | |
// NOTE: Known bug, will return 'auto' if style value is 'auto' | |
// const win = this.element.ownerDocument.defaultView; | |
// null means not to return pseudo styles | |
// win.getComputedStyle(this.element, null).color; | |
const newImageSource = this.imageUrl || | |
extractUrl(this.originalCssBackgroundImage) || | |
extractUrl(getComputedStyle(this.element)["backgroundImage"]); | |
// If image source is unchanged, don't reload it. | |
if (newImageSource == this.imageSource) { | |
return; | |
} | |
this.imageSource = newImageSource; | |
// Falsy source means no background. | |
if (!this.imageSource) { | |
this.#setTransparentTexture(); | |
return; | |
} | |
// Load the texture from a new image. | |
const image = new Image(); | |
image.onload = () => { | |
gl = this.context; | |
// Only textures with dimensions of powers of two can have repeat wrapping. | |
const isPowerOfTwo = (x: number) => (x & (x - 1)) == 0; | |
const wrapping = (isPowerOfTwo(image.width) && isPowerOfTwo(image.height)) | |
? gl.REPEAT | |
: gl.CLAMP_TO_EDGE; | |
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapping); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapping); | |
gl.texImage2D( | |
gl.TEXTURE_2D, | |
0, | |
gl.RGBA, | |
gl.RGBA, | |
gl.UNSIGNED_BYTE, | |
image, | |
); | |
this.backgroundWidth = image.width; | |
this.backgroundHeight = image.height; | |
// Hide the background that we're replacing. | |
this.#hideCssBackground(); | |
}; | |
// Fall back to a transparent texture when loading the image failed. | |
image.onerror = () => { | |
gl = this.context; | |
this.#setTransparentTexture(); | |
}; | |
// Disable CORS when the image source is a data URI. | |
image.crossOrigin = isDataUri(this.imageSource) ? null : this.crossOrigin; | |
image.src = this.imageSource; | |
} | |
#step() { | |
gl = this.context; | |
if (!this.visible.value) { | |
return; | |
} | |
this.#computeTextureBoundaries(); | |
if (this.running.value) { | |
this.update(); | |
} | |
this.render(); | |
} | |
#drawQuad() { | |
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad); | |
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); | |
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); | |
} | |
render() { | |
gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
gl.viewport(0, 0, this.canvas.width, this.canvas.height); | |
gl.enable(gl.BLEND); | |
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
gl.useProgram(this.renderProgram.id); | |
bindTexture(this.backgroundTexture, 0); | |
bindTexture(this.textures[0], 1); | |
gl.uniform1f(this.renderProgram.locations.perturbance, this.perturbance); | |
gl.uniform2fv( | |
this.renderProgram.locations.topLeft, | |
this.renderProgram.uniforms.topLeft, | |
); | |
gl.uniform2fv( | |
this.renderProgram.locations.bottomRight, | |
this.renderProgram.uniforms.bottomRight, | |
); | |
gl.uniform2fv( | |
this.renderProgram.locations.containerRatio, | |
this.renderProgram.uniforms.containerRatio, | |
); | |
gl.uniform1i(this.renderProgram.locations.samplerBackground, 0); | |
gl.uniform1i(this.renderProgram.locations.samplerRipples, 1); | |
this.#drawQuad(); | |
gl.disable(gl.BLEND); | |
} | |
update() { | |
gl.viewport(0, 0, this.resolution, this.resolution); | |
gl.bindFramebuffer( | |
gl.FRAMEBUFFER, | |
this.framebuffers[this.bufferWriteIndex], | |
); | |
bindTexture(this.textures[this.bufferReadIndex]); | |
gl.useProgram(this.updateProgram.id); | |
this.#drawQuad(); | |
this.#swapBufferIndices(); | |
} | |
#swapBufferIndices() { | |
this.bufferWriteIndex = 1 - this.bufferWriteIndex; | |
this.bufferReadIndex = 1 - this.bufferReadIndex; | |
} | |
#computeTextureBoundaries() { | |
// NOTE: Known bug, will return 'auto' if style value is 'auto' | |
// const win = this.element.ownerDocument.defaultView; | |
// null means not to return pseudo styles | |
// win.getComputedStyle(this.element, null).color; | |
let backgroundSize = getComputedStyle(this.element)["background-size"]; | |
const backgroundAttachment = | |
getComputedStyle(this.element)["background-attachment"]; | |
const backgroundPosition = translateBackgroundPosition( | |
getComputedStyle(this.element)["background-position"], | |
); | |
// Here the 'container' is the element which the background adapts to | |
// (either the chrome window or some element, depending on attachment) | |
let container: { left: number; top: number; height: number; width: number }; | |
if (backgroundAttachment == "fixed") { | |
container = { | |
left: (window.pageXOffset || window.scrollX), | |
top: (window.pageYOffset || window.scrollY), | |
}; | |
container.width = window.document.documentElement.clientWidth || | |
window.innerWidth; | |
container.height = window.document.documentElement.clientHeight || | |
window.innerHeight; | |
} else { | |
container = getOffset(this.element); | |
container.width = this.element.clientWidth; | |
container.height = this.element.clientHeight; | |
} | |
// TODO: background-clip | |
if (backgroundSize == "cover") { | |
const scale = Math.max( | |
container.width / this.backgroundWidth, | |
container.height / this.backgroundHeight, | |
); | |
const backgroundWidth = this.backgroundWidth * scale; | |
const backgroundHeight = this.backgroundHeight * scale; | |
} else if (backgroundSize == "contain") { | |
const scale = Math.min( | |
container.width / this.backgroundWidth, | |
container.height / this.backgroundHeight, | |
); | |
const backgroundWidth = this.backgroundWidth * scale; | |
const backgroundHeight = this.backgroundHeight * scale; | |
} else { | |
backgroundSize = backgroundSize.split(" "); | |
let backgroundWidth = backgroundSize[0] || ""; | |
let backgroundHeight = backgroundSize[1] || backgroundWidth; | |
if (isPercentage(backgroundWidth)) { | |
backgroundWidth = container.width * parseFloat(backgroundWidth) / 100; | |
} else if (backgroundWidth != "auto") { | |
backgroundWidth = parseFloat(backgroundWidth); | |
} | |
if (isPercentage(backgroundHeight)) { | |
backgroundHeight = container.height * parseFloat(backgroundHeight) / | |
100; | |
} else if (backgroundHeight != "auto") { | |
backgroundHeight = parseFloat(backgroundHeight); | |
} | |
if (backgroundWidth == "auto" && backgroundHeight == "auto") { | |
backgroundWidth = this.backgroundWidth; | |
backgroundHeight = this.backgroundHeight; | |
} else { | |
if (backgroundWidth == "auto") { | |
backgroundWidth = this.backgroundWidth * | |
(backgroundHeight / this.backgroundHeight); | |
} | |
if (backgroundHeight == "auto") { | |
backgroundHeight = this.backgroundHeight * | |
(backgroundWidth / this.backgroundWidth); | |
} | |
} | |
} | |
// Compute backgroundX and backgroundY in page coordinates | |
let backgroundX = backgroundPosition[0]; | |
let backgroundY = backgroundPosition[1]; | |
if (isPercentage(backgroundX)) { | |
backgroundX = String( | |
container.left + | |
(container.width - backgroundWidth) * parseFloat(backgroundX) / 100, | |
); | |
} else { | |
backgroundX = String(container.left + parseFloat(backgroundX)); | |
} | |
if (isPercentage(backgroundY)) { | |
backgroundY = String( | |
container.top + | |
(container.height - backgroundHeight) * parseFloat(backgroundY) / 100, | |
); | |
} else { | |
backgroundY = String(container.top + parseFloat(backgroundY)); | |
} | |
const elementOffset = getOffset(this.element); | |
this.renderProgram.uniforms.topLeft = new Float32Array([ | |
(elementOffset.left - backgroundX) / backgroundWidth, | |
(elementOffset.top - backgroundY) / backgroundHeight, | |
]); | |
this.renderProgram.uniforms.bottomRight = new Float32Array([ | |
this.renderProgram.uniforms.topLeft[0] + | |
this.element.clientWidth / backgroundWidth, | |
this.renderProgram.uniforms.topLeft[1] + | |
this.element.clientHeight / backgroundHeight, | |
]); | |
const maxSide = Math.max(this.canvas.width, this.canvas.height); | |
this.renderProgram.uniforms.containerRatio = new Float32Array([ | |
this.canvas.width / maxSide, | |
this.canvas.height / maxSide, | |
]); | |
} | |
#initShaders() { | |
const vertexShader = [ | |
"attribute vec2 vertex;", | |
"varying vec2 coord;", | |
"void main() {", | |
"coord = vertex * 0.5 + 0.5;", | |
"gl_Position = vec4(vertex, 0.0, 1.0);", | |
"}", | |
].join("\n"); | |
this.dropProgram = createProgram( | |
vertexShader, | |
[ | |
"precision highp float;", | |
"const float PI = 3.141592653589793;", | |
"uniform sampler2D texture;", | |
"uniform vec2 center;", | |
"uniform float radius;", | |
"uniform float strength;", | |
"varying vec2 coord;", | |
"void main() {", | |
"vec4 info = texture2D(texture, coord);", | |
"float drop = max(0.0, 1.0 - length(center * 0.5 + 0.5 - coord) / radius);", | |
"drop = 0.5 - cos(drop * PI) * 0.5;", | |
"info.r += drop * strength;", | |
"gl_FragColor = info;", | |
"}", | |
].join("\n"), | |
); | |
this.updateProgram = createProgram( | |
vertexShader, | |
[ | |
"precision highp float;", | |
"uniform sampler2D texture;", | |
"uniform vec2 delta;", | |
"varying vec2 coord;", | |
"void main() {", | |
"vec4 info = texture2D(texture, coord);", | |
"vec2 dx = vec2(delta.x, 0.0);", | |
"vec2 dy = vec2(0.0, delta.y);", | |
"float average = (", | |
"texture2D(texture, coord - dx).r +", | |
"texture2D(texture, coord - dy).r +", | |
"texture2D(texture, coord + dx).r +", | |
"texture2D(texture, coord + dy).r", | |
") * 0.25;", | |
"info.g += (average - info.r) * 2.0;", | |
"info.g *= 0.995;", | |
"info.r += info.g;", | |
"gl_FragColor = info;", | |
"}", | |
].join("\n"), | |
); | |
gl.uniform2fv(this.updateProgram.locations.delta, this.textureDelta); | |
this.renderProgram = createProgram( | |
[ | |
"precision highp float;", | |
"attribute vec2 vertex;", | |
"uniform vec2 topLeft;", | |
"uniform vec2 bottomRight;", | |
"uniform vec2 containerRatio;", | |
"varying vec2 ripplesCoord;", | |
"varying vec2 backgroundCoord;", | |
"void main() {", | |
"backgroundCoord = mix(topLeft, bottomRight, vertex * 0.5 + 0.5);", | |
"backgroundCoord.y = 1.0 - backgroundCoord.y;", | |
"ripplesCoord = vec2(vertex.x, -vertex.y) * containerRatio * 0.5 + 0.5;", | |
"gl_Position = vec4(vertex.x, -vertex.y, 0.0, 1.0);", | |
"}", | |
].join("\n"), | |
[ | |
"precision highp float;", | |
"uniform sampler2D samplerBackground;", | |
"uniform sampler2D samplerRipples;", | |
"uniform vec2 delta;", | |
"uniform float perturbance;", | |
"varying vec2 ripplesCoord;", | |
"varying vec2 backgroundCoord;", | |
"void main() {", | |
"float height = texture2D(samplerRipples, ripplesCoord).r;", | |
"float heightX = texture2D(samplerRipples, vec2(ripplesCoord.x + delta.x, ripplesCoord.y)).r;", | |
"float heightY = texture2D(samplerRipples, vec2(ripplesCoord.x, ripplesCoord.y + delta.y)).r;", | |
"vec3 dx = vec3(delta.x, heightX - height, 0.0);", | |
"vec3 dy = vec3(0.0, heightY - height, delta.y);", | |
"vec2 offset = -normalize(cross(dy, dx)).xz;", | |
"float specular = pow(max(0.0, dot(offset, normalize(vec2(-0.6, 1.0)))), 4.0);", | |
"gl_FragColor = texture2D(samplerBackground, backgroundCoord + offset * perturbance) + specular;", | |
"}", | |
].join("\n"), | |
); | |
gl.uniform2fv(this.renderProgram.locations.delta, this.textureDelta); | |
} | |
#initTexture() { | |
this.backgroundTexture = gl.createTexture(); | |
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture); | |
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
} | |
#setTransparentTexture() { | |
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture); | |
gl.texImage2D( | |
gl.TEXTURE_2D, | |
0, | |
gl.RGBA, | |
gl.RGBA, | |
gl.UNSIGNED_BYTE, | |
transparentPixels, | |
); | |
} | |
#hideCssBackground() { | |
// Check whether we're changing inline CSS or overriding a global CSS rule. | |
const inlineCss = this.element.style.backgroundImage; | |
if (inlineCss == "none") { | |
return; | |
} | |
this.originalInlineCss = inlineCss; | |
this.originalCssBackgroundImage = | |
getComputedStyle(this.element)["backgroundImage"]; | |
this.element.style.backgroundImage = "none"; | |
} | |
#restoreCssBackground() { | |
// Restore background by either changing the inline CSS rule to what it was, or | |
// simply remove the inline CSS rule if it never was inlined. | |
this.element.style.backgroundImage = this.originalInlineCss || ""; | |
} | |
#dropAtPointer( | |
pointer: Touch | MouseEvent, | |
radius: number, | |
strength: number | Float32Array | number[] | Int32Array, | |
) { | |
const borderLeft = | |
parseInt(getComputedStyle(this.element)["border-left-width"]) || 0, | |
borderTop = | |
parseInt(getComputedStyle(this.element)["border-top-width"]) || 0; | |
this.drop( | |
pointer.pageX - getOffset(this.element).left - borderLeft, | |
pointer.pageY - getOffset(this.element).top - borderTop, | |
radius, | |
strength, | |
); | |
} | |
/** | |
* Public methods | |
*/ | |
/** | |
* Manually add a drop at the element's relative coordinates (x, y). radius controls the drop's size and strength the amplitude of the resulting ripple. | |
*/ | |
drop( | |
x: number, | |
y: number, | |
radius: number, | |
strength: number, | |
) { | |
gl = this.context; | |
const elWidth = this.element.clientWidth; | |
const elHeight = this.element.clientHeight; | |
const longestSide = Math.max(elWidth, elHeight); | |
radius = radius / longestSide; | |
const dropPosition = new Float32Array([ | |
(2 * x - elWidth) / longestSide, | |
(elHeight - 2 * y) / longestSide, | |
]); | |
gl.viewport(0, 0, this.resolution, this.resolution); | |
gl.bindFramebuffer( | |
gl.FRAMEBUFFER, | |
this.framebuffers[this.bufferWriteIndex], | |
); | |
bindTexture(this.textures[this.bufferReadIndex]); | |
gl.useProgram(this.dropProgram.id); | |
gl.uniform2fv(this.dropProgram.locations.center, dropPosition); | |
gl.uniform1f(this.dropProgram.locations.radius, radius); | |
gl.uniform1f(this.dropProgram.locations.strength, strength); | |
this.#drawQuad(); | |
this.#swapBufferIndices(); | |
return this; | |
} | |
/** | |
* The effect resizes automatically when the width or height of the window changes. When the dimensions of the element changes, you need to call this method to update the size of the effect accordingly. | |
*/ | |
updateSize() { | |
const newWidth = this.element.clientWidth, | |
newHeight = this.element.clientHeight; | |
if (newWidth != this.canvas.width || newHeight != this.canvas.height) { | |
this.canvas.width = newWidth; | |
this.canvas.height = newHeight; | |
} | |
return this; | |
} | |
/** | |
* Remove the effect from the element. | |
*/ | |
destroy() { | |
this.element.removeEventListener( | |
"mousemove", | |
(e) => this.#dropAtPointer(e, this.dropRadius, 0.01), | |
); | |
this.element.removeEventListener("touchmove", (e) => { | |
const touches = e.changedTouches; | |
for (let i = 0; i < touches.length; i++) { | |
this.#dropAtPointer(touches[i], this.dropRadius, 0.01); | |
} | |
}); | |
this.element.removeEventListener("touchstart", (e) => { | |
const touches = e.changedTouches; | |
for (let i = 0; i < touches.length; i++) { | |
this.#dropAtPointer(touches[i], this.dropRadius, 0.01); | |
} | |
}); | |
// ...and only a big ripple on mouse down events. | |
this.element.removeEventListener( | |
"mousedown", | |
(e) => this.#dropAtPointer(e, this.dropRadius * 1.5, 0.14), | |
); | |
// this.element.classList.remove("jquery-ripples") | |
this.element.removeAttribute("data-ripples"); | |
// Make sure the last used context is garbage-collected | |
gl = null; | |
window.removeEventListener("resize", this.updateSize); | |
this.canvas.remove(); | |
this.#restoreCssBackground(); | |
this.destroyed.value = true; | |
} | |
/** | |
* Toggle the effect's visibility to be shown. It will also effectively resume the simulation. | |
*/ | |
show() { | |
this.visible.value = true; | |
this.canvas.style.display = ""; // block | flex | |
this.#hideCssBackground(); | |
return this; | |
} | |
/** | |
* Toggle the effect's visibility to be hidden. It will also effectively pause the simulation. | |
*/ | |
hide() { | |
this.visible.value = false; | |
this.canvas.style.display = "none"; | |
this.#restoreCssBackground(); | |
return this; | |
} | |
/** | |
* Toggle the simulation's state to pause. | |
*/ | |
pause() { | |
this.running.value = false; | |
return this; | |
} | |
/** | |
* Toggle the simulation's state to play/resume. | |
*/ | |
play() { | |
this.running.value = true; | |
return this; | |
} | |
/** | |
* Update properties of the effect. | |
* | |
* The properties can be either `dropRadius`, `perturbance`, `interactive`, `imageUrl` or `crossOrigin` | |
*/ | |
set(property: string, value: string) { | |
switch (property) { | |
case "dropRadius": | |
this.dropRadius = Number(value); | |
break; | |
case "perturbance": | |
this.perturbance = Number(value); | |
break; | |
case "interactive": | |
this.interactive = Boolean(value); | |
break; | |
case "crossOrigin": | |
this.crossOrigin = value; | |
break; | |
case "imageUrl": | |
this.imageUrl = value; | |
this.#loadImage(); | |
break; | |
} | |
} | |
} | |
// RIPPLES COMPOSABLE DEFINITION | |
// ========================== | |
const useRipples = (element: HTMLElement, options: IOptions) => { | |
const ripples = new Ripples(element, options); | |
if (!ripples.config) { | |
throw new Error( | |
"Your browser does not support WebGL, the OES_texture_float extension or rendering to floating point textures.", | |
); | |
} | |
element.setAttribute("ripples", { ripples }); | |
return { | |
state: { | |
initialized: ripples.inited, | |
running: ripples.running, | |
visible: ripples.visible, | |
}, | |
set: (name: string, value: any) => ripples.set(name, value), | |
destroy: () => ripples.destroy(), | |
pause: () => ripples.pause(), | |
play: () => ripples.play(), | |
hide: () => ripples.hide(), | |
show: () => ripples.show(), | |
drop: (x: number, y: number, radius: number, strength: number) => | |
ripples.drop(x, y, radius, strength), | |
}; | |
}; | |
export default useRipples; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Ref } from "vue"; | |
export interface IUniforms { | |
topLeft?: Float32Array; | |
bottomRight?: Float32Array; | |
containerRatio?: Float32Array; | |
} | |
export interface ILocations { | |
perturbance?: WebGLUniformLocation; | |
topLeft?: WebGLUniformLocation; | |
bottomRight?: WebGLUniformLocation; | |
containerRatio?: WebGLUniformLocation; | |
samplerBackground?: WebGLUniformLocation; | |
samplerRipples?: WebGLUniformLocation; | |
delta?: WebGLUniformLocation; | |
center?: WebGLUniformLocation; | |
radius?: WebGLUniformLocation; | |
strength?: WebGLUniformLocation; | |
} | |
export interface IConfig { | |
type: any; | |
arrayType: any; | |
linearSupport: boolean; | |
extensions: string[]; | |
} | |
export interface IOptions { | |
interactive?: boolean; | |
resolution?: number; | |
perturbance?: number; | |
dropRadius?: number; | |
crossOrigin?: string; | |
imageUrl?: string; | |
} | |
export interface IRipples { | |
element: HTMLElement; | |
interactive: boolean; | |
resolution: number; | |
perturbance: number; | |
dropRadius: number; | |
crossOrigin: string; | |
imageUrl: string | null; | |
textureDelta: Float32Array; | |
canvas: HTMLCanvasElement; | |
context: WebGLRenderingContext; | |
textures: WebGLTexture[]; | |
framebuffers: WebGLFramebuffer[]; | |
bufferWriteIndex: number; | |
bufferReadIndex: number; | |
quad: WebGLBuffer; | |
visible: Ref<boolean>; | |
running: Ref<boolean>; | |
inited: Ref<boolean>; | |
destroyed: Ref<boolean>; | |
backgroundWidth?: number; | |
backgroundHeight?: number; | |
originalCssBackgroundImage?: string; | |
imageSource?: string; | |
backgroundTexture?: WebGLTexture; | |
renderProgram?: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
updateProgram?: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
dropProgram?: { | |
id: WebGLProgram; | |
uniforms: IUniforms; | |
locations: ILocations; | |
}; | |
originalInlineCss?: string; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const isPercentage = (str: string) => str[str.length - 1] == "%"; | |
export const getOffset = (el: HTMLElement) => { | |
const box = el.getBoundingClientRect(); | |
return { | |
top: box.top + (window.pageYOffset || window.scrollY) - | |
document.documentElement.clientTop, | |
left: box.left + (window.pageXOffset || window.scrollX) - | |
document.documentElement.clientLeft, | |
}; | |
}; | |
export const createImageData = (width: number, height: number) => { | |
try { | |
return new ImageData(width, height); | |
} catch (e) { | |
// Fallback for IE | |
const canvas = document.createElement("canvas"); | |
return canvas.getContext("2d").createImageData(width, height); | |
} | |
}; | |
export const translateBackgroundPosition = (value: string) => { | |
const parts = value.split(" "); | |
if (parts.length === 1) { | |
switch (value) { | |
case "center": | |
return ["50%", "50%"]; | |
case "top": | |
return ["50%", "0"]; | |
case "bottom": | |
return ["50%", "100%"]; | |
case "left": | |
return ["0", "50%"]; | |
case "right": | |
return ["100%", "50%"]; | |
default: | |
return [value, "50%"]; | |
} | |
} else { | |
return parts.map((part: string) => { | |
switch (value) { | |
case "center": | |
return "50%"; | |
case "top": | |
case "left": | |
return "0"; | |
case "right": | |
case "bottom": | |
return "100%"; | |
default: | |
return part; | |
} | |
}); | |
} | |
}; | |
export const extractUrl = (value: string) => { | |
const urlMatch = /url\(["']?([^"']*)["']?\)/.exec(value); | |
if (urlMatch == null) { | |
return null; | |
} | |
return urlMatch[1]; | |
}; | |
export const isDataUri = (url: string) => url.match(/^data:/); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment