Unity实时水波模拟:基于波动传播模型的交互式涟漪实现

1. 项目概述:从ShaderToy到Unity的水波涟漪

如果你在Unity里做过水面效果,大概率用过法线贴图加菲涅尔反射,或者用顶点动画模拟波浪。这些方法效果不错,但总感觉少了点“灵性”——那种真实的、动态的、能与场景交互的涟漪扩散感。今天要聊的这个“Unity-Wave-Propagation-Water-Ripple”项目,就完美地填补了这个空缺。它不是一个简单的贴图动画,而是一个基于波动传播物理模型的实时水波模拟器。简单来说,它把水面看作一个二维网格,每个网格点记录着当前的水位高度,然后通过一套数学规则,让一个点的“起伏”像真实水波一样,向四周扩散、衰减、叠加。这个项目的核心价值在于,它将一个在ShaderToy上广受好评的创意原型(原作者tomkh的“Wave Propagation Effect”),完整地移植并适配到了Unity的渲染管线中,让你能直接在游戏或交互应用里,实现点击、拖拽甚至物体落入水面时,生成无比逼真的动态涟漪。

这个效果特别适合用在需要高动态、强交互的水面场景。比如,你的2D游戏里有一滩积水,角色踩上去会泛起涟漪;或者在一个艺术装置或数据可视化项目中,用鼠标划过屏幕就能“拨动”出一片水波。它不依赖于复杂的三维网格变形,核心计算都在屏幕空间的纹理(Render Texture)里完成,性能开销相对可控,在移动端经过优化也能有不错的表现。无论你是想为你的独立游戏增添一抹生动的细节,还是单纯对图形学与物理模拟的结合感兴趣,这个项目都是一个绝佳的学习范本和实用工具。接下来,我会带你彻底拆解它的实现原理、Unity工程的具体搭建步骤、Shader代码的每一处细节,并分享我在实际应用和性能调优中踩过的坑和总结的技巧。

2. 核心原理:二维波动方程的离散化实现

要理解这个水波效果,我们得先抛开Unity和Shader,回到最基础的物理模型。理想水面的波动,可以用一个简化版的二维波动方程来描述。不过别担心,我们不需要解复杂的微分方程,而是用一种更直观、更适合计算机模拟的“细胞自动机”思路来理解。

2.1 网格与状态:水面的数字化

想象一下,我们把整个水面(在屏幕上就是一块矩形区域)划分成无数个细小格子,组成一个二维网格。每个格子我们称之为一个“细胞”(Cell)。在这个模拟中,每个细胞只关心一个核心状态:当前时刻的水面高度(或者说位移)。我们可以用一个二维数组height[t][x][y]来记录,其中t代表时间帧,xy是网格坐标。

但实时渲染中,我们无法保存所有历史帧的数据。聪明的做法是只保留当前帧上一帧的高度图。我们定义两张纹理(Render Texture):

  • CurrentBuffer: 存储当前帧所有网格点的高度值。
  • PreviousBuffer: 存储上一帧所有网格点的高度值。 高度值通常用像素的R通道(一个0到1的浮点数)来存储,0代表静止水平面,正负值代表起伏。

2.2 传播与计算:下一帧高度如何产生

波动传播的核心算法,可以用下面这个简化的离散公式来理解,它计算的是网格中某个点(x, y)在下一帧的新高度newHeight

newHeight[x][y] = ( (currentHeight[x+1][y] + currentHeight[x-1][y] + currentHeight[x][y+1] + currentHeight[x][y-1]) / 2.0 - previousHeight[x][y] ) * damping;

我们来拆解这个公式的每一部分:

  1. 邻居平均(currentHeight[x+1][y] + currentHeight[x-1][y] + currentHeight[x][y+1] + currentHeight[x][y-1]) / 2.0
    • 这部分计算了当前点上下左右四个直接邻居在当前帧的平均高度。为什么是除以2?原公式是(sum_of_4_neighbors) / 2,这其实是一个简化的拉普拉斯算子离散形式,它描述了“周围邻居对我的拉扯作用”。如果周围邻居都比我高,这个平均值就会把我“拉”高;反之则拉低。这是波动传播的驱动力。
  2. 减去上一帧自身高度- previousHeight[x][y]
    • 这是模拟“惯性”。新的高度变化不仅取决于当前周围环境,还和自身上一刻的状态有关。减去上一帧的高度,结合邻居平均,共同决定了运动的加速度(二阶导数的离散形式)。这是波动方程的核心。
  3. 乘以阻尼系数* damping
    • damping是一个略小于1的常数(比如0.99)。每一帧计算后都乘以这个系数,波的能量就会逐渐衰减,高度值慢慢回归到0(静止水面)。没有阻尼,波就会永远震荡下去。

计算流程的“双缓冲”技巧: 在代码中,我们不会真的去修改CurrentBuffer。标准的做法是:

  • 输入:PreviousBuffer(作为上一帧高度prev),CurrentBuffer(作为当前帧高度curr)。
  • 输出:一张新的NextBuffer(作为下一帧高度new)。
  • 然后,在下一轮计算开始前,进行缓冲区轮换:PreviousBuffer = CurrentBuffer,CurrentBuffer = NextBuffer。 这个过程就像动画制作中的翻页,通过两张(或三张)纹理交替作为输入和输出,实现状态的连续迭代更新。

注意:这个简化公式省略了波速等参数。在实际的ShaderToy原版和本Unity项目中,计算会更精细一些,可能包含对角线邻居的贡献,或者调整了归一化系数,但核心思想完全一致:用当前帧邻居的状态和自身历史状态,迭代计算出下一帧的状态。

2.3 渲染与视觉化:从高度到波纹

计算得到的高度图(CurrentBuffer)只是一张灰度图,数值在正负之间变化。如何把它变成我们看到的蓝色涟漪?

  1. 法线生成:这是关键一步。我们通过对高度图进行屏幕空间差分来近似计算法线。简单说,就是检查一个像素点与其右边邻居、上边邻居的高度差。
    • float dX = height[x+1][y] - height[x][y];// X方向的变化率(坡度)
    • float dY = height[x][y+1] - height[x][y];// Y方向的变化率(坡度)
    • 这两个差值构成了一个向量(dX, dY, 1),将其归一化后,就得到了该点的近似法线向量(nX, nY, nZ)。这个法线图是后续光照计算的基础。
  2. 光照与颜色:有了法线图,就可以应用经典的光照模型。
    • 漫反射:用法线与光源方向(比如一个固定的顶光)点乘,决定基础明暗。
    • 镜面反射/菲涅尔效应:通常还会混合一些基于视角的效应,让水面在边缘处更透明或反射更强。在这个项目中,为了简洁和性能,可能采用一个固定的颜色渐变(如从深蓝到浅蓝再到白色)来映射高度值,并结合法线扰动,模拟出波光粼粼的感觉。
  3. 交互输入:如何用鼠标或触摸产生涟漪?很简单,在交互发生的瞬间,向CurrentBuffer中对应的网格坐标“注入”一个高度值(比如一个脉冲,一个圆形区域内的点设置为一个正值)。在下一帧的计算中,这个突起就会按照上述波动公式开始向四周传播和扩散,形成涟漪。

3. Unity工程搭建与核心组件解析

理解了原理,我们来看如何在Unity中具体实现。这个GitHub项目提供了完整的代码和Shader,但为了让你能彻底掌握并能自行修改,我们来一步步拆解。

3.1 项目结构与资源准备

首先,你需要一个Unity项目(建议使用较新版本,如2021.3 LTS或2022.3 LTS,对URP/HDRP和ShaderGraph支持更好)。核心文件通常包括:

  1. C#脚本:负责管理渲染纹理、组织渲染流程、处理交互输入。
  2. Compute Shader 或 多个Pass的Shader:执行核心的高度场迭代计算。原项目可能使用Fragment Shader在多张RenderTexture之间绘制来实现,现代做法更倾向于用Compute Shader,并行效率更高。
  3. 显示用Shader:用于将最终的高度图/法线图渲染到屏幕上的某个平面(Quad)或整个相机视图。

第一步:创建渲染纹理(Render Texture)我们需要至少两张Render Texture作为高度场缓冲区。在Asset文件夹中右键创建,或通过代码动态创建。

// 示例代码:创建用于计算的高度场纹理 RenderTexture CreateHeightFieldRT(int width, int height) { RenderTexture rt = new RenderTexture(width, height, 0, RenderTextureFormat.RFloat); // 只需一个浮点通道存储高度 rt.enableRandomWrite = true; // 允许Compute Shader写入 rt.wrapMode = TextureWrapMode.Clamp; // 边缘模式,Clamp防止波纹溢出屏幕 rt.filterMode = FilterMode.Point; // 计算阶段通常用点过滤,避免插值引入误差 rt.Create(); return rt; }

这里的关键参数是RenderTextureFormat.RFloat,因为高度值需要浮点精度。enableRandomWrite = true对于使用Compute Shader至关重要。

3.2 核心计算脚本(C#)的工作流

创建一个名为WavePropagationController的MonoBehaviour脚本,它将是整个系统的总指挥。

脚本的核心职责:

  1. 初始化缓冲区:在Start()Awake()中创建两张(或三张)Render Texture (_bufferA,_bufferB),并可能用一张纯色纹理(代表平静水面)初始化它们。
  2. 设置计算材质/Compute Shader:加载计算用的Shader,并为其设置正确的纹理参数和全局变量(如阻尼系数_Damping、波速_WaveSpeed等)。
  3. 每帧更新计算:在Update()中:
    • 处理输入(鼠标/触摸),将交互位置和强度写入当前高度缓冲区(这通常需要另一个单独的Compute Shader Pass或Blit操作)。
    • 执行核心的波动传播计算。这通常是一个Graphics.Blit调用(使用一个特定的Material)或者一个ComputeShader.Dispatch调用。这个操作读取_bufferA(作为当前帧) 和_bufferB(作为上一帧),然后输出到一张临时纹理或直接轮换后的_bufferA
    • 执行缓冲区交换:Swap(_bufferA, _bufferB)
  4. 渲染结果:在OnRenderImage(后处理方式)或者用一个单独的摄像机渲染到一个Quad上的方式,将最终的高度图(或经过着色后的结果)显示出来。

一个典型的双缓冲更新循环伪代码:

void UpdateWaveSimulation() { // 1. 处理交互,将输入写入到 _currentBuffer ProcessInteraction(_currentBuffer); // 2. 执行波动计算:以 _currentBuffer 和 _previousBuffer 为输入,输出到 _nextBuffer _simulationMaterial.SetTexture("_CurrentTex", _currentBuffer); _simulationMaterial.SetTexture("_PreviousTex", _previousBuffer); _simulationMaterial.SetFloat("_Damping", damping); Graphics.Blit(_currentBuffer, _nextBuffer, _simulationMaterial); // 使用特定的Shader Pass进行计算 // 3. 缓冲区轮换 Swap(ref _currentBuffer, ref _previousBuffer); // _previousBuffer 变成旧的_currentBuffer // _nextBuffer 现在变成了新的 _currentBuffer,但我们需要把它的内容“移交” // 更常见的做法是直接让 _nextBuffer 和 _currentBuffer 交换引用,避免大量数据拷贝 Swap(ref _currentBuffer, ref _nextBuffer); }

3.3 计算着色器(Compute Shader)深度解析

对于性能要求高的场景,Compute Shader是首选。我们来看一个简化版的核心计算内核。

// WaveSimulation.compute #pragma kernel CSWavePropagate RWTexture2D<float> _CurrentHeight; // 当前帧高度图 (可读写) Texture2D<float> _PreviousHeight; // 上一帧高度图 (只读) float _Damping; float _WaveSpeed; // 控制波传播速度的系数 uint _Width; uint _Height; [numthreads(8, 8, 1)] void CSWavePropagate (uint3 id : SV_DispatchThreadID) { uint x = id.x; uint y = id.y; if (x >= _Width || y >= _Height) return; // 获取当前点及邻居的高度(注意边界处理) float center = _CurrentHeight[uint2(x, y)]; // 使用分支或预取方式安全地获取邻居值,处理边界(边界点邻居可能取自身或0) float left = (x > 0) ? _CurrentHeight[uint2(x-1, y)] : center; float right = (x < _Width-1) ? _CurrentHeight[uint2(x+1, y)] : center; float top = (y > 0) ? _CurrentHeight[uint2(x, y-1)] : center; float bottom = (y < _Height-1) ? _CurrentHeight[uint2(x, y+1)] : center; // 获取上一帧自身高度 float prevCenter = _PreviousHeight[uint2(x, y)]; // 核心波动公式(更完整版,包含波速系数) // 公式: newHeight = ( (sum(neighbors) - 4*center) * waveSpeedFactor + 2*center - prevCenter ) * damping // 其中 (sum(neighbors) - 4*center) 是拉普拉斯算子的标准5点离散形式 float neighborSum = left + right + top + bottom; float laplacian = neighborSum - 4.0 * center; float newHeight = (laplacian * _WaveSpeed + 2.0 * center - prevCenter) * _Damping; // 将结果写入一个临时数组或直接交换缓冲区策略中的输出纹理 // 这里假设 _CurrentHeight 就是我们要写入的“下一帧”缓冲区(在双缓冲交换策略下) // 实际上,为了避免读写冲突,我们应有专门的输出纹理。这里为简化概念。 _CurrentHeight[uint2(x, y)] = newHeight; }

实操心得:在Compute Shader中,边界处理(if (x > 0))会带来分支,可能影响GPU并行效率。一种优化技巧是,将纹理的Wrap Mode设置为Clamp,然后在采样时使用SampleLevel或直接通过Load读取,让硬件自动处理边界。或者,你可以将计算区域缩小一圈,边界永远保持为0(静止),这样内部像素的计算就无需边界判断。

3.4 渲染与着色器(Display Shader)

计算得到的高度图需要被渲染出来。我们创建一个Unlit Shader或者Surface Shader来负责视觉化。

// 片段着色器核心部分 (简化版) fixed4 frag (v2f i) : SV_Target { // 采样高度图 float height = tex2D(_HeightMap, i.uv).r; // 通过差分计算法线 (在片段着色器中或提前用Compute Shader生成法线图) float2 texelSize = _MainTex_TexelSize.xy; float hLeft = tex2D(_HeightMap, i.uv - float2(texelSize.x, 0)).r; float hRight = tex2D(_HeightMap, i.uv + float2(texelSize.x, 0)).r; float hTop = tex2D(_HeightMap, i.uv - float2(0, texelSize.y)).r; float hBottom = tex2D(_HeightMap, i.uv + float2(0, texelSize.y)).r; float3 normal; normal.x = (hRight - hLeft) * 0.5; // X方向坡度 normal.y = (hBottom - hTop) * 0.5; // Y方向坡度 normal.z = 1.0; // Z分量 normal = normalize(normal); // 基础颜色 + 法线光照 float3 lightDir = normalize(float3(0.5, 1.0, 0.2)); // 简单定向光 float diffuse = max(0, dot(normal, lightDir)); // 根据高度和光照混合颜色 float3 waterColorDeep = float3(0.0, 0.1, 0.3); float3 waterColorShallow = float3(0.2, 0.5, 0.8); float3 foamColor = float3(1.0, 1.0, 1.0); // 高度越高(波峰),颜色越浅,甚至出现白色泡沫 float heightFactor = saturate(abs(height) * _FoamIntensity); float3 baseColor = lerp(waterColorDeep, waterColorShallow, diffuse); float3 finalColor = lerp(baseColor, foamColor, heightFactor); // 添加简单的镜面高光 float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos); float3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(0, dot(normal, halfDir)), _Gloss); finalColor += spec * _SpecularColor; return float4(finalColor, 1.0); }

这个着色器做了几件事:从高度差算法线、用法线计算漫反射光照、根据高度值混合深浅水色和泡沫色、添加了简单的镜面高光。你可以根据需要调整颜色、光照模型,甚至加入折射、反射等更复杂的效果。

4. 完整实现步骤与参数调优

现在,我们把所有部分组装起来,形成一个可运行的Unity场景。

4.1 场景搭建步骤

  1. 创建渲染目标:在场景中创建一个Quad或Plane,它将作为水面的视觉载体。将其缩放至适合屏幕或场景的大小。
  2. 创建材质:创建一个新材质,使用上文编写的“显示用Shader”。将材质赋给Quad。
  3. 挂载控制脚本:创建一个空GameObject,命名为“WaveSimulationManager”,将WavePropagationController脚本挂载上去。
  4. 脚本配置
    • 将Quad的材质拖拽到脚本的Display Material字段。
    • 在脚本中指定计算用的Compute Shader或Material。
    • 设置渲染纹理的分辨率(如512x512)。分辨率越高,波纹越精细,但性能消耗越大。
    • 调整参数:Damping(阻尼,0.98-0.999)、WaveSpeed(波速,0.5-2.0)、Force(交互力度)。
  5. 交互设置:在控制脚本的Update方法中,检测鼠标点击或触摸。将屏幕坐标转换为纹理UV坐标,然后在计算着色器或通过一个单独的“注入”Pass,向当前高度纹理的对应位置添加一个圆形区域的高度值。

4.2 关键参数详解与调优指南

参数调优是让效果逼真的关键。以下是我的经验值范围和建议:

参数名典型范围作用调优技巧
Damping (阻尼)0.985 - 0.998控制波纹衰减速度。值越接近1,波纹持续越久,传播越远;值越小,波纹消失越快。想要平静水面的小涟漪,用0.99左右;想要类似粘稠液体的效果,用0.98;想要悠长的水波,用0.995以上。
WaveSpeed (波速)0.8 - 1.5控制波纹向外扩散的速度。物理上关联于水深。值越大,波传播越快。通常设置在1.0附近比较自然。调得过高(>2)可能使模拟不稳定(数值爆炸)。
Force/Amplitude (力度/振幅)0.05 - 0.3鼠标点击或交互时注入的高度值大小。决定涟漪的初始强度。太大的值会导致水面剧烈震荡,像爆炸而不是滴水。从0.1开始尝试。
Radius (作用半径)0.01 - 0.05 (UV空间)交互时影响的范围大小。决定涟漪的初始大小。点按产生小圆波,拖拽可产生线状波。可以做成根据鼠标移动速度动态变化。
Texture Resolution (纹理分辨率)256x256 - 1024x1024高度图的分辨率。分辨率直接影响波纹的细腻程度和性能。移动端建议256或512,PC端可尝试1024。注意RenderTexture的创建和采样开销。
Time Step (模拟步进)Fixed to Frame Time通常每帧执行一次计算。波动方程对时间步长敏感。如果游戏帧率波动大,可以考虑使用固定的时间步长(Fixed Timestep)进行模拟,与渲染帧率解耦,避免波纹速度时快时慢。

性能优化技巧:

  • 降低分辨率:这是最有效的优化手段。很多时候512x512的视觉效果已经足够好,但性能比1024x1024好很多。
  • 使用Compute Shader:相比用Fragment Shader通过Blit进行全屏绘制,Compute Shader的线程组并行计算效率更高,尤其是对于这种每个像素独立计算的任务。
  • 降低更新频率:如果不是极度需要,可以每两帧更新一次模拟(if (Time.frameCount % 2 == 0)),视觉上几乎无差异,但计算量减半。
  • 分块更新:如果水面很大但只有局部有交互,可以只更新以交互点为中心的一个矩形区域,而不是整个纹理。但这实现起来更复杂。
  • 使用半精度浮点数:如果目标平台支持(如某些移动GPU),在Render Texture格式和Compute Shader中使用半精度(half)可以提升性能。

5. 常见问题、排查与效果增强

在实际使用中,你肯定会遇到各种问题。这里记录了一些典型情况及其解决方法。

5.1 模拟不稳定(数值爆炸)

现象:波纹不仅不衰减,反而幅度越来越大,最终所有像素值变成NaN或极大值,画面出现破碎的色块。原因:这是数值模拟的经典问题。波动公式中的系数组合(特别是波速_WaveSpeed和阻尼_Damping)不满足数值稳定性条件。当_WaveSpeed过大时,每次迭代增加的能量超过了阻尼衰减的能量。解决方案

  1. 降低_WaveSpeed:这是最直接的方法。尝试将其降到1.0以下,比如0.8。
  2. 增加_Damping:让其更接近1,但不要等于或超过1。从0.99提高到0.995。
  3. 检查边界条件:确保你的边界处理是正确的。如果边界反射能量(例如使用重复Wrap模式),也可能导致能量累积。确保边界是“吸收”或“固定”的(Clamp to Edge或固定为0)。
  4. 使用更稳定的积分方法:原始的Verlet积分(用到了当前帧和上一帧)有时不够稳定。可以尝试引入微小的“速度阻尼”项,或者使用半隐式积分方法,但这会大幅增加计算复杂度。

5.2 波纹有“方格”感或锯齿

现象:扩散的波纹不是光滑的圆形,而是有明显的像素方格感。原因:计算纹理分辨率太低,且计算着色器中使用的是点采样(Point Filter)。点采样在计算时是必要的,可以防止插值污染数据,但在最后显示时,如果直接将低分辨率的高度图拉伸到全屏,就会产生块状感。解决方案

  1. 提高计算分辨率:这是根本解决方法,但耗费性能。
  2. 在显示阶段使用双线性或三线性过滤:确保最终显示水面的材质,其_HeightMap纹理的采样器使用Linear过滤。在Shader中声明:sampler2D _HeightMap;默认就是线性过滤。关键点:计算用的纹理(RenderTexture)其filterMode应设为Point,但传递给显示Shader时,Unity会使用显示Shader中定义的采样器设置进行采样。
  3. 后处理模糊:对最终生成的法线图或颜色图进行一次轻微的高斯模糊,可以平滑锯齿,让波纹看起来更柔和。这需要额外的Blit操作。

5.3 交互延迟或位置不准

现象:鼠标点击后,波纹出现的位置和鼠标位置有偏移,或者有延迟感。原因

  1. 坐标转换错误:鼠标屏幕坐标到纹理UV坐标的转换出错。需要考虑到视口、渲染纹理比例等因素。
  2. 注入时机:交互输入的处理帧和波纹计算帧不同步。解决方案
  3. 精确坐标转换
    Vector3 mousePos = Input.mousePosition; Ray ray = Camera.main.ScreenPointToRay(mousePos); RaycastHit hit; if (Physics.Raycast(ray, out hit)) // 假设水面有一个Collider { Vector2 uv = hit.textureCoord; // 这是在水面模型UV空间中的坐标 // 如果水面模型UV是简单拉伸,那么uv可以直接用。 // 否则需要根据模型的UV映射来调整。 // 然后将uv传入Compute Shader。 }
    如果水面是全屏效果,则更简单:Vector2 uv = new Vector2(mousePos.x / Screen.width, mousePos.y / Screen.height);
  4. 确保每帧注入:在控制脚本的Update中检测输入,并立即在同一帧的模拟计算之前,将交互数据写入高度缓冲区。确保执行顺序是:处理输入 -> 执行波动计算 -> 交换缓冲区 -> 渲染

5.4 效果增强思路

基础波纹跑通后,你可以尝试以下增强效果,让它更出彩:

  1. 多源扰动:不止是鼠标,可以让场景中的物体(如雨滴、角色脚部、行驶的船)持续对水面产生扰动。为每个物体定义一个影响区域和力度,每帧向高度图叠加。
  2. 风场:引入一个全局或局部的风场向量,在波动计算中给每个点一个持续的方向性偏移,可以模拟出风吹湖面的效果。
  3. 障碍物:在高度图中定义一些区域为“陆地”或“障碍物”,这些点的高度永远为0且不参与计算。这样波纹传播到边缘就会“反弹”或消失,可以模拟水池边界。
  4. 与Stylized Water 2等资产结合:你可以将这个动态高度图/法线图,作为第三方水面着色器(如Stylized Water 2)的额外法线输入。将计算得到的法线图与资产原有的法线图混合,能在漂亮的静态水面上叠加动态涟漪,效果极佳。
  5. 渲染优化:将计算和渲染分离到不同的摄像机层。用一个低分辨率摄像机专门计算水波,结果存储为RenderTexture。主摄像机渲染场景时,采样这张纹理来影响水面的法线和高度。这样可以灵活控制计算开销和渲染质量。

这个“Unity-Wave-Propagation-Water-Ripple”项目是一个经典的图形学编程案例,它巧妙地将物理模拟、并行计算和实时渲染结合在一起。我最初实现它时,被数值爆炸问题困扰了很久,最后发现是波速参数设置得太激进。后来在移动端项目中使用时,又将分辨率从1024降到了512,并通过每两帧更新一次模拟,成功将帧率稳定在60fps。它的扩展性很强,你可以把它当作一个基础的“物理场模拟器”,思路同样可以用于模拟热扩散、草地摆动甚至人群密度图。希望这篇超详细的拆解,能帮你不仅实现效果,更能理解其每一行代码背后的意义,从而创造出属于你自己的独特水面交互。