Three.js案例
不靠 CSS!Three.js + Shader 让你图片带呼吸感

不靠 CSS!Three.js + Shader 让你图片带呼吸感

在这篇文章中,我们将从 0 开始,用 Three.js 手动实现一个充满高级感的页面动效组件——圆角图片卡片 + 鼠标视差交互。与常规 CSS 圆角不同,我们通过 Shader 精细裁剪卡片边缘,实现:

  • 圆角遮罩,抗锯齿平滑过渡

  • 多张卡片排布、角度偏移

  • 鼠标驱动视差动效,带有真实“空间感”

场景初始化与纹理加载

我们先构建基本的 Three.js 场景框架,包括尺寸设置、相机初始化和纹理加载。

const settings = {
  sizes: {
    width: window.innerWidth,
    height: window.innerHeight
  },
  boxDimensions: {
    h: 1.4,
    w: 1,
  }
}

我们把尺寸、卡片大小抽出来作为配置,方便后续统一修改或做响应式适配。

接着加载三张图片纹理:

const textureLoader = new THREE.TextureLoader();
const photoTexture02 = textureLoader.load('https://assets.codepen.io/4201020/city2.png');
const photoTexture03 = textureLoader.load('https://assets.codepen.io/4201020/shopp-e-1731593813681771088199459824.png');
const photoTexture = textureLoader.load('https://assets.codepen.io/4201020/shopp-e-1731594468645280783813006762.png');
 
photoTexture.wrapS = THREE.RepeatWrapping;
photoTexture.wrapT = THREE.RepeatWrapping;
photoTexture.repeat.set(0.1, 0.1);

注意:最后一张图片我们设置了 wraprepeat,这是为了让贴图在 uv 坐标上做一定的缩放,避免失真或显示不完整。

创建基本场景元素

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, settings.sizes.width / settings.sizes.height, 0.1, 1000);
camera.position.set(0, 0, 3);
camera.lookAt(0, 0, 0);
scene.add(camera);
 
const sun = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(sun);

我们使用透视相机并放置在 Z 轴位置为 3 的地方,视角能完整看到正前方的卡片排列。同时添加环境光 AmbientLight,尽管我们用的是 matcap 材质,但光源仍然可以保持材质亮度稳定。

构建自定义 Shader 材质

这是本项目的核心亮点。

function RoundedPortalPhotoPlane(geometry, photoTexture) {
  const material = new THREE.MeshMatcapMaterial({
    matcap: photoTexture,
    transparent: true,
  });
 
  material.onBeforeCompile = (shader) => {
    ...
  };
 
  return new THREE.Mesh(geometry, material);
}

我们在材质编译前使用 onBeforeCompile 注入自定义 shader 逻辑。因为 MeshMatcapMaterial 不能直接修改 shader 源码,但我们可以在编译前“篡改”源码片段,加入我们自己的变量、函数和逻辑判断。

注入 vertex shader

varying vec4 vPosition;
varying vec2 vUv;

我们将 vUv(每个像素点的 UV 坐标)传递到 fragment shader 中,用于后续的遮罩计算。

注入 fragment shader

我们加入一段 SDF(Signed Distance Function)函数:

float roundedBoxSDF(vec2 CenterPosition, vec2 Size, float Radius) {
  return length(max(abs(CenterPosition)-Size+Radius,0.0))-Radius;
}

这段代码的作用是计算一个点是否处于“圆角矩形”内部,并返回其距离边缘的距离。

结合 soft edge 逻辑:

float smoothedAlpha =  1.0 - smoothstep(0.0, edgeSoftness * 2.0, distance);
gl_FragColor = vec4(outgoingLight, smoothedAlpha);

我们用 smoothstep 平滑过渡边缘距离,得到了柔和抗锯齿的圆角遮罩。

这一整套逻辑不依赖 DOM/CSS,完全由 GPU 计算生成,适用于任何平台。

构建多张卡片并分组管理

const planeGroup = new THREE.Group();
 
const photoPlane01 = new RoundedPortalPhotoPlane(planeGeometry, photoTexture02);
photoPlane01.position.set(-1, 0, 1);
photoPlane01.rotation.y = Math.PI * 0.1;
 
const photoPlane02 = new RoundedPortalPhotoPlane(planeGeometry, photoTexture);
photoPlane02.position.set(0, 0, 0.5);
 
const photoPlane03 = new RoundedPortalPhotoPlane(planeGeometry, photoTexture03);
photoPlane03.position.set(1, 0, 1);
photoPlane03.rotation.y = -Math.PI * 0.1;
 
planeGroup.add(photoPlane01, photoPlane02, photoPlane03);
scene.add(planeGroup);

我们统一使用 PlaneGeometry,创建三张卡片:

  • 每张卡片稍微有角度倾斜(±0.1 弧度)

  • 它们被加入一个 Group 中,便于统一旋转和控制

这也为视差交互打下基础。

鼠标驱动的视差动效

window.addEventListener('mousemove', (event) => {
  mouse.x = event.clientX / settings.sizes.width * 2 - 1;
  mouse.y = - (event.clientY / settings.sizes.height) * 2 + 1;
});

我们将鼠标位置转换为标准化设备坐标(-1 到 1 的范围),并在动画中做插值:

const parallaxX = mouse.x * -0.3;
const parallaxY = mouse.y * 0.3;
 
planeGroup.rotation.y += (parallaxX - planeGroup.rotation.y) * 3 * deltaTime;
planeGroup.rotation.x += (parallaxY - planeGroup.rotation.x) * 3 * deltaTime;

这里使用了经典的“缓动插值”,让视差动效更具流动感,不会突兀跳动。

渲染循环与总结

renderer.setAnimationLoop(animation);
 
function animation(time) {
  const elapsedTime = clock.getElapsedTime();
  const deltaTime = elapsedTime - previousTime;
  previousTime = elapsedTime;
 
  // parallax 更新...
 
  renderer.render(scene, camera);
}

最终,我们构建了一个三张圆角卡片组成的 3D 群组,具有实时视差交互、抗锯齿遮罩和极佳的 GPU 性能表现。

代码

https://codepen.io/smcnally000/pen/eYqXWyJ (opens in a new tab)