← Tất cả bài viết

[ Blog ]

27 tháng 6, 2026

9 phút đọc

Hiệu Ứng Displacement Ảnh WebGL Khi Hover (GLSL)

Tự dựng hiệu ứng displacement ảnh WebGL khi hover bằng shader GLSL trong React Three Fiber — uniform, lerp, tối ưu hiệu năng và fallback prefers-reduced-motion.

WebGLGLSLReact Three Fiberthree.jsShadersPerformance
Hiệu Ứng Displacement Ảnh WebGL Khi Hover (GLSL)

Hiệu ứng ảnh "tan chảy", lỏng như thủy tinh mà bạn thấy trên các website đoạt giải gần như luôn dùng chung một thủ thuật: một hiệu ứng displacement ảnh WebGL điều khiển bởi đúng một shader GLSL. Bạn đưa cho GPU hai texture — tấm ảnh và một "displacement map" thang xám — rồi đẩy các pixel đi dựa trên độ sáng của map, animate đúng một uniform progress từ 0 đến 1 khi hover. Đó là toàn bộ ảo giác. Không video, không sprite sheet, không export 200 frame.

Tôi đã đưa hiệu ứng này lên các site production từng được Awwwards ghi nhận, và khoảng cách giữa "trông rẻ tiền" và "trông cao cấp" nằm hoàn toàn ở chi tiết: cách lerp, phép tính tỉ lệ khung hình, và tôn trọng người dùng không muốn chuyển động. Bài này đi qua phần triển khai thật trong React Three Fiber, với code copy là chạy.

Displacement shader thực ra làm gì

Một fragment shader chạy một lần cho mỗi pixel. Với mỗi pixel nó hỏi: nên lấy texel nào của ảnh gốc? Bình thường bạn sample thẳng tọa độ UV (texture2D(uImage, vUv)). Hiệu ứng displacement sẽ dịch tọa độ UV đó đi một lượng đọc từ texture thứ hai trước khi sample:

float strength = texture2D(uDisp, vUv).r; // 0..1 từ map thang xám
vec2 distortedUv = vUv + strength * uIntensity;
vec4 color = texture2D(uImage, distortedUv);

Chỗ nào của displacement map màu trắng thì pixel bị kéo đi xa; chỗ nào đen thì giữ nguyên. Animate uIntensity về 0 khi ảnh ổn định và bức hình "lắp ráp lại" từ hỗn loạn. Đổi map (gợn mực, perlin noise, nét cọ, chất lỏng) là bạn có một chữ ký hoàn toàn khác với cùng ~30 dòng code.

Displacement map chính là phần art direction. Gradient hướng tâm cho hiệu ứng warp-zoom sạch sẽ; texture noise nhòe cho cảm giác mực loang mà đa số người nhận ra ngay.

Fragment shader GLSL

Đây là một fragment shader tối giản nhưng đúng hình hài production, cross-fade giữa hai ảnh và bẻ cong chúng theo hai hướng ngược nhau — đúng kiểu hover transition kinh điển nơi một ảnh tan vào ảnh kế tiếp:

precision highp float;

uniform sampler2D uTexture1;
uniform sampler2D uTexture2;
uniform sampler2D uDisp;
uniform float uProgress;   // 0 = ảnh 1, 1 = ảnh 2
uniform float uIntensity;  // ~0.3 là vừa đẹp
varying vec2 vUv;

void main() {
  float disp = texture2D(uDisp, vUv).r;

  // Đẩy ảnh đang biến mất một hướng, ảnh đang hiện ra hướng ngược lại.
  vec2 uv1 = vUv + uProgress * disp * uIntensity * vec2(1.0, 0.0);
  vec2 uv2 = vUv - (1.0 - uProgress) * disp * uIntensity * vec2(1.0, 0.0);

  vec4 c1 = texture2D(uTexture1, uv1);
  vec4 c2 = texture2D(uTexture2, uv2);

  gl_FragColor = mix(c1, c2, uProgress);
}

Vertex shader thì cực đơn giản — chỉ truyền UV qua:

varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Hai cái bẫy ai cũng dính:

  • Tỉ lệ khung hình. UV của plane là 0–1, nhưng ảnh của bạn hiếm khi vuông. Sample UV thô sẽ làm méo ảnh. Hoặc bạn fit kiểu "cover" trong shader bằng tỉ lệ uResolution/uImageResolution, hoặc đặt kích thước plane theo đúng tỉ lệ ảnh để UV 1:1 map chuẩn luôn. Cách chỉnh kích thước plane đơn giản hơn và là cái tôi chọn trước.
  • Wrapping texture. Khi distortedUv vượt khỏi 0–1, GPU mặc định sẽ wrap và bạn bị vệt nhòe ở mép. Đặt texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping (hoặc giữ intensity đủ nhỏ để UV không tràn).

Ráp vào React Three Fiber

shaderMaterial của drei là cách gọn nhất để khai báo uniform một lần và có một material thân thiện với JSX, có type. Signature của nó là shaderMaterial(uniforms, vertexShader, fragmentShader), và mỗi uniform trở thành một property set được trên instance — quá hợp để animate từ useFrame.

import * as THREE from "three";
import { useRef } from "react";
import { Canvas, useFrame, extend } from "@react-three/fiber";
import { shaderMaterial, useTexture } from "@react-three/drei";

const DisplaceMaterial = shaderMaterial(
  {
    uTexture1: null,
    uTexture2: null,
    uDisp: null,
    uProgress: 0,
    uIntensity: 0.3,
  },
  vertexShader,   // các chuỗi shader ở trên
  fragmentShader,
);

extend({ DisplaceMaterial });

function ImagePlane({ hovered }: { hovered: boolean }) {
  const ref = useRef<any>(null);
  const [t1, t2, disp] = useTexture([
    "/img/a.jpg",
    "/img/b.jpg",
    "/img/displacement.jpg",
  ]);

  useFrame((_, delta) => {
    if (!ref.current) return;
    const target = hovered ? 1 : 0;
    // lerp độc lập với frame-rate, tiến về target
    ref.current.uProgress = THREE.MathUtils.damp(
      ref.current.uProgress,
      target,
      6,        // độ mượt — càng cao càng dứt khoát
      delta,
    );
  });

  return (
    <mesh>
      <planeGeometry args={[1.6, 1, 1, 1]} />
      <displaceMaterial
        ref={ref}
        uTexture1={t1}
        uTexture2={t2}
        uDisp={disp}
      />
    </mesh>
  );
}

Bản thân trạng thái hover lấy từ pointer event của R3F (onPointerOver/onPointerOut trên mesh) hoặc một div DOM bọc quanh <Canvas>. Tôi thích cách bọc bằng DOM hơn: hit-test một div full-bleed rẻ và đáng tin hơn raycast một plane đơn lẻ.

Lerp mới là linh hồn của hiệu ứng

Người mới thường set thẳng uProgress = 1 khi hover rồi thắc mắc sao nó giống slideshow. Phép màu nằm ở làm mượt uniform, không phải nhảy cóc. Hai lựa chọn chắc tay:

  1. THREE.MathUtils.damp (như trên) — độc lập frame-rate, không cần thư viện, ease tự nhiên. Mặc định của tôi.
  2. GSAPgsap.to(material.uniforms.uProgress, { value: 1, duration: 0.8, ease: "power3.out" }). Dùng khi bạn cần một ease có tên cụ thể hoặc muốn nối displacement vào một timeline lớn hơn. Nếu đang phân vân chọn thư viện animation, xem GSAP hay Framer Motion.

Tránh kiểu value += 0.05 mỗi frame: nó chạy nhanh chậm khác nhau trên màn 60Hz so với 120Hz. Luôn tính tới delta hoặc dùng damp/GSAP vốn đã lo việc đó.

Ghi chú hiệu năng từ thực chiến

Hiệu ứng này rẻ với GPU nhưng rất dễ làm nặng. Những thứ thực sự quan trọng:

Quyết địnhRẻ & sắc nétĐắt / giật
Kích thước textureGấp ~2× px hiển thị, đã nénJPEG 4K nguyên bản
Định dạng textureKTX2 / Basis (nén theo GPU)PNG/JPEG giải mã nằm trong VRAM
dprGiới hạn: dpr={[1, 2]}Không chặn trên điện thoại 3×
Vòng lặp renderframeloop="demand" + invalidate, hoặc dừng khi ngoài màn hìnhLuôn render mọi plane
Quản lý màutexture.colorSpace = THREE.SRGBColorSpaceBạc màu / sai gamma

Ba nguyên tắc tôi không bao giờ phá:

  • Set colorSpace cho mọi texture ảnh nếu không ảnh sẽ bị đục. R3F/drei giờ phần lớn tự lo, nhưng hãy kiểm lại.
  • Giới hạn dpr — chặn [1, 2] trên <Canvas> là cú tối ưu lớn nhất trên điện thoại. Render hiệu ứng hover ở mật độ 3× chẳng đẹp thêm chút nào.
  • Đừng render plane không nhìn thấy. Dùng IntersectionObserver để chỉ mount/animate ảnh đang trên màn hình, và dừng vòng lặp khi rời hover một khi uProgress đã ổn định. Cùng kỷ luật giữ cuộn mượt với GSAP & Lenis ở 60fps áp dụng được ở đây.

Trên case study website sự kiện Iventions, các displacement transition chạy song song với một timeline GSAP đầy đủ và một scene Three.js, và việc giữ trong ngân sách frame quy về đúng những lựa chọn này — texture nén và vòng lặp theo nhu cầu, không phải GPU mạnh hơn.

Fallback prefers-reduced-motion (không thỏa hiệp)

Một cú warp displacement là chuyển động tiền đình. Người dùng đặt prefers-reduced-motion: reduce nên nhận một cross-fade sạch sẽ — hoặc không transition gì cả — thay vì một bức ảnh đang cuộn xoáy. Đây vừa là yêu cầu về khả năng tiếp cận, vừa ngày càng là tín hiệu chất lượng liên quan tới thứ hạng.

import { useReducedMotion } from "@react-three/drei"; // hoặc một hook tự viết nhỏ gọn

function ImagePlane({ hovered }: { hovered: boolean }) {
  const reduce = useReducedMotion();

  useFrame((_, delta) => {
    if (!ref.current) return;
    const target = hovered ? 1 : 0;
    if (reduce) {
      // Đưa intensity về 0 → nó thành cross-fade opacity thuần.
      ref.current.uIntensity = 0;
      ref.current.uProgress = target;
    } else {
      ref.current.uIntensity = 0.3;
      ref.current.uProgress = THREE.MathUtils.damp(
        ref.current.uProgress, target, 6, delta,
      );
    }
  });
}

Nếu không muốn dùng hook của R3F, đọc thẳng media query:

const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

Đặt uIntensity = 0 giữ nguyên đúng đường shader cũ — mix() vẫn cross-fade — nên bạn duy trì một code path duy nhất thay vì rẽ nhánh sang một fallback DOM riêng.

FAQ

Có bắt buộc dùng React Three Fiber không, hay làm được bằng three.js / WebGL thuần?

Bạn làm được bằng three.js thuần hoặc thậm chí WebGL viết tay — shader y hệt. R3F chỉ bỏ đi phần boilerplate dựng scene/camera/render-loop và cho bạn useFrame + shaderMaterial để cập nhật uniform gọn gàng. Với một ảnh hero đơn lẻ thì three.js thuần ổn; với một gallery nhiều plane tương tác, mô hình component của R3F nhanh chóng có lợi.

Lấy displacement map ở đâu?

Ảnh thang xám nào cũng dùng được: gradient hướng tâm (Photoshop/Figma), render mây/noise, ảnh mực loang trong nước đã khử màu, hoặc một pattern perlin/simplex sinh thẳng trong shader. Đen = không chuyển động, trắng = chuyển động tối đa. Tính cách của map chính là tính cách transition của bạn — đáng để art-direct chứ đừng vớ đại cái đầu tiên tìm thấy.

Sao ảnh của tôi bị méo hoặc bạc màu?

Méo = lệch tỉ lệ giữa UV 0–1 của plane và ảnh không vuông; sửa bằng cách đặt plane theo tỉ lệ ảnh hoặc chỉnh UV trong shader. Bạc màu = thiếu sRGB color space; đặt texture.colorSpace = THREE.SRGBColorSpace.

Hiệu ứng displacement có hại cho hiệu năng hay SEO không?

Không, nếu bạn nén texture (KTX2), giới hạn dpr, và chỉ render plane đang hiện/đang animate. Bản thân tấm ảnh vẫn nên xuất ra dưới dạng <img> thật cho SEO/LCP, với canvas WebGL phủ lên trên một cách tiệm tiến — để bot và người dùng không có JS vẫn thấy ảnh, còn hiệu ứng chỉ là phần nâng cấp.


Cùng lõi displacement này mở rộng được từ một thumbnail hover đơn lẻ tới transition ảnh WebGL toàn trang khi chuyển route. Nếu bạn muốn hiệu ứng chữ ký này được dựng vào site của mình — mượt, dễ tiếp cận và đạt chuẩn Awwwards — xem cách tôi làm việc với khách hàng và studio.