Monday, October 8, 2018

Ripples For Days

I see a lot of water shaders with all sorts of techniques for doing ripples when objects interact.  There's using the SDF in Unreal to figure out how far objects are from the water surface, but you can't pass in information about specific objects and their interaction.  There's passing in specific points to the shader about where you want ripples to happen, but this is limited to a handful of ripples who's state needs to be maintained manually.  And there's full fluid simulations which look cool, but can be costly to calculate and difficult to stylize.

The technique I've used in a few projects, including Prodeus and Super Lucky's Tale, takes the flexibility of a particle system and combines it with the shader performance of sampling a texture once.

The basic idea is to render all the ripples from an orthographic camera looking down and then re-project that texture onto your water surface.  This allows you to get all the ripple information and composite it with your water information, combining height, normal and foam information into one seamless shader.

Click Here to skip to project on GitHub and try it out for yourself.

There are three components to this technique; the ripple rendering script that handles rendering all the the ripples into a single texture, the ripple shader that is applied to the ripple particles being rendered, and the ripple include which you use in your water shader to sample the ripple texture.

Ripple Renderer Script

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DynamicRippleRenderer : MonoBehaviour
 public LayerMask layerMask;
 public int texSize = 2048;
 public float rippleDist = 64.0f;
 public Camera rippleCam;
 public RenderTexture targetTex;
Starting off the ripple renderer script we need a layer mask, the ripple particles will be on their own layer, I added a layer at the bottom of the layer list called "Ripples" and that is what I set this field to and set the ripple particles layer to as well.
texSize defines the size of the render texture, a multiple of 2 is usually used.
rippleDist is how large to actually make the renderable ripple area, ripples outside this area will not be rendered.
rippleCam is a camera that will be created later to render the ripples.
targetTex is a render texture that will be created later that the camera will render to.

 void Start() {

 void CreateTexture() {
  targetTex = new RenderTexture(texSize, texSize, 0, RenderTextureFormat.ARGBHalf, RenderTextureReadWrite.Linear);

 void CreateCamera() {
  rippleCam = this.gameObject.AddComponent(); // add a camera to this game object
  rippleCam.renderingPath = RenderingPath.Forward; // simple forward render path
  rippleCam.transform.rotation = Quaternion.Euler(90, 0, 0); // rotate the camera to face down
  rippleCam.orthographic = true; // the camera needs to be orthographic
  rippleCam.orthographicSize = rippleDist; // the area size that ripples can occupy
  rippleCam.nearClipPlane = 1.0f; // near clip plane doesn't have to be super small
  rippleCam.farClipPlane = 500.0f; // generous far clip plane
  rippleCam.depth = -10; // make this camera render before everything else
  rippleCam.targetTexture = targetTex; // set the target to the render texture we created
  rippleCam.cullingMask = layerMask; // only render the "Ripples" layer
  rippleCam.clearFlags = CameraClearFlags.SolidColor; // clear the texture to a solid color each frame
  rippleCam.backgroundColor = new Color(0.5f, 0.5f, 0.5f, 0.5f); // the ripples are rendered as overlay so clear to grey
  rippleCam.enabled = true;
In the Start function we will call the two functions that create the render texture and the camera.

In CreateTexture a new render texture is created and set to the targetTex variable. The format I'm using is ARGBHalf to get better quality but you can try ARGB32 to save some space or memory bandwidth or a single channel format if all you need is height.

In CreateCamera we add a new camera to this game object. (did I mention this script should be applied to an empty game object) There are lots of settings for the camera which I have hopefully explained in the comments, but the camera needs to be orthographic, pointed down, uses the target texture, only draws the "Ripple" layer, gets cleared to gray. It needs to be cleared to grey because the ripple shader is going to render as an overlay (light parts make things lighter and dark parts make things darker) This allows for normal map accumulation in a low range buffer (ARGB32) and is also order independent. Saves some memory and still looks pretty good. Alternatively you could clear to black, use an HDR buffer (ARGBHalf) and render additively with negative parts of the normal map subtracting below zero.
 void OnEnable() {

 void OnDisable() {
When the script is enabled or disabled the ripple feature in the shader will be toggled to save on performance.
 void LateUpdate() {

  Vector3 newPos =;
  Vector3 viewOffset =;

  if (Camera.main != null) {
   newPos = Camera.main.transform.position;
   viewOffset = newPos + Camera.main.transform.forward * rippleDist * 0.5f;

  newPos.x = viewOffset.x;
  newPos.z = viewOffset.z;
  newPos.y += 250.0f;
  float mulSizeRes = (float)texSize / ( rippleDist * 2f );
  newPos.x = Mathf.Round (newPos.x * mulSizeRes) / mulSizeRes;
  newPos.z = Mathf.Round (newPos.z * mulSizeRes) / mulSizeRes;
  this.transform.position = newPos;
  this.transform.rotation = Quaternion.Euler(90, 0, 0);

  Shader.SetGlobalTexture ("_DynamicRippleTexture", targetTex);
  Shader.SetGlobalMatrix ("_DynamicRippleMatrix", rippleCam.worldToCameraMatrix);
  Shader.SetGlobalFloat ("_DynamicRippleSize", rippleCam.orthographicSize);

In LateUpdate the ripple rendering camera will follow the main camera but will snap to the pixels in the ripple render texture. This will keep the pixels from swimming when you move the camera a little bit. The global shader variables for the ripple texture, ripple camera matrix, and the ripple camera size are also set.

The Ripple Shader

Shader "Custom/ParticleRipple"
 Properties {
 _MainTex ("Particle Texture", 2D) = "white" {}

  Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
  Blend DstColor SrcColor // overlay blend mode
  //Blend One One // additive blend mode
  ColorMask RGBA
  Cull Off
  Lighting Off
  ZWrite Off

  SubShader {
   Pass {

    #pragma vertex vert
    #pragma fragment frag

    #include "UnityCG.cginc"

    sampler2D _MainTex;
    float4 _MainTex_ST;
    struct appdata_t {
     float4 vertex : POSITION;
     float2 texcoord : TEXCOORD0;
     fixed4 color : COLOR;

    struct v2f {
     float4 vertex : SV_POSITION;
     float2 texcoord : TEXCOORD0;
     fixed4 color : COLOR;
    v2f vert (appdata_t v)
     v2f o;
     o.vertex = UnityObjectToClipPos(v.vertex);
     o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
     o.color = v.color;
     return o;

    fixed4 frag (v2f IN) : SV_Target
     half4 col = tex2D(_MainTex, IN.texcoord);
     col = lerp( half4( 0.5, 0.5, 0.5, 0.5 ), half4(,1.0 ), col.w * IN.color.w );   
     return col;
The ripple particle shader itself is very simple. It basically just samples a texture and blends it to gray based on the vertex alpha and the texture alpha. The RGB of the vertex color is not used. Blend DstColor SrcColor is to get the overlay blend mode.

The ripple texture itself has normal overlay packed into the red and green channel, foam overlay packed into the blue channel, and height / alpha overlay packed into the alpha channel. When using overlay blend mode it's good to use an alpha to blend to 0.5 because sometimes texture colors are not exact and can lead to seeing the edges of the ripple particle. This texture is uncompressed and non SRGB.

The Ripple Shader Include


sampler2D _DynamicRippleTexture;
float4x4 _DynamicRippleMatrix;
float _DynamicRippleSize;

float4 WaterRipples(float3 worldPos, float3 worldNormal) {
 float2 rippleCoords = mul(_DynamicRippleMatrix, float4(worldPos, 1)).xy * (1.0 / _DynamicRippleSize);
 half rippleMask = saturate((1.0 - abs(rippleCoords.x)) * 20) * saturate((1.0 - abs(rippleCoords.y)) * 20) * saturate(worldNormal.y);
 half4 ripples = tex2D( _DynamicRippleTexture, saturate(rippleCoords.xy * 0.5 + 0.5) ); = pow(, 0.45);

 ripples = ripples * 2.0 - 1.0;
 ripples *= rippleMask;

 return ripples;

The ripple include file has a function for sampling the ripple texture. The world position of the surface gets passed in and transformed by the ripple camera matrix. This puts the world position in -1 to +1 ripple coordinate space. A mask is made using this -1 +1 space and the surface world normal which also gets passed in. The ripples need to be faded out at the edges to avoid a harsh ripple cutoff. When sampling the ripple texture the -1 +1 space get converted to 0-1 coordinate space. For whatever reason, if your project is in Linear color space render textures always get sampled as SRGB textures regardless of what they are set to in script so the texture needs to be powed by 0.45. However, if your project is gamma space all render textures are sampled as Non-SRGB. Because the ripples were rendered as overlay the texture needs to be converted to -1 +1 color space. The texture then gets masked by the ripple Mask and returned to the shader to be used however.

Water Shader

The water texture is packed similarly to the ripple texture. Normal information is in the red and green channels, foam is in the blue channel, and height is in the alpha channel. This texture is uncompressed and non SRGB.
Shader "Custom/Water" {
 Properties {
  _Color ("Color Dark", Color) = (1,1,1,1)
  _Color2("Color Light", Color) = (1,1,1,1)
  _FoamColor("Foam Color", Color) = (1,1,1,1)
  _MainTex ("Albedo (RGB)", 2D) = "white" {}
  _Scrolling("Water Scrolling", Vector) = (0,0,0,0)
  _Glossiness ("Smoothness", Range(0,1)) = 0.5
  _Metallic ("Metallic", Range(0,1)) = 0.0
 SubShader {
  Tags { "RenderType"="Opaque" }
  LOD 200

  // Physically based Standard lighting model, and enable shadows on all light types
  #pragma surface surf Standard fullforwardshadows

  // Use shader model 3.0 target, to get nicer looking lighting
  #pragma target 3.0

  // multi compile for turning ripples on and off
  #pragma multi_compile _ DYNAMIC_RIPPLES_ON

  // the ripple include file that has the functions for sampling the ripple texture
  #include "RippleInclude.cginc"

  // the variables 
  sampler2D _MainTex;
  half _Glossiness;
  half _Metallic;
  fixed4 _Color; // light color of the water
  fixed4 _Color2; // light color of the water
  fixed4 _FoamColor; // the color of the foam
  float4 _Scrolling; // X and Y scrolling speed for the water texture, Z and W is scrolling speed for the second water texture

  struct Input {
   float2 uv_MainTex; // needed for the water texture coords
   float3 worldNormal; // needed for WorldNormalVector() to work
   float3 worldPos; // we need the world position fro sampling the ripple texture
   INTERNAL_DATA // also needed for WorldNormalVector() to work and any other normal calculations

  // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
  // See for more information about instancing.
  // #pragma instancing_options assumeuniformscaling
   // put more per-instance properties here

Just setting up all the variables and telling the shader what information it's going to need to pass to the surface function. Make sure to include RippleInclude.cginc and enable multi_compile for DYNAMIC_RIPPLES_ON! Hopefully the comments explain what everything is.

  void surf (Input IN, inout SurfaceOutputStandard o) {
   // Albedo comes from a texture tinted by color
   fixed4 c = tex2D (_MainTex, IN.uv_MainTex + frac(_Time.yy * _Scrolling.xy));
   fixed4 c2 = tex2D(_MainTex, IN.uv_MainTex * 1.3 + frac(_Time.yy *;

   // blend textures together
   c = (c + c2) * 0.5;

   // get the normal, foam, and height params
   half3 normal = half3(c.x, c.y, 1) * 2.0 - 1.0;
   normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
   half foam = smoothstep(0.4, 0.6, c.z * c.z );
   half height = c.w;

For the surface function start by sampling the textures and blending them together. Then generate the normal, foam, and height which are the 3 things that will drive the overall look of the water.

   // get the world normal, tangent, and binormal for masking the ripples and converting world normals to tangent normals
   float3 worldNormal = WorldNormalVector(IN, float3(0, 0, 1));
   float3 worldTangent = WorldNormalVector(IN, float3(1, 0, 0));
   float3 worldBinormal = WorldNormalVector(IN, float3(0, 1, 0));
   // sample the ripple texture
   half4 ripples = WaterRipples(IN.worldPos, worldNormal);

   // convert normal from world space to local space and add to surface normal
   // we only need the X and Y since this is an overlay for the existing water normals
   float2 rippleNormal = 0;
   rippleNormal.x = dot(worldTangent, half3(ripples.x, 0, ripples.y));
   rippleNormal.y = dot(worldBinormal, half3(ripples.x, 0, ripples.y));
   // add the normal foam and height contributions
   normal.xy += rippleNormal;
   foam += ripples.z * 5.0;
   height += ripples.w;

Put all the ripple related stuff inside the #ifdef so it won't activate unless the ripple renderer is enabled. We have to get the world normal, tangent and binormal for the surface in order to convert the ripple normals from world space to tangent space so they can be blended properly with the tangent space normals of the water surface. Then all the ripple information gets added to the water information, I boosted the foam a little bit since that's the driving visual for this toony water.

   // tighten the foam transition for a toony look
   foam = smoothstep(0.45, 0.55, foam);

   // modify the height ( which is used as a light dark color mask ) by the normal
   height = height + (normal.x * 0.5) - (normal.y * 0.5);

   // smooth step the height to get a tighter transition
   height = smoothstep(0.50, 0.55, height);

   // blend between the light and dark water color based on the height
   float3 waterColor = lerp( _Color.rgb, _Color2.rgb, height );

   // blend between the water color and the foam color
   waterColor = lerp(waterColor, _FoamColor.rgb, foam);

Once everything that will contribute to the water has contributed, we can blend everything together to get the final water color.

   // half the color to the albedo for shadow
   o.Albedo = waterColor * 0.5;
   // half the color to the emissive for consistancy
   o.Emission = waterColor * 0.5;
   o.Metallic = _Metallic;
   o.Smoothness = _Glossiness;
   // normal is flat but still needs to be set to get the INTERNAL_DATA
   o.Normal = float3(0,0,1);
   o.Alpha = c.a;
 FallBack "Diffuse"
Half the water color gets sent to the Albedo to receive lighting and shadow and the other half gets send to the Emission to give the water a consistent look. While the Normal is flat, it still needs to be set to keep the compiler from excluding things in the INTERNAL_DATA.

When making a ripple particle system be sure to set it's layer to "Ripple" so it will be drawn by the ripple camera. Also set the article rotation to 0, and the render type to "Billboard" or "Horizontal Billboard" so the normal overlay from the ripple texture will be in world space. The main camera in the scene should have the "Ripples" layer disabled in it's culling mask.


One slight annoyance in this technique is that it comes with a little overhead from the ripple camera. You can see that the ripple camera takes about 0.3 milliseconds to render, which is about the same amount of time as the main camera takes to render this entire simple scene. The actual work of rendering the ripple particles only takes 0.02 milliseconds (varying with the amount of ripples being rendered) but you always have this 0.25-ish amount of overhead when using a second camera. You can get this precious quarter millisecond back by adding a command buffer to the main camera and manually rendering the ripples. That's a topic for another time though.

1 comment: