Three.js 鼠标 Instancing 交互特效

Three.js 鼠标 Instancing 交互特效


基于 GPU Instancing 的鼠标轨迹交互特效,纯前端单文件

基于 Three.js Instancing 技术实现的鼠标交互特效,55×55 圆角方块等角网格。

技术栈: Three.js r160 · GSAP · WebGL

特性:

  • GPU Instancing 单次 drawcall 渲染 3025 个实例
  • 自定义 vertex shader,鼠标轨迹压陷效果
  • 双速度追踪模拟弹簧感
  • 入场动画按距中心依次展开
  • 纯前端单文件,无需构建工具

完整源码

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mouse Instancing</title>
<style>
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#canvas { width: 100%; height: 100%; display: block; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';

// ── Renderer ───────────────────────────────────────────
const canvas = document.querySelector('#canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, stencil: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.setSize(canvas.offsetWidth, canvas.offsetHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

// ── Scene ──────────────────────────────────────────────
const scene = new THREE.Scene();

// ── Orthographic camera (isometric 45°) ───────────────
const SIZE_CAM = 4;
function updateCamera(cam) {
  const w = canvas.offsetWidth, h = canvas.offsetHeight;
  let ratio = w / h, ratioW = h / w;
  if (ratio > ratioW) ratioW = 1; else ratio = 1;
  cam.left   = -SIZE_CAM * ratio;
  cam.right  =  SIZE_CAM * ratio;
  cam.top    =  SIZE_CAM * ratioW;
  cam.bottom = -SIZE_CAM * ratioW;
  cam.updateProjectionMatrix();
}
const camera = new THREE.OrthographicCamera();
updateCamera(camera);
camera.position.set(40, 40, 40);
camera.lookAt(new THREE.Vector3(0, 0, 0));

// ── Lights (matching original) ─────────────────────────
scene.add(new THREE.HemisphereLight(0x9f9f9f, 0xffffff, 1));
scene.add(new THREE.AmbientLight(0xffffff, 1));

const d2 = new THREE.DirectionalLight(0x909090, 1);
d2.position.set(-1, 0.5, 1).multiplyScalar(10);
scene.add(d2);

const d1 = new THREE.DirectionalLight(0xffffff, 4);
d1.position.set(1, 0.5, 1).multiplyScalar(10);
d1.castShadow = true;
d1.shadow.camera.left = d1.shadow.camera.bottom = -10;
d1.shadow.camera.right = d1.shadow.camera.top = 10;
d1.shadow.camera.far = 40;
d1.shadow.mapSize.set(2048, 2048);
scene.add(d1);

// ── Grid config (matching original exactly) ────────────
const GRID  = 55;
const GSIZE = 0.5;
const GHALF = GRID * GSIZE;

const opts = {
  speed: 1, frequency: 1, mouseSize: 1, rotationSpeed: 1,
  color: '#1084ff', colorDegrade: 1.5,
  rotationAmmount: 0, mouseScaling: 0, mouseIndent: 1,
};

// ── Instanced mesh ─────────────────────────────────────
const geometry = new RoundedBoxGeometry(GSIZE, GSIZE, GSIZE, 4, 0.1);
const material = new THREE.MeshPhysicalMaterial({ color: opts.color, metalness: 0, roughness: 0 });
const mesh = new THREE.InstancedMesh(geometry, material, GRID * GRID);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);

// ── Instance positions + color degradation ─────────────
const col = material.color;
const total = col.r + col.g + col.b;
const w = new THREE.Vector3(col.r, col.g, col.b)
  .divideScalar(total).multiplyScalar(-0.5).addScalar(1.0);
const dummy    = new THREE.Object3D();
const colorVec = new THREE.Color();
let idx = 0;
for (let x = 0; x < GRID; x++) {
  for (let y = 0; y < GRID; y++) {
    const px = x * GSIZE - GHALF / 2 + GSIZE / 2;
    const pz = y * GSIZE - GHALF / 2 + GSIZE / 2;
    dummy.position.set(px, 0, pz);
    dummy.updateMatrix();
    mesh.setMatrixAt(idx, dummy.matrix);

    const center = 1 - dummy.position.length() * 0.12 * opts.colorDegrade;
    colorVec.setRGB(
      center * w.x + (1 - w.x),
      center * w.y + (1 - w.y),
      center * w.z + (1 - w.z)
    );
    mesh.setColorAt(idx, colorVec);
    idx++;
  }
}

// ── Shader uniforms ────────────────────────────────────
const uTime    = { value: 0 };
const uniforms = {
  uTime,
  uPos0:    { value: new THREE.Vector2() },
  uPos1:    { value: new THREE.Vector2() },
  uAnimate: { value: 0 },
  uConfig:  { value: new THREE.Vector4(opts.speed, opts.frequency, opts.mouseSize, opts.rotationSpeed) },
  uConfig2: { value: new THREE.Vector4(opts.rotationAmmount, opts.mouseScaling, opts.mouseIndent) },
};

// ── Vertex shader (exact replication of original) ──────
const vertexHead = `
  uniform float uTime;
  uniform float uAnimate;
  uniform vec2 uPos0;
  uniform vec2 uPos1;
  uniform vec4 uConfig;   // speed, frequency, mouseSize, rotationSpeed
  uniform vec4 uConfig2;  // rotationAmmount, mouseScaling, mouseIndent

  float mapRange(float v, float a, float b, float c, float d) {
    return c + (v - a) * (d - c) / (b - a);
  }

  mat4 rotationMatrix(vec3 axis, float angle) {
    axis = normalize(axis);
    float s = sin(angle), c = cos(angle), oc = 1.0 - c;
    return mat4(
      oc*axis.x*axis.x + c,           oc*axis.x*axis.y - axis.z*s,  oc*axis.z*axis.x + axis.y*s,  0.0,
      oc*axis.x*axis.y + axis.z*s,    oc*axis.y*axis.y + c,          oc*axis.y*axis.z - axis.x*s,  0.0,
      oc*axis.z*axis.x - axis.y*s,    oc*axis.y*axis.z + axis.x*s,  oc*axis.z*axis.z + c,          0.0,
      0.0, 0.0, 0.0, 1.0
    );
  }

  vec3 rotate(vec3 v, vec3 axis, float angle) {
    return (rotationMatrix(axis, angle) * vec4(v, 1.0)).xyz;
  }

  float sdSegment(vec2 p, vec2 a, vec2 b) {
    vec2 pa = p - a, ba = b - a;
    return length(pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0));
  }

  float cubicOut(float t) {
    float f = t - 1.0;
    return f * f * f + 1.0;
  }

  float cubicInOut(float t) {
    return t < 0.5 ? 4.0*t*t*t : 0.5*pow(2.0*t - 2.0, 3.0) + 1.0;
  }

  void main() {
`;

const projectVertex = `
  vec4 ipos     = instanceMatrix[3];
  float toCenter = length(ipos.xz);

  float mouseTrail = sdSegment(ipos.xz, uPos0, uPos1);
  mouseTrail = smoothstep(2.0, 5.0 * uConfig.z, mouseTrail);

  // Mouse scale
  transformed *= 1.0 + cubicOut(1.0 - mouseTrail) * uConfig2.y;

  // Entry animation timing per-instance
  float start = 0.0 + toCenter * 0.02;
  float end   = start + (toCenter + 1.5) * 0.06;
  float anim  = mapRange(clamp(uAnimate, start, end), start, end, 0.0, 1.0);

  // Rotation (zero by default in the live demo)
  transformed = rotate(transformed, vec3(0.0, 1.0, 1.0),
    uConfig2.x * (anim * 3.14159 + uTime * uConfig.x + toCenter * 0.4 * uConfig.w));

  // Mouse indent — push cubes down near cursor
  transformed.y += (-1.0 * (1.0 - mouseTrail)) * uConfig2.z;

  // Entry: scale up + fall from 1 unit above
  transformed.xyz *= cubicInOut(anim);
  transformed.y   += cubicInOut(1.0 - anim) * 1.0;

  // Ambient wave
  transformed.y += sin(uTime * 2.0 * uConfig.x + toCenter * uConfig.y) * 0.1;

  // Project (replaces #include <project_vertex>)
  vec4 mvPosition = vec4(transformed, 1.0);
  #ifdef USE_INSTANCING
    mvPosition = instanceMatrix * mvPosition;
  #endif
  mvPosition = modelViewMatrix * mvPosition;
  gl_Position = projectionMatrix * mvPosition;
`;

function patchShader(shader) {
  shader.vertexShader = shader.vertexShader.replace('void main() {', vertexHead);
  shader.vertexShader = shader.vertexShader.replace('#include <project_vertex>', projectVertex);
  shader.uniforms = { ...shader.uniforms, ...uniforms };
}

material.onBeforeCompile = patchShader;

mesh.customDepthMaterial = new THREE.MeshDepthMaterial();
mesh.customDepthMaterial.depthPacking = THREE.RGBADepthPacking;
mesh.customDepthMaterial.onBeforeCompile = patchShader;

// ── Entry animation via GSAP ───────────────────────────
gsap.to(uniforms.uAnimate, { value: 1, duration: 3.0, ease: 'none' });

// ── Mouse — raycasting onto XZ hit plane ───────────────
const hitPlane = new THREE.Mesh(
  new THREE.PlaneGeometry(1, 1),
  new THREE.MeshBasicMaterial()
);
hitPlane.scale.setScalar(50);
hitPlane.rotation.x = -Math.PI / 2;
hitPlane.updateMatrix();
hitPlane.updateMatrixWorld();

const raycaster = new THREE.Raycaster();
const ndc       = new THREE.Vector2();
const mousePos  = new THREE.Vector2(-100, -100);
const vel       = new THREE.Vector2();
const tmp       = new THREE.Vector2();

// 初始位置移出网格,避免中心出现凹陷
uniforms.uPos0.value.set(-100, -100);
uniforms.uPos1.value.set(-100, -100);

window.addEventListener('mousemove', (e) => {
  ndc.x = (e.clientX / innerWidth) * 2 - 1;
  ndc.y = -(e.clientY / innerHeight) * 2 + 1;
  raycaster.setFromCamera(ndc, camera);
  const hits = raycaster.intersectObject(hitPlane);
  if (hits.length > 0) {
    mousePos.x = hits[0].point.x;
    mousePos.y = hits[0].point.z; // XZ world → vec2
  }
});

// ── Render loop ────────────────────────────────────────
const clock = new THREE.Clock();

(function animate() {
  requestAnimationFrame(animate);
  uTime.value = clock.getElapsedTime();

  // Fast follower: uPos0 lerps toward mouse
  tmp.copy(mousePos).sub(uniforms.uPos0.value).multiplyScalar(0.08);
  uniforms.uPos0.value.add(tmp);

  // Slow follower: uPos1 follows uPos0 with velocity damping
  tmp.copy(uniforms.uPos0.value).sub(uniforms.uPos1.value).multiplyScalar(0.05)
     .sub(vel).multiplyScalar(0.05);
  vel.add(tmp);
  uniforms.uPos1.value.add(vel);

  renderer.render(scene, camera);
})();

// ── Resize ─────────────────────────────────────────────
window.addEventListener('resize', () => {
  renderer.setSize(canvas.offsetWidth, canvas.offsetHeight, false);
  updateCamera(camera);
});
</script>
</body>
</html>

3300举报0Xiao.Xi12天前
点击获取 ^_^
被收录:

暂无评论