
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>
被收录:
暂无评论
