This project is an example of lightweight procedural skybox and ocean shaders for browsers using the Three.js library. It is mainly designed for mobile, so don't expect too much in terms of graphics, but expect performance (I get a constant 120 FPS on a 2021 mid-range mobile at full resolution).
Daytime | Sunset | Moon rising |
I made a little demo so you can check how it runs and looks. It supports touch input too. Or you can watch the video.
The skybox is basically a cube around the player generated with 3 gradients:
- Density gradient, which is used to give a brighter color at horizon
- Luminosity gradient, which is used to estimate how much light reaches a given fragment
- Twilight gradient, which is used to roughly simulate Rayleigh scattering by multiplying the density and luminosity gradients, offering beautiful dawns and dusks
Density gradient | Twilight gradient | Luminosity gradient |
Twilight gradient with color |
The sun is drawn by raising the luminosity gradient to some power and multiplying the result so that we get a glow around the sun. The moon is essentially the same thing, but with the inversed luminosity gradient, a higher power and higher multiplier so that the glow is more subtle.
The sun | The moon |
The stars are generated by picking random 3d unit vectors mapped to a cube grid alongside with a random offset, size, and color. These vales are packed inside a texture and sent to the gpu. On the gpu, we map the corresponding cube grid values and compute the stars in a similar way to how the sun and moon were calculated. The moon and the stars are only drawn if the sky luminosity is low enough.
Stars | Stars with unclamped distance |
The water is generated using 3 materials:
- Surface material which is used to render the waviness effect
- Water volume material which is used to avoid skybox rendering underwater
- Object material which is used to render textured geometry
The entire ocean geometry is a giant box that stays centered with the player camera on the xz plane. The ocean surface has a side length of only 4000m to avoid float precision issues. Because the ocean is only 12 vertices and 12 triangles, it is very cheap geometry-wise. It is made from 2 meshes: the surface plane and the volume box, hence the need for surface and volume materials.
The ocean surface seems wavy even though it is geometrically completely flat, thanks to 2 scrolling different normal maps generated with Blender's ocean modifier (tutorial). In an earlier iteration, I also implemented parallax mapping which improved the waviness efect considerably, but I found that it drops the performance way too much on mobile.
The sea floor is procedurally generated using Perlin noise. I got heavily inspired by this video to generate sea floor height. Also, for performance reasons, it is divided into tiles (or chunks) and only tiles close enough to the player are visible.
I highly recommend using Visual Studio Code to edit projects like this one. If you have never used Three.js before, I recommend installing it with Node.js using the npm install three
command in the terminal of your project folder, inside VS Code. This will ensure you get the latest version of Three.js and that the TypeScript file (responsible for coding suggestions and tooltips) can get linked to your project. To link the TS file with the project put this in the head of your index.html:
<script type="importmap">
{
"imports":
{
"three": "./path/to/three.module.js"
}
}
</script>
If you have done it properly, you shold get coding suggestions when you write something three.js-related in your scripts.
You can then import Three.js in your JavaScript files using:
import * as THREE from "three";
Or:
import { first, second, third } from "three";
To run the project you will need an HTTP server, and the Live Server extension will help you create a local server directly from VS Code.
Coding shaders with code highlights improves readability and workflow, so I highly recommend using Shader languages support for VS Code together with Comment tagged templates. Here is an example of how to use them:
export const vertexShader =
/*glsl*/`
varying vec2 _uv;
void main()
{
_uv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const fragmentShader =
/*glsl*/`
varying vec2 _uv;
void main()
{
gl_FragColor = vec4(_uv, 0.5, 1.0);
}
`;
I made this project because I wanted to make my own game, but quickly realized how difficult it is to do that by myself. I posted it here because I think it has reached a point which makes it worth sharing.