最近接了一个品牌的落地页需求,对方给的要求很直接:背景要一片动态海面,界面要玻璃质感。这两个关键词组合在一起,说白了就是当下很流行的沉浸式Web体验——海水在页面上真的能波动,信息卡片像一块毛玻璃浮在海面上,既要通透又要清晰,而且整页滚动和交互不能掉帧。这种效果放在产品发布页、品牌官网、个人作品集里都特别出彩,但对开发者的基本功要求也不低。这篇文章就把我这次的完整实现过程拆开讲,从动态海面的生成原理,到玻璃界面的CSS细节,再到两者合体时的性能控制,适合已经会基础前端、想挑战WebGL和高级CSS效果的开发者照着做一遍。
1. 先把需求拆清楚:动态海面要动到什么程度,玻璃界面要透到什么感觉
1.1 这类页面最适合用在什么地方
我一开始没急着写代码,先把场景想清楚了。动态海面加玻璃界面这个组合,最典型的应用是三类页面:第一类是品牌故事页,用背景氛围感撑起品牌调性,让用户刷到第一屏就眼前一亮;第二类是活动落地页,海面作为视觉锤,玻璃卡片承载报名信息、日程亮点;第三类是个人作品集首页,进门第一屏就要展示基本功,这种效果比普通静态大图更能体现前端深度。
这三类页面的共同点是不需要太复杂的信息架构,核心内容通常只有三五张卡片、一两段主文案、一个按钮。信息少,视觉就必须做得有记忆点,动态海面正好承担这个任务,而玻璃界面负责在花哨的背景上保证内容可读性。如果页面信息量很大,全是密密麻麻的表格和列表,那就不适合这种设计了,毛玻璃对密集文字的可读性是负面作用。
1.2 技术方案的现实选择:不是只有WebGL一条路
想实现海面动态效果,常见有三条路。第一条是纯CSS动画,用几个不同方向和速度的径向渐变做成波形背景,再配合transform的旋转位移模拟水波。这条路性能好、兼容性好,但假得明显,两块渐变在动,本质上还是平面图形在位移,适合预算低、运营类的小活动页。第二条是用CSS的3D变换配合一些遮罩技巧,做一个类似水面的流动效果,但控制力还是不够。
真正能打的是第三条路:WebGL。Three.js是最容易上手的选择,它把WebGL的底层细节封好了,你用PlaneGeometry建一个足够细分的平面网格,在着色器里让每个顶点的Y轴位置随时间变化,就能得到一片会晃的水面。而且海面的透视、颜色渐变、雾效、高光反射这些质感细节,只有WebGL能做得比较逼真。玻璃界面则是另一套技术栈,核心是CSS的backdrop-filter属性,半透明背景加背景模糊加细描边,三行代码就能出玻璃感,不需要切图也不需要做纹理。
我的选型结论很明确:海面用Three.js,玻璃用CSS。两者之间的通信只需要通过自定义事件绑定鼠标位置,复杂度可控。如果你完全不想碰Three.js,那ShaderToy上也有不少纯GLSL海面方案,但工程化比较麻烦,不如Three.js收放自如。
2. 动态海面的实现:从一片静态方格子到真正能晃的水
2.1 海面能不能动,关键在“顶点”
先搞清楚一个基础概念:Three.js里的PlaneGeometry(平面几何体)本质上是一张由三角形拼成的网格,每个三角形有三个顶点。默认状态下这个Plane是一个平整的矩形,所有顶点的高度坐标都是0,所以渲染出来是平的。要让海面动起来,核心思路就是:在每一帧渲染前,把这些顶点的高度值改成不同的数值,并且让这个数值随时间变化。
波动的数学原理很简单,我们中学学过正弦函数sin(x),它的图像本身就是平滑的波浪。一个顶点的高度可以用多个不同频率的正弦波叠加而成,公式类似这样:
height = sin(x * 频率A + 时间 * 速度A) * 振幅A + sin(y * 频率B + 时间 * 速度B) * 振幅B + ...为什么必须叠加多个波?因为单个正弦波太规律了,水面看起来会像一块被风吹动的塑料布,很假。实际海面是无数大小浪的叠加,大浪套小浪,小浪上还有碎波。通常叠加三到五层不同频率和方向的正弦波,视觉上就有自然的随机感了。在Shader里做这个操作性能最好,因为顶点计算是并行跑在GPU上的,一个几千顶点的平面也毫无压力。
有个坐标系细节要格外注意:PlaneGeometry默认是平躺在XY平面上的,Z轴才是它的高度方向。你在创建几何体后一定要先执行rotateX(-Math.PI / 2),把它翻到XZ平面,这样Y轴才是视觉上的垂直方向,后续你的大脑和代码才不容易混乱。
2.2 第1步:搭好Three.js基础场景
我习惯用Vite作为工程脚手架,安装依赖的时候只需要三个包:three、vite,以及一个用于演示交互控制的three/examples/jsm/controls/OrbitControls.js(实际项目里可以不引入,或者加一个限制条件的相机控制)。先写一个最小可运行的场景:
import * as THREE from 'three'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a1a2a); scene.fog = new THREE.FogExp2(0x0a1a2a, 0.02); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200); camera.position.set(0, 10, 24); camera.lookAt(0, 2, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();这里我做了两件比较重要的事。第一是设置了scene.fog,雾的颜色和背景色保持一致,这样海面远处会自动慢慢隐入背景,实现“海天相接”的透视感,这是让海面看起来广阔的关键技巧。第二是设置renderer为alpha透明,因为后面玻璃卡片和网页文档背景要能看到,WebGL画布需要叠在它们下层。
2.3 第2步:写一个能让顶点波动的水面Shader
接下来创建海面网格。我定义一个函数用来生成海面,并在其中传入时间uniform。
function createWater() { const geo = new THREE.PlaneGeometry(80, 80, 256, 256); geo.rotateX(-Math.PI / 2); const waterMaterial = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uDeepColor: { value: new THREE.Color(0x023e6b) }, uShallowColor: { value: new THREE.Color(0x1d7fb8) }, }, vertexShader: document.getElementById('water-vertex').textContent, fragmentShader: document.getElementById('water-fragment').textContent, transparent: true, }); const mesh = new THREE.Mesh(geo, waterMaterial); scene.add(mesh); return mesh; }Vertex Shader是这张水面的灵魂。我的实现是三层正弦波的叠加,第一层是长波长的大波浪,第二层是中等长度的波纹,第三层是高频碎波。核心代码如下:
uniform float uTime; varying float vHeight; varying vec2 vUv; void main() { vec3 pos = position; float wave1 = sin(pos.x * 0.35 + uTime * 1.3) * 1.2; float wave2 = sin(pos.y * 0.55 + uTime * 1.1) * 0.8; float wave3 = sin((pos.x + pos.y) * 0.75 + uTime * 2.0) * 0.35; pos.y += wave1 + wave2 + wave3; vHeight = pos.y; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }Fragment Shader负责把高度值映射成颜色。我的策略是:高度低的地方颜色深,高度高的地方颜色浅,这样浪尖会自然亮起来,浪谷比较暗,海水就有了立体感。
uniform vec3 uDeepColor; uniform vec3 uShallowColor; varying float vHeight; varying vec2 vUv; void main() { float h = clamp(vHeight / 3.0 + 0.5, 0.0, 1.0); vec3 color = mix(uDeepColor, uShallowColor, h); float alpha = 0.85; gl_FragColor = vec4(color, alpha); }写完这两个Shader之后,在时机上要每帧更新uTime。在animate里加上一句:
waterMaterial.uniforms.uTime.value += 0.016;这里我直接按帧增量更新。需要说明一下:不同刷新率设备在同一帧内的表现会略有差异,更规范的方式是用clock.getDelta()计算真实时间差。我为了代码简洁用了固定增量,实际发布项目还是建议用Clock。
2.4 第3步:让水有“水”的质感,而不只是一张动态蓝布
做到这一步,你已经有了一片波动的蓝色网格,但看起来更像果冻,不像水。要让水面有真实质感,还得补三样东西:立体光照、高光反射、细节法线。
立体光照可以通过在Shader里加入菲涅尔效果实现。菲涅尔简单说就是:视线垂直于水面时,我们看到的主要是水下的颜色;视线掠射水面时,反射的天空光会更多。在Fragment Shader里可以用法线与视线方向的夹角来模拟这个效果,看起来水面的边缘会泛出淡淡的白色反射光。
高光反射更直接,我没用真正的反射贴图,而是在Shader里加了一个太阳高光。用光源方向和水面法线的半向量与视线方向的点积做一个指数幂,模拟出太阳在水面上洒下的那一道亮斑。这一步对真实感提升极大,加了之后水面瞬间从塑料布变成有光泽的水面。
法线细节我采用的是最可控的方式:在Shader里叠加两个不同尺度的噪声函数,用来扰动法线,而不是真的加载一张法线贴图。这样不需要多一次纹理采样,性能更好,而且噪声的波长可以实时调节,配合波浪高度形成更自然的起伏。
水面颜色的深度控制也值得多花一点时间。我使用了uDeepColor和uShallowColor两个uniform,浪谷更接近深海蓝,浪尖带一点浅蓝甚至是青色。加上雾效的作用,远处海面会逐渐融进背景色,整张海面就有了纵深。
到这里,动态海面的核心已经完成了。这段代码在桌面端跑起来大概能稳定在60帧,泳道网格256x256也撑得住,但到了移动端就要小心性能了,这一点我会在第5部分详细讲。
3. 玻璃界面:毛玻璃不是靠滤镜糊一层就行
3.1 玻璃拟态的三个门面参数
玻璃拟态这个设计风格其实已经流行了好几年,它的核心视觉特征可以用三个参数概括:背景模糊、半透明材质、高光描边。
背景模糊是玻璃拟态和普通半透明卡片最大的区别。它允许背后被遮挡的内容以一种“揉碎了”的方式透出来,而不是直接透出清晰的图像。这正好和动态海面形成互补:海面的波浪细节在玻璃卡片区域被模糊,但颜色和光感依然能渗透进卡片,让卡片看起来像是真实浮在海上的一块弧形玻璃。
半透明材质决定的是玻璃的密度。白色通道越低,玻璃越透明,背后的海面就越抢戏,文字会看不清;白色通道太高,玻璃就变成了磨砂塑料,失去通透感。我一般控制在5%到15%的白色透明度。
高光描边包含两部分。一是卡片的border,通常使用rgba(255,255,255,0.2)左右的半透明白色,模拟玻璃切边的亮度;二是边框内部上方的一道更亮的高光,可以用box-shadow: inset 0 1px 0 rgba(255,255,255,0.3)来实现,这是让玻璃看起来有厚度感的关键一笔。没有这道内高光,玻璃就是一个平面的色块,加了之后立刻有玻璃板边缘的切面感。
3.2 写一个能用的玻璃卡片
CSS核心代码其实很短,真正的难点是搞清楚每个属性的含义和值怎么配:
.glass-card { position: relative; width: 360px; padding: 32px; border-radius: 20px; background: rgba(255, 255, 255, 0.08); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.18); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 8px 32px rgba(0, 20, 40, 0.4); }这里有三点需要特别提醒。
第一,backdrop-filter的厂商前缀不能省。虽然现代Chrome和Edge已经支持无前缀写法,但Safari仍然必须加-webkit-前缀,所以两行都要写,顺序上无前缀写在后面。
第二,blur值不是越大越好。我实测在16px到20px之间效果最自然,因为这个数值既能让水面的波浪细节柔和化,又保留大块颜色和明暗变化。如果调到40px以上,卡片背后会变成一片雾,完全没有层次。
第三,box-shadow的叠加顺序有讲究。内高光写在第一个,是模拟玻璃上表面的反射;外阴影写在后面,是模拟玻璃卡片在页面上的悬浮投影。这个投影颜色不要用纯黑色,加一点蓝色(比如rgba(0, 20, 40, 0.4))会更好地和环境融合。
3.3 让玻璃有“空气感”的进阶细节
基础玻璃卡片有了,但很多页面做完还是显得“脏”或者“平”,问题往往出在三个细节上。
第一个细节是噪点。真实的玻璃材质并不是绝对光滑的,表面有微小的灰尘颗粒和划痕,在光的照射下会形成微弱的噪点。可以在卡片上叠加一层使用SVG feTurbulence生成的噪点背景,透明度控制在2%以下。虽然很细微,但它打破了渐变色彩的完美平滑,会让玻璃更有真实性。
第二个细节是光线的方向感。玻璃卡片应该让人觉得是实打实被光照着的,所以卡片的光源方向要和海面的太阳光方向一致。比如海面左侧有高光亮斑,那么卡片左侧边框就应该更亮一些。实现方式是用一个伪元素,用线性渐变从左上角到右下角拉一道半透明白色,叠加在卡片之上,模拟来自同一方向的光。
第三个细节是内容层与玻璃层分离。玻璃卡片上不要直接放文字,应该再有一层内容容器,确保文字自成一体,阴影和光效不会干扰阅读。我给卡片内的文字加了一层淡淡的text-shadow,实际上是白色文字加0.5px的深色阴影,小字号时候能明显提升可读性。
.glass-content { color: rgba(255, 255, 255, 0.92); text-shadow: 0 1px 6px rgba(0, 0, 0, 0.2); }如果你想要大标题上的玻璃质感,还可以用background-clip: text配合一个半透明渐变,做出玻璃字的效果。不过这个技巧对字体和背景的要求比较高,只适合标题文本,正文不要用。
3.4 和动态海面融合的视觉技巧
玻璃卡片放在海面上面,视觉上的融合程度决定整页的高级感。我的经验是三句话:玻璃要多透一点,海面要多暗一点,文字要多亮一点。
多透一点的意思是,玻璃卡片的背景透明度可以比常规界面更低,因为背后是动态海面,天然有颜色在流动,玻璃如果太实会挡住海面的大半质感。多暗一点的意思是,海面Shader里可以在玻璃卡片所处的中央区域加一圈暗角,让这个区域亮度下降,为白色文字留出对比空间。实现方式不复杂,在Fragment Shader里根据uv坐标计算到中心的距离,乘以一个暗色系数即可。多亮一点则是保证文字颜色尽量接近纯白,玻璃卡片内部不要再用其他彩色背景。
另外一个非常实用的小技巧是:把海面的主色调采样出来,直接用在卡片阴影和按钮渐变里。这样页面整体色调高度统一,玻璃卡片不会显得是“贴”上去的独立组件,而是场景的一部分。
4. 合体实操:把海面和玻璃装进同一个页面
4.1 页面层次与Z轴管理
现在到了把两部分组装起来的环节。页面结构上,最高层是我们的HTML内容,也就是玻璃卡片所在的文档层;下面一层是承载Three.js画布的canvas元素;再往下是body背景色或者CSS渐变。
关键是要给canvas做一个fixed定位,铺满全屏,并且赋一个较低的z-index:
.water-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 0; }然后是内容容器:
.page-content { position: relative; z-index: 1; min-height: 100vh; display: flex; align-items: center; justify-content: center; pointer-events: none; } .glass-card { pointer-events: auto; }我习惯把page-content的pointer-events设为none,这样鼠标事件可以直接穿透到canvas,方便做鼠标涟漪交互。玻璃卡片自己再恢复pointer-events: auto,保证按钮和链接可点。
这里要提醒一个容易翻车的地方:WebGL渲染器默认创建的canvas是inline元素,会有几个像素的间隙,务必在CSS里设置display: block。否则页面底部会多出一条透明缝隙,看起来像没对齐。
4.2 鼠标视差让海面跟着眼睛走
光有波浪动画还不够,页面要“活”起来,我给海面加了一层鼠标视差。思路是:监听鼠标在窗口中的位置,归一化到[-1, 1]区间,然后用这个值微调相机的位置。
const mouse = { x: 0, y: 0 }; let targetX = 0; let targetY = 0; window.addEventListener('mousemove', (e) => { mouse.x = (e.clientX / window.innerWidth) * 2 - 1; mouse.y = (e.clientY / window.innerHeight) * 2 - 1; }); function animate() { requestAnimationFrame(animate); targetX += (mouse.x * 1.5 - targetX) * 0.05; targetY += (mouse.y * 0.8 - targetY) * 0.05; camera.position.x = targetX; camera.position.y = 10 + targetY; camera.lookAt(0, 2, 0); waterMaterial.uniforms.uTime.value += delta; renderer.render(scene, camera); }注意这里的平滑系数0.05,我实测下来比较舒服。系数太小(比如0.01)会有严重的延迟感,系数太大(比如0.2)又会觉得镜头跟手太死板,像在拖动鼠标而不是浏览页面。想要丝滑,还是要用lerp插值。
滚动视差也可以类似处理。监听滚动事件,把window.scrollY映射成相机位置的z轴偏移,这样往下滚的时候海面会微微拉升,内容卡片和背景形成视差关系。滚动值不要直接赋给相机,同样是要经过lerp平滑,否则滚动时会一卡一卡。
4.3 交互细节:涟漪、悬浮与按钮
为了让用户感受到“动态海面”不是背景GIF,而是真实可交互的场景,我添加了一个简单的涟漪交互:当鼠标在画布上移动时,会以鼠标位置为圆心产生一个衰减的波纹扰动。实现的原理是在Vertex Shader里额外增加一个uniform uMousePos,当一个顶点距离鼠标位置较近时,给它加上一个与距离和时间相关的扰动值,每隔一段时间衰减到0。
这个交互带来的体验提升非常明显,用户一旦发现海面会随鼠标动,会忍不住多晃动几下,页面的记忆点立刻就有了。
玻璃卡片也要配套交互。hover时我会让卡片稍微放大,并且提高内高光的透明度,表示这块玻璃“立起来”了。卡片上的按钮用一个横向渐变的半透明背景,hover时渐变色稍微偏移,模拟光线扫过玻璃的效果。
4.4 性能优化清单与实测数据
动态海面加玻璃卡片,最怕的就是卡。性能优化我做了一张清单,按优先级排列:
- 控制像素比。renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)),在移动端甚至可以限制到1.5。这是性价比最高的一步,直接减少GPU渲染像素量。
- 控制网格精度。桌面端256x256足够,移动端我用128x128。肉眼几乎无差别,但帧率能提升20%以上。
- 降低海面刷新频率。对纯视觉效果来说,不需要每秒60次更新海面uniform,用时间戳判断,每秒30次更新Shade的时间变量,能省一半的顶点计算,视觉上依然顺滑。
- 用visibilitychange暂停渲染。切换到后台标签页时停止requestAnimationFrame,避免白耗CPU和GPU。
- 利用fog隐藏远距离细节。远处的网格其实不需要那么多顶点,但因为PlaneGeometry是均匀的,这个省不掉。所以我把整个平面的尺寸控制在80x80以内,配合雾效,远处自然看不清。
我这套配置在桌面端稳定60帧,在手机端(中端机型)基本稳定在40帧以上,玻璃卡片的backdrop-filter是最大的性能杀手,但它只覆盖局部区域,影响不大。
5. 实录:常见翻车现场和排查方法
5.1 海面全黑或者纹路僵硬
新手最常遇到的是海面全黑。排查思路:先检查相机位置,是不是被海面本身挡住了,或者相机lookAt指向天空。再检查Shader里gl_Position是不是写对。最后检查材质阴影:ShaderMaterial默认不主动接收阴影,如果场景里有方向光但Shader里没写光照计算,模型就全是黑的。这种情况我不建议在ShaderMaterial里补计算,直接换用ShaderMaterial + 内置uniform光照,或者回到MeshPhongMaterial加凹凸贴图。
纹路僵硬通常是网格精度不够或者波函数过于单一。网格精度低于64x64时,海浪会呈现明显的折线感,就像看低清电视。波函数如果只用单层正弦,浪峰和浪谷的过渡太平滑,建议至少叠加三层不同频率的波,同时把振幅调成不等值。
5.2 帧率低、掉帧严重
性能问题先看任务管理器,但更可靠的是打开Chrome的Rendering标签页,开启FPS meter。掉帧大部分来自三类原因。
第一是绘制尺寸过大。检查renderer.setSize是不是用了CSS百分比导致canvas实际尺寸超过视口。第二是网格精度过高,256x256已经接近WebGL中等预算的极限,笔记本上可能没问题,但集成显卡的电脑会很吃力,及时降到128x128。第三是玻璃卡片数量太多。backdrop-filter不是免费的,每张卡片都开启blur,浏览器要为每个卡片单独计算背景采样。卡片超过四张且面积较大时,建议考虑减少卡片数量,或者只在主视觉卡片上启用真模糊,其余用半透明纯色模拟。
5.3 backdrop-filter在手机上失效
这是兼容性重灾区。iOS Safari在低版本上只支持-webkit-backdrop-filter,所以前缀不能删。还有一点容易忽视:如果祖先元素设置了overflow: hidden或者使用某些transform,会导致backdrop-filter的裁剪区域异常,模糊效果变成整个父容器糊掉。遇到这种情况,检查是否对父元素加了filter或者will-change属性,必要时把卡片结构层级摊平。
Android端部分浏览器对backdrop-filter支持不完整。我的兜底方案是在@supports规则里做能力检测,当不支持时,卡片就降级为。.9的纯色半透明背景,保证可读性不受影响。这个降级不是缺陷,反而比硬上模糊效果更干净。
5.4 水面与玻璃层级错乱
这种情况通常是canvas盖在了内容上面。检查CSS的z-index或者position是否缺失。记住canvas的z-index必须小于内容层,且canvas要fixed定位。还有一种隐蔽情况:Three.js渲染器在初始化时如果width和height拿到的是0(比如容器未渲染完成),canvas画布尺寸会是0x0,看起来就像内容层盖住了一切。解决办法是监听DOMContentLoaded之后再去初始化Three.js,或者用ResizeObserver监听容器尺寸变化。
以及一个额外提醒:如果页面有滚动条,fixed定位的canvas不会随滚动移动,这是对的。但如果你用了absolute定位,canvas会跟着滚动跑,滚动时海面会瞬间闪断,效果就会很糟糕。务必用fixed。
6. 再往深走一步:值得尝试的扩展方向
6.1 把玻璃卡片真正放进3D场景
CSS玻璃卡片在文档层上终究是平面的。如果你想让玻璃卡片真正浮在水面上,还有升级路线:在Three.js场景中用MeshPhysicalMaterial创建一块3D玻璃平板,给它设置transmission、roughness和thickness参数。MeshPhysicalMaterial是Three.js里目前最接近真实物理材质的选项,transmission会开启透射效果,让海面色彩穿透玻璃,roughness控制表面磨砂程度,thickness控制玻璃边缘的色散。
这样做的代价是需要自己处理UI点击、文字投射、坐标转换,工程量明显增加。只有当你需要摄像机大幅旋转、或者要做展开动画时才值得考虑。
6.2 增加声音与更丰富的交互反馈
海面页面很适合配上环境音效,比如浪花声和低沉的氛围音乐。视口进入页面之后用AudioContext初始化,避免浏览器自动播放的限制。当涟漪交互触发时,同步播放一个极短的水花音效片段,音量与鼠标速度相关,能带来很强的沉浸感。这一块要注意音源授权,尽量用可商用素材库的分轨素材。
6.3 换成更逼真的海浪着色器
如果你对效果要求再上一个台阶,可以参考“Gerstner波”模型。相比线性正弦叠加,它通过偏移顶点在水平方向的位置,让浪尖更尖锐、浪谷更宽阔,视觉上就是那种卷起来的海浪。实现原理稍微复杂,核心是把波方向、陡度、波长三个参数拆开,用旋转矩阵作用于顶点坐标,代码量翻倍,但效果能接近写实游戏里的水面。
还有一条更轻的路线:直接把Shader语言换成shadertoy风格,用一组FBM噪声函数反复叠加,再配合菲涅尔和反射,效果比多层正弦更有机理感,但调试时间会拉长,性能也需要重新测试。
就我个人的实际体会来说,动态海面和玻璃界面这对组合,真正吃功夫的不是某个单一技术点,而是两者之间的材质匹配和性能预算分配。海面Shader写得太重,玻璃就糊得掉帧;玻璃卡片堆得太多,海面再漂亮也白搭。建议你第一次实现时,先按文章里的基础版本跑通,观察帧率,再一步一步加交互和材质细节。这组技术在将来做各种沉浸式场景都会用得上,值得你花一个下午好好调试。