pingpoli.de



Sparrow WebGL Devlog 11: Water

Implementing animated water with reflections and refractions in JavaScript and WebGL

08.10.2023 - 10:13


Water is one of the most challenging and rewarding elements to render in computer graphics, especially in real-time. Good-looking water is visually appealing and I've always been fascinated by the challenge of rendering realistic water. This is why I have worked on adding water to my WebGL engine for the last few weeks. In this post, I'll discuss some of the different techniques that can be used to render water in WebGL and what I did in my implementation.

Basic Water


There are many ways you can render water in real-time graphics. Depending on the look of your scene, it can range from easy to very complicated. Probably the most straightforward way to add water is just drawing a big blue quad, which could be enough for something like a Minecraft environment.

Stepping up from a blue quad would be adding and looping through a seamless animated water texture. Flat water, however, doesn't look very interesting, so it would be nice to add some waves too. The first option is to subdivide the quad into a grid of much smaller quads and then animate the y position of the vertices in the vertex shader. This approach has the advantage that the elevation of the water surface actually changes. The disadvantage, however, is that depending on the size and scale of your water, you might need a lot of vertices for this to look good.

The second option - and the one I generally prefer - is to add waves with a normal map instead. This does not actually change the elevation of the water, it's always perfectly flat. A normal map only changes how light interacts with the surface so it looks like there are waves. The normals are calculated and animated on a per-fragment level, which means you can easily add a lot of small waves with minimal performance impact.

In the past, I either used sinus patterns or water textures and normal maps from the internet, which is fine for learning and testing. However, when I worked on my (sadly yet unreleased) indie game, I couldn't use random textures from the internet. Instead, I created a procedural algorithm to create seamless animated water textures and normal maps. The trick for creating these textures is periodic 3D Perlin noise. You can imagine periodic 3D noise as a cube that tiles 3D space perfectly and all faces of the cube match with their neighbors. You can then use the values of the Perlin noise as the water elevation, create a mesh, and calculate the normals, which are then encoded in the RGB values of the texture. Similarly, you can mix a few different tones of blue to create a color texture. So far, I have only implemented this in C++ and am just using the images in JavaScript and WebGL. However, the textures are quite big, which is a much bigger problem in WebGL because of limited internet speeds. In the future, I might try to convert this approach to JavaScript and generate the water textures on the fly if this can be done faster than the average download speed for the large textures.

Reflections and Refractions


One of the most iconic aspects of water is its reflectivity: Mountains and trees reflected in a still glacial lake, the colors of the sunset reflected in the ocean, or just your reflection in the local pond. Reflections are amazing. But how can you render reflections without ray tracing? Arbitrary reflections are very difficult to implement with rasterization APIs like WebGL and are alongside better shadows the biggest benefit of ray-tracing technology. The best option is a pre-baked reflectivity cube map, but this cannot reflect dynamic real-time objects like the player character for example.

However, large water surfaces are a lucky exception. When you think about it, the reflection of an object is the same as if it's viewed from below the water surface.

This means you can just reflect the camera on the water surface and render the scene to a texture from the new camera position. Everything below the water surface is discarded because it cannot reflect anyway. The same trick also works for refractions. The scene is rendered to another texture from the normal camera position with everything above the water surface cut off.

However, this adds two additional render passes. You can render them in a lower resolution and with fewer details, but they can add a significant amount of time to your frame time depending on your scene's complexity.

When the water surface is drawn you can distort the reflections and refractions with your normal map so they are also affected by the water ripples.

The reflection and refraction render passes in the top right and the final water:


Implementation


Most tricks related to water like normal map waves or mirroring the camera for reflections are not complicated on their own. However, to get good-looking water, you need a lot of small effects, and the combination of them all can become a lot more tricky. There are a lot of knobs you have to tweak and get at least within the right range before the water starts to look good. If one value is too far off, it can look quite bad. However, you can use this to your advantage too. By setting some parameters to strange values, I was able to create fairly good-looking lava, even though the texture used to render the lava is still a blue water texture.



My WebGL water implementation is a big improvement compared to the OpenGL version it is based on. When you want to use water, you only have to create a water object and add all the objects you want to reflect and refract. All the complicated shader switches, uniforms, camera reflections, and more are handled internally. This was one of the reasons why I needed the shader modules and matrix slots I talked about in the last refactoring blog post. Even though using the water class on the outside is very clean and simple, the engine-internal code could still use some work to reduce complexity and make it more maintainable in the future.

var water = new Sparrow.Water( engine , { /* water options */ } );
water.addObject( cube );
water.addObject( model );




Rendering water in WebGL or other graphic APIs can range from quite simple to very complicated because there are so many different ways to do it and so many small details to get right. But I hope this post has given you a good idea of what is involved. I'm happy with the current state of my water implementation, but I want to add additional controls to make it more customizable for different scenarios in the future.




by Christian - 08.10.2023 - 10:13


Comments

Social Media:  © pingpoli.de 2020 All rights reservedCookiesImpressum generated in 41 ms