Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/hetari/creative-coding/llms.txt

Use this file to discover all available pages before exploring further.

Three fullscreen images are layered behind three invisible hover zones that span the viewport. When the cursor enters a zone, a GPU-accelerated transition fires: a custom PixiJS Filter backed by a GLSL fragment shader slices the canvas into a rotating grid, displaces each slice’s UV coordinates in opposite directions for the outgoing and incoming images, then smoothly mix()-blends between the two textures as GSAP advances the uProgress uniform from 0 to 1. Mid-transition direction reversals are handled cleanly by swapping textures and mirroring progress, so hovering rapidly between sections never causes a visual snap.

Registry Entry

This sketch is registered in src/config/sketches.ts as:
{
  id: 'images-swiper',
  title: 'Swiper',
  category: 'images',
  path: '/images/swiper',
  order: 10,
  previewMode: 'iframe',
  resources: ['https://www.youtube.com/@codingmath/videos'],
}

Tech Stack

LayerTool
RendererusePixiApp composable, PixiJS v8
ShaderCustom Filter with GlProgram, UniformGroup
AssetsPixiJS Assets.addBundle / Assets.loadBundle
AnimationGSAP gsap.to() on the UniformGroup uniforms
Reactivity / eventsVueUse useWindowSize, useEventListener, useTemplateRefsList
GUILilGui component (transition duration slider)
Performance overlayStatsPanel component
External linksResourcesList component

Shader Uniforms

The GLSL filter receives the following uniforms, set through PixiJS UniformGroup and direct texture binding:
UniformTypeDescription
uTextureOnesampler2DThe current (outgoing) image texture
uTextureTowsampler2DThe target (incoming) image texture. Note: the name Tow matches the JavaScript property intentionally
uvAspectvec2UV scale correction vector for cover-fit cropping. Recomputed whenever the window resizes
uProgressfloatTransition progress from 0.0 (fully showing uTextureOne) to 1.0 (fully showing uTextureTow), animated by GSAP
uGridDivsfloatNumber of vertical grid slices. 10 on mobile / 25 on tablet / 50 on desktop
uAnglefloatRotation angle applied to the displacement vector. π/10 on mobile / π/6 on tablet / π/4 on desktop

Responsive Layout Adaptation

updatePixiJSLayout() runs on mount and whenever the window size changes. It resizes the renderer, recalculates cover-fit UV correction, and updates the grid uniforms based on breakpoints:
function updatePixiJSLayout() {
  if (!app.value || !sprite || !customFilter)
    return

  const width = windowWidth.value
  const height = windowHeight.value

  // 1. Resize the PixiJS renderer to match current window size.
  app.value.renderer.resize(width, height)

  // 2. Scale the sprite to cover the entire canvas/renderer.
  sprite.width = width
  sprite.height = height

  // 3. Recalculate aspect ratio correction (cover behavior).
  const windowAspect = width / height
  const targetUniforms = customFilter.resources.aspectUniforms.uniforms

  // Window is wider than the image: scale down Y to crop top/bottom.
  if (windowAspect > imageAspect) {
    targetUniforms.uvAspect = { x: 1.0, y: imageAspect / windowAspect }
  }
  // Window is taller than the image: scale down X to crop left/right.
  else {
    targetUniforms.uvAspect = { x: windowAspect / imageAspect, y: 1.0 }
  }

  // 4. Calculate dynamic grid divisions and angle based on window width.
  let gridDivs = 50.0
  let angle = Math.PI / 4.0

  if (width < 640) {
    // Small screens (phone) -> 10 slices, smaller angle (Math.PI / 10)
    gridDivs = 10.0
    angle = Math.PI / 10.0
  }
  else if (width < 1024) {
    // Medium screens (tablet) -> 25 slices, medium angle (Math.PI / 6)
    gridDivs = 25.0
    angle = Math.PI / 6.0
  }

  targetUniforms.uGridDivs = gridDivs
  targetUniforms.uAngle = angle
}

Asset Loading

Images are registered as a named PixiJS bundle on first run. The guard Assets.resolver.hasBundle('gallery') prevents double-registration if the component hot-reloads:
if (!Assets.resolver.hasBundle('gallery')) {
  Assets.addBundle('gallery', {
    img1: '/images/1.webp',
    img2: '/images/2.webp',
    img3: '/images/3.webp',
  })
}
const assets = await Assets.loadBundle('gallery')
After loading, imageAspect is derived from the first image’s natural dimensions to drive all cover-fit calculations.

Transition Logic

Each hover section maps to an image by index. When a new section is entered:
  1. If uProgress >= 0.5, the transition is more than halfway through — the textures are swapped and progress is mirrored (1 - currentProgress) so the incoming image becomes the new base, avoiding a backwards flash.
  2. The target texture is updated to the hovered section’s image.
  3. Any in-flight GSAP tween is killed with gsap.killTweensOf(targetUniforms).
  4. A new tween animates uProgress → 1 with power3.out easing.
secRefs.value.forEach((sec, index) => {
  useEventListener(sec, 'mouseenter', () => {
    if (activeIndex === index)
      return

    activeIndex = index

    if (customFilter) {
      const currentProgress = targetUniforms.uProgress

      if (currentProgress >= 0.5) {
        customFilter.resources.uTextureOne = customFilter.resources.uTextureTow
        targetUniforms.uProgress = 1 - currentProgress
      }
      customFilter.resources.uTextureTow = assets[`img${index + 1}`].source

      gsap.killTweensOf(targetUniforms)
      gsap.to(targetUniforms, {
        uProgress: 1,
        duration: duration.value,
        ease: 'power3.out',
      })
    }
  })
})

Fragment Shader — Grid Displacement

The full swiper.frag shader implements the visual transition in six steps:
precision highp float;

in vec2 vTextureCoord;

uniform mat3 mappedMatrix;
uniform sampler2D uTextureOne;
uniform sampler2D uTextureTow;

uniform vec2 uvAspect;
uniform float uProgress;
uniform float uGridDivs;
uniform float uAngle;

mat2 rotate(float a) {
  float s = sin(a);
  float c = cos(a);
  return mat2(c, -s, s, c);
}

void main() {
  // 1. Homogeneous coordinate mapping
  vec3 map = vec3(vTextureCoord.xy, 1.0);

  // 2. Aspect ratio correction (centered cover crop)
  vec2 uv = (map.xy - 0.5) * uvAspect + 0.5;

  // 3. UV division — creates uGridDivs repeating [0,1] gradients horizontally
  vec2 uvDivided = fract(uv * vec2(uGridDivs, 1.0));

  // 4. Progress clamp
  float progress = clamp(uProgress, 0.0, 1.0);

  // 5. Rotated displacement — outgoing image displaced by progress,
  //    incoming image displaced by (1 - progress)
  vec2 uvDisplaced1 = uv + rotate(uAngle) * uvDivided * progress * 0.1;
  vec2 uvDisplaced2 = uv + rotate(uAngle) * uvDivided * (1.0 - progress) * 0.1;

  // 6. Sample and blend
  vec4 img1 = texture2D(uTextureOne, uvDisplaced1);
  vec4 img2 = texture2D(uTextureTow, uvDisplaced2);
  gl_FragColor = mix(img1, img2, progress);
}
The key insight is that the grid slices — created by fract(uv * uGridDivs) — give each vertical column its own local displacement vector. As uProgress rises, img1’s columns shear away while img2’s columns converge into place, rotated diagonally by uAngle.

Vertex Shader

The swiper.vert shader maps PixiJS aPosition (range [0, 1]) to WebGL clip space ([-1, 1]) and flips the Y axis to match Pixi’s top-down coordinate system:
in vec2 aPosition;
out vec2 vTextureCoord;

void main(void) {
  gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0);
  gl_Position.y = -gl_Position.y;
  vTextureCoord = aPosition;
}
The GLSL shader files (swiper.frag / swiper.vert) are imported as raw strings using Vite’s ?raw suffix and passed directly to GlProgram.from({ vertex, fragment }). This avoids any shader compilation step at build time — the strings are compiled by the GPU driver at runtime when PixiJS first creates the filter.

Build docs developers (and LLMs) love