# 3D 波浪 3D Waves > 深蓝低多边形波浪背景,干净稳重;光在波面上流动,鼠标让镜头跟着轻轻移动。效果来自 Vanta.js 的 WAVES(MIT),改写成了不用装库的版本。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:企业官网、软件、SaaS 的首屏,要干净、稳重、不出错的动态背景。 - 不适合:温暖、手作、本地小店风格;要很强个性的作品集。 - 适合的网站:官网、SaaS、活动页 - 放在:首屏、背景 - 手机上:好用:网格很轻,手机上也流畅。 - 性能代价:低:一张 1.6 万个三角形的网格,波形在 GPU 上算,几乎不占资源。 - 常搭配:[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md)、[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md)、[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `waves-3d.tsx`(比如 `src/components/waves-3d.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/waves-3d.tsx),比手抄准:`curl -fsSL -o src/components/waves-3d.tsx https://www.dachuiai.com/code/waves-3d.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountWaves3D`、`Waves3D` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。颜色、光泽度、波高、波速、缩放通过 options 传(和 Vanta 的参数同名)。保留文件开头 Vanta.js(Teng Bao)的 MIT 许可声明。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountWaves3D(元素) 不依赖 React,删掉类型标注存成 .js 就能调用,返回的函数用来清理;那个元素要有大小(比如 position:absolute; inset:0)。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountWaves3D** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | | `options` | `{}` | 颜色、光泽度、波高、波速、缩放,不传就是视频里的样子 | **Waves3D** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | | `options` | | 颜色、光泽度、波高、波速、缩放,不传就是视频里的样子 | ## 你可以说 > 用开源库 Vanta.js(MIT,配 three.js r134)的 WAVES 效果做一个全屏背景:颜色 0x0a1c2c,光泽度 70,波高 22,波速 0.55,缩放 0.72;打开鼠标跟随,让波面随鼠标轻轻倾斜;整体深蓝灰,高光柔和。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * Waves3D 3D 波浪 * 一整片起伏的面,光在上面流动,看着很干净。效果来自开源库 Vanta.js 的 WAVES(作者 Teng Bao,MIT), * 这里把它改写成不依赖库的 WebGL2:同一张 101×81 的网格、同样的波形公式、同样的平面着色(flat shading) * 和 three.js r134 里 Phong 材质的打光(环境光 0.9 + 点光 0.9),所以不用再装 three.js 和 vanta。 * 参数和视频里一样:颜色 0x0a1c2c、光泽度 70、波高 22、波速 0.55、缩放 0.72(鼠标第一次划过后镜头才拉远,和原版一样); * 鼠标让镜头跟着轻轻移动。 * 滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * * Vanta.js 的许可: * Copyright 2020 Teng Bao * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated * documentation files (the "Software"), to deal in the Software without restriction, including without limitation * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or substantial portions of * the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ------------------------------------------------------------- */ export type WavesOptions = { color?: number; // 波面颜色(十六进制,比如 0x0a1c2c) shininess?: number; // 光泽度,越大高光越集中 waveHeight?: number; // 波高 waveSpeed?: number; // 波速 zoom?: number; // 缩放,小于 1 镜头拉远 }; const DEFAULTS: Required = {color: 0x0a1c2c, shininess: 70, waveHeight: 22, waveSpeed: 0.55, zoom: 0.72}; const VS = `#version 300 es in vec3 aPos; // x、原来的高度、z in float aPhase; // 每个顶点自己的波相位(含 Vanta 的"横向碎浪"项) uniform mat4 uProj, uView; uniform float uPhase, uHeight; out vec3 vView; void main(){ float d = sin(uPhase + aPhase); vec4 mv = uView * vec4(aPos.x, aPos.y + (d + 1.0) * (d + 1.0) / 4.0 * uHeight, aPos.z, 1.0); vView = -mv.xyz; gl_Position = uProj * mv; }`; // three.js r134 的 MeshPhongMaterial(flatShading):法线由屏幕上的位置差算出,每个三角面一个朝向 const FS = `#version 300 es precision highp float; in vec3 vView; out vec4 o; uniform vec3 uColor, uLight; uniform float uShininess; void main(){ vec3 n = normalize(cross(dFdx(vView), dFdy(vView))); vec3 V = normalize(vView), L = normalize(uLight + vView), H = normalize(L + V); float dNL = clamp(dot(n, L), 0.0, 1.0), dNH = clamp(dot(n, H), 0.0, 1.0), dVH = clamp(dot(V, H), 0.0, 1.0); float fr = exp2((-5.55473 * dVH - 6.98316) * dVH); vec3 F = vec3(0.0667) * (1.0 - fr) + fr; // 高光色 0x111111 的菲涅耳 vec3 spec = 0.9 * dNL * F * 0.25 * (uShininess * 0.5 + 1.0) * pow(dNH, uShininess); o = vec4(uColor * (0.9 + 0.9 * dNL) + spec, 1.0); // 环境光 0.9 + 点光 0.9 }`; // 一点矩阵运算:透视投影和"看向某点" type Vec3 = [number, number, number]; const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const nrm = (a: Vec3): Vec3 => { const l = Math.hypot(...a); return [a[0] / l, a[1] / l, a[2] / l]; }; const crs = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const perspective = (fov: number, aspect: number, near: number, far: number) => { const f = 1 / Math.tan((fov * Math.PI) / 360), nf = 1 / (near - far); return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]); }; const lookAt = (eye: Vec3, target: Vec3) => { const z = nrm(sub(eye, target)), x = nrm(crs([0, 1, 0], z)), y = crs(z, x); return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1]); }; const mul = (m: Float32Array, p: Vec3): Vec3 => [m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]]; /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ /** 在 host 里铺满 3D 波浪背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountWaves3D(host: HTMLElement, options: WavesOptions = {}): () => void { const o = {...DEFAULTS, ...options}; const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; const gl = canvas.getContext('webgl2', {antialias: true, alpha: false, depth: true}); if (!gl) return () => {}; // 不支持 WebGL2:留着 host 自己的底色 const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader'); return s; }; const prog = gl.createProgram()!; try { gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS)); gl.bindAttribLocation(prog, 0, 'aPos'); gl.bindAttribLocation(prog, 1, 'aPhase'); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) ?? 'link'); } catch { return () => {}; } const U = (n: string) => gl.getUniformLocation(prog, n); // 网格:101×81 个点,间距 18;每个点高度带一点随机起伏,每个格子随机选一条对角线切成两个三角形(和 Vanta 一样) const WW = 100, HH = 80, CELL = 18, ws = o.waveSpeed; const pos = new Float32Array((WW + 1) * (HH + 1) * 3), phase = new Float32Array((WW + 1) * (HH + 1)); for (let i = 0; i <= WW; i++) for (let j = 0; j <= HH; j++) { const k = i * (HH + 1) + j, x = CELL * (i - WW / 2), z = CELL * (HH / 2 - j); pos.set([x, Math.random() * 4 - 10, z], k * 3); phase[k] = -ws * x * 0.025 + ws * z * 0.015 + Math.sqrt(ws) * Math.cos(-x - 0.7 * z); } const idx: number[] = []; for (let i = 1; i <= WW; i++) for (let j = 1; j <= HH; j++) { const n = i * (HH + 1) + j, r = n - 1, h = n - (HH + 1), p = h - 1; idx.push(...(Math.random() < 0.5 ? [p, r, h, r, h, n] : [p, r, n, p, h, n])); } const vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, pos, gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, phase, gl.STATIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 1, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW); if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); const color = [(o.color >> 16) & 255, (o.color >> 8) & 255, o.color & 255].map((v) => v / 255); const TARGET: Vec3 = [140, -30, 190], LIGHT: Vec3 = [-100, 250, -100]; const START: Vec3 = [240, 200, 390]; const cam: Vec3 = [...START]; // 鼠标:x 从左到右、y 从上到下 0–1,镜头目标跟着挪,镜头每帧追 2%。 // 和 Vanta 原版一样:鼠标第一次划过之前镜头停在起点,之后才按 zoom 拉远(视频里就是起点的构图) const mouse = {x: 0.5, y: 0.5, engaged: false}; const onMove = (e: PointerEvent) => { const r = host.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height; if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { mouse.x = x; mouse.y = y; mouse.engaged = true; } }; addEventListener('pointermove', onMove); let proj = perspective(35, 1, 50, 10000); const fit = () => { const r = host.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.max(2, Math.round(r.width * dpr)); canvas.height = Math.max(2, Math.round(r.height * dpr)); proj = perspective(35, Math.max(r.width, 1) / Math.max(r.height, 1), 50, 10000); }; // 时间按"60 帧一秒的帧数"算,和 Vanta 一样 let frames = 0; const reduce = matchMedia('(prefers-reduced-motion: reduce)'); let raf = 0, last = 0, seen = false; const render = (dt: number) => { frames += dt * 60; const k = 1 - Math.pow(0.98, dt * 60); const z = o.zoom, goal: Vec3 = mouse.engaged ? [(START[0] + 100 * (mouse.x - 0.5)) / z, (START[1] - 100 * (mouse.y - 0.5)) / z, (START[2] - 50 * (mouse.x - 0.5)) / z] : START; for (let i = 0; i < 3; i++) cam[i] += (goal[i] - cam[i]) * k; const view = lookAt(cam, TARGET); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.enable(gl.DEPTH_TEST); gl.useProgram(prog); gl.bindVertexArray(vao); gl.uniformMatrix4fv(U('uProj'), false, proj); gl.uniformMatrix4fv(U('uView'), false, view); gl.uniform1f(U('uPhase'), (ws * frames * 0.02) % (Math.PI * 2)); gl.uniform1f(U('uHeight'), o.waveHeight); gl.uniform3fv(U('uColor'), color); gl.uniform3fv(U('uLight'), mul(view, LIGHT)); gl.uniform1f(U('uShininess'), o.shininess); gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_SHORT, 0); }; const tick = (now: number) => { raf = requestAnimationFrame(tick); render(last ? Math.min((now - last) / 1000, 5 / 60) : 1 / 60); last = now; }; const sync = () => { const run = seen && !document.hidden && !reduce.matches; if (run && !raf) { last = 0; raf = requestAnimationFrame(tick); } if (!run && raf) { cancelAnimationFrame(raf); raf = 0; } }; fit(); render(0); const ro = new ResizeObserver(() => { fit(); if (!raf) render(0); }); ro.observe(host); const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; sync(); }); io.observe(host); const onMotion = () => { sync(); if (!raf) render(0); }; document.addEventListener('visibilitychange', sync); reduce.addEventListener('change', onMotion); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); removeEventListener('pointermove', onMove); document.removeEventListener('visibilitychange', sync); reduce.removeEventListener('change', onMotion); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景,;颜色、波高这些可以传 options */ export function Waves3D({className = '', options}: {className?: string; options?: WavesOptions}) { const ref = useRef(null); const key = JSON.stringify(options ?? {}); // 按内容比较:每次渲染传一个新写的对象也不会重来 useEffect(() => mountWaves3D(ref.current!, JSON.parse(key) as WavesOptions), [key]); return
; } /* ---------------- 示例:首屏背景 ---------------- */ export default function Waves3DDemo() { return (
); } ```