This is the second tutorial in a series about creating the appearance of flowing materials. It comes after Texture Distortion and is about aligning patterns with the flow, instead of distorting them.

This tutorial is made with Unity 2017.4.4f1.

Create a material with this shader, using the same settings as the distortion material, except now using the ripple pattern and with tiling set to 1. When applied to the quad, we end up with simply the ripple pattern. The pattern is aligned to correspond with a flow along the V axis. The default is that it flows up, but as the pattern is symmetrical in works in the opposite direction too.

We'll start by reducing the surf function to just sampling the derivative-height data, using the squared height for albedo and setting the normal vector.

In this tutorial we'll create a different flow shader. Instead of distorting a texture, it will align it with the flow. Duplicate the DistortionFlow shader and rename it to DirectionalFlow . We'll leave all parameters the same, except that we won't use the jump parameters, so remove those. Also, we won't bother with an albedo texture, so the derivative-height data can be supplied via the main texture. And we won't need noise to offset a phase blend, so we're only interested in the RG channels of the flow map.

There is now a clear visual direction, even when there is no animation. However, the pattern isn't aligned with the flow, so the implied direction is incorrect. We have to use a different approach if we want to visualize proper ripples.

Import the texture, make sure that it's not in sRGB mode, and use it for the pattern of the distortion effect.

The distortion effect works best for either very turbulent or very sluggish flows. It doesn't work well for more calm flows that manifest clear ripple patterns, because such ripples have a clear direction to them. They're anisotropic. Here is an alternative water texture containing such ripples. It's made the same way as the other texture, but with a different pattern, and the derivatives are scaled by a factor of 0.025 relative to the height data.

While the illusion of flow can be convincing, the patterns created by distorting an isotropic pattern do not look like real water. This is most obvious when observing a still image of the effect, instead of an animation. You can't really tell what the flow direction is supposed to be. That's because the alignment of the waves and ripples is wrong. They're elongated along the flow direction, instead of being perpendicular to it.

When distorting a texture to simulate flow, it can end up getting stretched or squashed in any direction. This means that it must look good no matter how it gets deformed. This is only possible with isotropic patterns. Isotropic means that the image appears similar in all directions. This is the case for the water texture that we used in the previous tutorial .

Unfortunately—like with the distortion shader—we get a heavily distorted an unusable result. Rotating each fragment independently rips the pattern apart. This wasn't a problem when we used a uniform direction. We'll have to come up with a solution.

Retrieve the speed data and pass it to the function. But before that, let's also modulate it with the Flow Strength shader property. The distortion shader uses this property to control the amount of distortion, but it also affects the animation speed. While we don't really need to do this in the directional shader, it makes it easier to configure the exact same speed for both shaders. That is convenient when comparing the effects.

But because we normalize the flow vectors, we lose the speed information. Fortunately, we stored the speed in the flow map's B channel, so we can pass that to DirectionalFlowUV as well. Adjust and rename its parameter for that, then modulate the time with the speed before adding it.

The next step is to use the flow map to control the rotation. Sample the map and supply its data to DirectionalFlowUV .

Now that the derivatives rotate as well, the colors change too. At 90° rotation, red and green have swapped. Now we can restore the original color.

Supply a variable for this new output, then use it to rotate the derivatives that we sample later, with another matrix multiplication.

To keep the lighting correct, we have to rotate the normal vectors, which is the same as rotating the derivatives. As DirectionalFlowUV is responsible for the rotation, it makes sense that it also gives us the matrix to use for vector rotation. Let's make that possible by adding an output parameter to it. In this case, we do need the proper clockwise rotation matrix.

We still see the same colors. This would be correct if it was just color data. But these are derivatives, which represent surface curvature. When the surface rotates, so should its curvature, but that's not happening. This means that the lighting is affected by changes in position, but not rotation.

At zero rotation—due to the anisotropic pattern—we mostly see green, with little red. Blue can be ignored, because that's the height.

Although the pattern rotates correctly, there is something wrong with the normal vectors. This might not be immediately obvious, but it becomes glaring once you pay attention to how the surface should look. It's easiest to visualize by using the derivatives to colorize the material.

The rotation works as it should. The animation also reveals that the rotation is centered on the bottom left of the quad, which corresponds to the origin of the UV space. While we could offset the rotation so it is centered on another point, this isn't necessary.

To make sure that all flow vectors are now converted to correct rotations, let's rotate based on time, using `[[sin time],[cos time]]`. Set the material's speed to zero so the only movement is causes by the rotation, otherwise it's hard to interpret the movement.

We get a counterclockwise rotation instead. That's because we're not rotating the pattern itself, but the UV coordinates. To get the correct result, we have to rotate them in the opposite direction, just like we have to subtract the time to scroll in a positive direction. So we have to use the counterclockwise rotation matrix after all.

Because our flow map doesn't contain vectors of unit length, we have to normalize them first. Then construct the matrix using that direction vector, via the float2x2 construction function. Multiply that matrix with the original UV coordinates, using the mul function. After that's done the time offset and tiling should be applied.

The Rendering 1, Matrices tutorial defined a 2D rotation matrix as `[[cos theta,-sin theta],[sin theta,cos theta]]`, but that represents a counterclockwise rotation. As we need a clockwise rotation, we have to flip the sign of `sin theta`, which gives us the final rotation matrix `[[y,x],[-x,y]]`.

To rotate UV coordinates, we need a 2D rotation matrix, as described in the Rendering 1, Matrices tutorial. If the flow vector `[[x],[y]]` is of unit length, then it represents a point on the unit circle. As `[[0],[1]]` corresponds to no rotation, the X coordinate represents the sine of some rotation angle `theta` (theta), while the Y coordinate represents the cosine of the same angle. Also, the flow vector `[[1],[0]]` represents flow in the U direction, to the right. So the flow vector can be interpreted as `[[sin theta],[cos theta]]` for a clockwise rotation.

Use this function in our shader to get the final flow UV coordinates. We'll supply it with float(0, 1) as the flow vector—`[[0],[1]]` representing the default orientation—the tiling property, and the time modulated by the speed. Then we use the result to sample the pattern.

We begin by simply scrolling up, moving the pattern in the positive V direction, by subtracting the time from the V coordinate. Then apply the tiling.

Aligning a texture with a direction is a matter of transforming UV coordinates. This is a fundamental operation useful for flow simulation, so we'll add a function for that to our Flow include file. Name it DirectionalFlowUV . It needs the original UV coordinates and a flow vector as parameters. Also give it tiling and time parameters, similar to the FlowUVW function. As it won't perform deformation that requires a time reset, no phase data nor a time-blending weight are involved.

Now that we have an anisotropic pattern, we need to find a way to align it with a flow direction. We'll first try this with a fixed and controlled direction, and once that's working move on to using the flow map.

Tiled Flow

The distortion approach had a temporal problem, because we were forced to reset the distortion at some point, to keep the pattern intact. We hid that by blending between two different phases across time. The directional approach has this problem too, but it's of a different nature. While the pattern breaks up more as time progresses, it's already destroyed at time zero, without any animation. So resetting time won't help.

Distortion without any movement, speed 0.

Instead, there is a discontinuity where there is a difference in orientation. This is a spatial problem, not a temporal one. The solution is once again to hide the problem by blending. But now we have to blend in space, not time. And we're dealing with a 2D surface, not with 1D time, so it will be more complex.

What we'll do is try to find a compromise between the perfect result of a uniform flow and the desired result of using a different flow direction per fragment. That compromise is to divide the surface into regions. We'll simply use a grid of square tiles. Each tile has a uniform flow, so won't suffer from any distortion. Then we'll blend each tile with its neighbors, to hide the discontinuities between them. This approach was first publicly described by Frans van Hoesel in 2010, as the Tiled Directional Flow algorithm. We'll create a variant of it.

Flow Grid To split the surface into tiles, we need to decide on a grid resolution. We'll make that configurable via a shader property, using a default of 10. _Tiling ("Tiling", Float) = 1 _GridResolution ("Grid Resolution", Float) = 10 Grid resolution set to 10. Cutting the flow map into tiles can be done by multiplying the UV used for sampling the map by the grid resolution, then discarding the fractional part. That gives us tiles with fixed UV coordinates, from 0 up to the grid resolution. To convert that back to a range from 0 to 1, divide by the tiled coordinates by the grid resolution. Functions `x` and `floor(10x)/10`. float _Tiling, _GridResolution, _Speed, _FlowStrength; float _HeightScale, _HeightScaleModulated; … void surf (Input IN, inout SurfaceOutputStandard o) { float time = _Time.y * _Speed; float2x2 derivRotation; float2 uvTiled = floor(IN.uv_MainTex * _GridResolution) / _GridResolution; float3 flow = tex2D(_FlowMap, uvTiled ).rgb; … } One flow direction per grid cell.

Blending Cells At this point we have clearly distinguishable grid cells, each containing an undistorted pattern. The next step is to blend them. This requires us to sample multiple cells per fragment. So let's move the code to compute the derivative plus height data to a new FlowCell function. Initally, all it needs are the original UV coordinates and the scaled time. float3 FlowCell (float2 uv, float time) { float2x2 derivRotation; float2 uvTiled = floor( uv * _GridResolution) / _GridResolution; float3 flow = tex2D(_FlowMap, uvTiled).rgb; flow.xy = flow.xy * 2 - 1; flow.z *= _FlowStrength; float2 uvFlow = DirectionalFlowUV( uv , flow, _Tiling, time, derivRotation ); float3 dh = UnpackDerivativeHeight(tex2D(_MainTex, uvFlow)); dh.xy = mul(derivRotation, dh.xy); return dh; } void surf (Input IN, inout SurfaceOutputStandard o) { float time = _Time.y * _Speed; //float2x2 derivRotation; //… //dh.xy = mul(derivRotation, dh.xy); float2 uv = IN.uv_MainTex ; float3 dh = FlowCell(uv, time); fixed4 c = dh.z * dh.z * _Color; … } Sampling a different cell can be done by adding an offset before flooring the UV coordinates to find the fixed flow. Add a parameter for that to FlowCell . float3 FlowCell (float2 uv, float2 offset, float time) { float2x2 derivRotation; float2 uvTiled = floor(uv * _GridResolution + offset ) / _GridResolution; … } Let's first try an offset of one unit in the U dimension. That means that we end up sampling one cell to the right, visually shifting the flow data one step to the left. float3 dh = FlowCell(uv, float2(1, 0), time); Cells offset one step to the right. To blend the cells horizontally, we have to sample both the original and the offset cell per tile. We'll designate the original data as A and the offset data as B. First, let's just average them, giving each a weight of 0.5 and summing that. float3 dhA = FlowCell(uv, float2(0, 0), time); float3 dhB = FlowCell(uv, float2(1, 0), time); float3 dh = dhA * 0.5 + dhB * 0.5; Averaged cells. Each tile now contains the same amount of A and B everywhere. Next, we have to transition from A to B along the U dimension. We can do this by linearly interpolating between A and B. The fractional part of the scaled U coordinate is the value `t` that we can use to interpolate the weights. Let's visualize it, by using it as the albedo. float3 dhA = FlowCell(uv, float2(0, 0), time); float3 dhB = FlowCell(uv, float2(1, 0), time); float t = frac(uv.x * _GridResolution); float3 dh = dhA * 0.5 + dhB * 0.5; fixed4 c = dh.z * dh.z * _Color; o.Albedo = t; // c.rgb ; Interpolation basis. The A cell starts at full strength on the left side of each tile, where `t` is zero. And it should be gone when `t` reaches 1 on the right side. So the weight of A is `t-1`. B is the other way around, so its weight is simply `t`. float3 dhA = FlowCell(uv, float2(0, 0), time); float3 dhB = FlowCell(uv, float2(1, 0), time); float t = frac(uv.x * _GridResolution); float wA = 1 - t; float wB = t; float3 dh = dhA * wA + dhB * wB ; fixed4 c = dh.z * dh.z * _Color; o.Albedo = c.rgb ; Horizontally interpolated cells.

Overlapping Cells Although interpolation between the cells should eliminate the horizontal discontinuity, we can still see lines that make the grid obvious. These lines are artifacts caused by the sudden jump of the UV coordinates used to sample the flow map. The suddenly large UV delta triggers the GPU to select a different mipmap level along the grid line, corrupting the flow data. While we could eliminate these artifacts by eliminating mipmaps, this isn't desirable. It would be better if we could hide them some other way. We can hide the lines by making sure that the cell weights are zero at their edges, which is where the artifact lines are. But the weight function `t` reset each tile, so we have sawtooth waves that are both 0 and 1 on the edge. Thus although one side is always fine, the other suffers from the artifacts. Sawtooth waves are both 0 and 1 at grid lines. To solve this problem, we have to overlap the cells. That way we can alternate between them and use one to hide the artifacts of the other. First, halve the offset of the second cell. It's most convenient to do this inside FlowCell , so we can keep using whole numbers for the offset argument. The shader compiler will get rid of the extra calculations away. float3 FlowCell (float2 uv, float2 offset, float time) { offset *= 0.5; … } Overlapping cells. The horizontal cells are now overlapping, occurring at twice the frequency than the tiles that we actually use. Next, we have to correctly blend between the cells again. This is done by replacing `t` with `|2t-1|`, turning it into a triangle wave that is zero on both sides of a tile and 1 in the middle. Triangle waves always have the same value at grid lines, either 0 or 1. The result of this change is that the weight of A is now zero on both sides of each tile. It is at full strength halfway. And it's the other way around for B, which has zero weight in the middle of each tile. And because we offset B by only half a tile now, that's exactly where its artifact line would've shown up. float t = abs(2 * frac(uv.x * _GridResolution) - 1) ; float wA = 1 - t; float wB = t; Cells blended horizontally without artifacts. Now that we can blend without artifacts, let's also do this vertically. Add cells C and D, both offset by one step in the V dimension relative to A and B. float3 dhA = FlowCell(uv, float2(0, 0), time); float3 dhB = FlowCell(uv, float2(1, 0), time); float3 dhC = FlowCell(uv, float2(0, 1), time); float3 dhD = FlowCell(uv, float2(1, 1), time); float t = abs(2 * frac(uv.x * _GridResolution) - 1); float wA = 1 - t; float wB = t; float wC = 1 - t; float wD = t; float3 dh = dhA * wA + dhB * wB + dhC * wC + dhD * wD ; The weights of A and B must now be multiplied by `1-t` in the V dimension, and by `t` for C and D. Each dimension gets its own `t` value, which we can do by changing it to float2 and deriving it from both UV coordinates. float2 t = abs(2 * frac( uv * _GridResolution) - 1); float wA = (1 - t.x) * (1 - t.y) ; float wB = t.x * (1 - t.y) ; float wC = (1 - t.x) * t.y ; float wD = t.x * t.y ; Blending in both dimensions.

Sampling At Cell Centers Currently, we're sampling the flow at the bottom left corner of each tile. But that doesn't line up with the way that we blend the cells. The result is a misaligned blend between flow data, which makes the grid more obvious than it should be. Instead, we should sample the flow at the center of each cell, where its weight is 1. In the case of cell A, that's in the middle of each tile, so its sample point needs to be shifted there. The same is true for B, at least in the V dimension. Because B is already offset by half a tile in the U dimension, it doesn't need a horizontal shift. And C and D are fine in the V dimension, but C needs a horizontal shift. In general, we have to shift half a tile when there isn't an offset, and vice versa. We can conveniently do this in FlowCell by taking 1 minus the unscaled offset and halving that. Then add that to the UV coordinates after flooring, before the division. float2 shift = 1 - offset; shift *= 0.5; offset *= 0.5; float2 uvTiled = ( floor(uv * _GridResolution + offset) + shift) / _GridResolution; Centered flow samples. We're now correctly using the flow data, but how accurate we are depends on the grid resolution. The higher the resolution, the smoother the flow curves. But we cannot make the resolution too high, because that doesn't allow room for the ripple pattern to show up. Tiling 1, grid resolution 30. Increasing the tiling allows the resolution to go higher, but also makes the ripples smaller. You have to find a balance that works best for each situation. For example, a tiling of 5 combined with a grid resolution of 30 works well for the images in this tutorial. That makes it possible to see the flow, without the ripples becoming to small to see. Tiling 5, grid resolution 10 and 30.