I'm working on a top-down tile-based fairly graphical 2D game inspired by Dwarf Fortress. I'm at the point of implementing a river in the game world, which covers a number of tiles, and I've calculated the flow direction for each tile, as shown below by the red line in each tile.

Example of river tiles with directions

For reference of the graphical style, here's how my game looks currently:

In-game shot of graphical style

What I need is some technique to animate water flowing in each of the river tiles, so that the flow blends into the surrounding tiles so that tile edges are not apparent.

The closest example I have found to what I am after is described at http://www.rug.nl/society-business/centre-for-information-technology/research/hpcv/publications/watershader/ but I'm not quite at the point of being able to understand what's going on in it? I have enough of an understanding of shader programming to have implemented my own dynamic lighting but I can't quite get my head around the approach taken in the linked article.

Could someone explain how the above effect is achieved or suggest another approach to get the result I want? I think part of the above solution is overlapping the tiles (though I'm not sure in which combinations) and rotating the normal map used for the distortion (again no idea on specifics) and past that I'm a bit lost, thanks for any help!

  • Do you have a visual target for the water itself? I notice the link you cite is using normal maps for specular reflection - something that might not blend quite right in the flat/cartoon-style art direction you've shown. There are ways to adapt the technique to other styles, but we need some guidelines so we know what to aim for. – DMGregory Nov 2 '17 at 16:22
  • You can use your flow solution as a gradient for particles that you let loose in the stream. Probably expensive though, as you would need a lot of them. – Bram Nov 2 '17 at 17:21
  • I wouldn't solve this with a shader, I would do it the simple way that was used over centuries, just draw it and have like 8 different drawings of the water and also 8 different drawings of the water hitting the shore. Then add a color overlay if you want to have different terrain and add random at like sprinkles stones, fish or whatever into the river. Btw with 8 different I meant for every 45 degrees in rotation to have a different sprite – Yosh Synergi Nov 3 '17 at 5:10
  • @YoshSynergi I want the river flow to be in any direction rather than 8 directions, and I want to avoid having visible boundaries between the tile edges, similar to the result achieved in the linked shader – Ross Taylor-Turner Nov 3 '17 at 10:29
  • @Bram that is an option I'm considering that I could achieve, but also think it will need too many particles to be effective, particular when the camera is zoomed out a lot – Ross Taylor-Turner Nov 3 '17 at 10:30
up vote 8 down vote accepted

I didn't have any tiles handy that looked good with distortion, so here's a version of the effect I mocked up with these Kenney tiles instead:

Animation showing flowing water in tilemap.

I'm using a flowmap like this, where red = rightward flow and green = upward, yellow being both. Each pixel corresponds to one tile, with the bottom-left pixel being the tile at (0, 0) in my world coordinate system.

8x8

And a wave pattern texture like this:

enter image description here

I'm most familiar with Unity's hlsl/CG-style syntax, so you'll need to adapt this shader a little bit for your glsl context, but it should be straightforward to do.

// Colour texture / atlas for my tileset.
sampler2D _Tile;
// Flowmap texture.
sampler2D _Flow;
// Wave surface texture.
sampler2D _Wave;

// Tiling of the wave pattern texture.
float _WaveDensity = 0.5f;
// Scrolling speed for the wave flow.
float _WaveSpeed  = 5.0f;

// Scaling from my world size of 8x8 tiles 
// to the 0...1
float2 inverseFlowmapSize = (float2)(1.0f/8.0f);

struct v2f
{
    // Projected position of tile vertex.
    float4 vertex   : SV_POSITION;
    // Tint colour (not used in this effect, but handy to have.
    fixed4 color    : COLOR;
    // UV coordinates of the tile in the tile atlas.
    float2 texcoord : TEXCOORD0;
    // Worldspace coordinates, used to look up into the flow map.
    float2 flowPos  : TEXCOORD1;
};

v2f vert(appdata_t IN)
{
    v2f OUT;

    // Save xy world position into flow UV channel.
    OUT.flowPos = mul(ObjectToWorldMatrix, IN.vertex).xy;

    // Conventional projection & pass-throughs...
    OUT.vertex = mul(MVPMatrix, IN.vertex);
    OUT.texcoord = IN.texcoord;
    OUT.color = IN.color;

    return OUT;
}

// I use this function to sample the wave contribution
// from each of the 4 closest flow map pixels.
// uv = my uv in world space
// sample site = world space        
float2 WaveAmount(float2 uv, float2 sampleSite) {
    // Sample from the flow map texture without any mipmapping/filtering.
    // Convert to a vector in the -1...1 range.
    float2 flowVector = tex2Dgrad(_Flow, sampleSite * inverseFlowmapSize, 0, 0).xy 
                        * 2.0f - 1.0f;
    // Optionally, you can skip this step, and actually encode
    // a flow speed into the flow map texture too.
    // I just enforce a 1.0 length for consistency without getting fussy.
    flowVector = normalize(flowVector);

    // I displace the UVs a little for each sample, so that adjacent
    // tiles flowing the same direction don't repeat exactly.
    float2 waveUV = uv * _WaveDensity + sin((3.3f * sampleSite.xy + sampleSite.yx) * 1.0f);

    // Subtract the flow direction scaled by time
    // to make the wave pattern scroll this way.
    waveUV -= flowVector * _Time * _WaveSpeed;

    // I use tex2DGrad here to avoid mipping down
    // undesireably near tile boundaries.
    float wave = tex2Dgrad(_Wave, waveUV, 
                           ddx(uv) * _WaveDensity, ddy(uv) * _WaveDensity);

    // Calculate the squared distance of this flowmap pixel center
    // from our drawn position, and use it to fade the flow
    // influence smoothly toward 0 as we get further away.
    float2 offset = uv - sampleSite;
    float fade = 1.0 - saturate(dot(offset, offset));

    return float2(wave * fade, fade);
}

fixed4 Frag(v2f IN) : SV_Target
{
    // Sample the tilemap texture.
    fixed4 c = tex2D(_MainTex, IN.texcoord);

    // In my case, I just select the water areas based on
    // how blue they are. A more robust method would be
    // to encode this into an alpha mask or similar.
    float waveBlend = saturate(3.0f * (c.b - 0.4f));

    // Skip the water effect if we're not in water.
    if(waveBlend == 0.0f)
        return c * IN.color;

    float2 flowUV = IN.flowPos;
    // Clamp to the bottom-left flowmap pixel
    // that influences this location.
    float2 bottomLeft = floor(flowUV);

    // Sum up the wave contributions from the four
    // closest flow map pixels.     
    float2 wave = WaveAmount(flowUV, bottomLeft);
    wave += WaveAmount(flowUV, bottomLeft + float2(1, 0));
    wave += WaveAmount(flowUV, bottomLeft + float2(1, 1));
    wave += WaveAmount(flowUV, bottomLeft + float2(0, 1));

    // We store total influence in the y channel, 
    // so we can divide it out for a weighted average.
    wave.x /= wave.y;

    // Here I tint the "low" parts a darker blue.
    c = lerp(c, c*c + float4(0, 0, 0.05, 0), waveBlend * 0.5f * saturate(1.2f - 4.0f * wave.x));

    // Then brighten the peaks.
    c += waveBlend * saturate((wave.x - 0.4f) * 20.0f) * 0.1f;

    // And finally return the tinted colour.
    return c * IN.color;
}

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.