New portfolio
Simon Potocnak
Atmosphere
The title page uses Eric Bruneton’s atmospheric scattering model, implemented in Three.js by shotamatsuda.
The atmosphere is rendered as a post-processing effect and depth-masked with the rest of the scene, making it visible within the world. The main issue with this implementation was that the camera had to be moved into the atmospheric coordinate system. As a result, it was positioned extremely far from the world origin. This caused floating-point precision errors, preventing the rest of the geometry from rendering correctly. Some post-processing effects also did not behave as expected.
To solve this, the rendering is split into two passes. The first pass renders the atmosphere using a dedicated atmospheric camera that exists in the ECEF coordinate space. This camera is assigned to layer 1, meaning it only renders scene objects that share the same layer. For this reason, the SkyMesh full-screen quad is also assigned to layer 1.
Putting everything together, the rendering code looks like this.
// renders the scene geometry (layer 0)
const geometryPass = new RenderPass(this._scene, this._camera);
geometryPass.clear = true;
this._effectComposer.addPass(geometryPass);
// renders the atmosphere (layer 1)
const skyPass = new RenderPass(this._scene, atmosphere.GetAtmosphereCamera());
skyPass.clear = false;
this._effectComposer.addPass(skyPass); Unfortunately, I was unable to find an efficient and straightforward way to incorporate the atmosphere into Three.js’s SSR model so that it would be reflected on the ocean.
Ocean
The ocean is based on a custom version of the Three.js Water module. It is largely identical to the original implementation, with one key difference; it accepts a transmittance texture as an additional parameter. This allows the sun’s irradiance to be attenuated based on its elevation and azimuth, producing the characteristic reddish appearance near the horizon seen in the real world.
To sample the transmittance map, we first need to compute the observer’s height within the atmosphere and the sun’s zenith angle (represented as the cosine of the angle between the local zenith and the sun direction). These are the parameters required to sample the transmittance texture. Once they are computed, we sample the transmittance texture T and multiply the result by the incoming solar irradiance.
Following Eric Bruneton’s implementation we ‘ll first implement some helper functions that ensure we won’t recieve NaNs or other unpleasant values.
/*
Code from
*/
float SafeSqrt(float a) { return sqrt(max(a, 0.0)); }
float ClampDistance(float d) { return max(d, 0.0); }
float ClampRadius(float bottom, float top, float r) {
return clamp(r, bottom, top);
}
The first step in retrieving transmittance is converting the radius (r) and view zenith angle (mu) into texture coordinates (u, v). We can then simply use these coordinates to look up the optical depth (transmittance) for the point we are shading. In other words, we can look up how much of each wavelength of light is attenuated due to the participating media in the atmosphere.
The function that calculates the u, v coordinates accepts three parameters.
vec2 transmittanceWH: width and height of the transmittance texturefloat r: radius from the center of the Earth to the point we are shadingfloat mu: cosine of the view zenith angle. The zenith is a vector pointing straight above the measuring instrument (the camera in our case)
The rest of the function is a copy-paste of Bruneton’s implementation, and I believe he explains it much better than I ever could.
float GetTextureCoordFromUnitRange(float x, int textureSize) {
return 0.5 / float(textureSize) + x * (1.0 - 1.0 / float(textureSize));
}
float DistanceToTopAtmosphereBoundary(float radiusTop, float r, float mu) {
float discriminant = r * r * (mu * mu - 1.0) + radiusTop * radiusTop;
return ClampDistance(-r * mu + SafeSqrt(discriminant));
}
/**
Based on the sun directions retrieve the correct UV coordinates to sample the
precomputed transmittance texture
*/
vec2 GetTransmittanceTextureUvFromRMu(vec2 transmittanceWH, float r, float mu) {
const float radiusBottom = 6360.0; // from core to the surface
const float radiusTop = 6460.0; // from surface to the "end" of the atmosphere
// Keep the radius inside the valid atmosphere range.
r = ClampRadius(radiusBottom, radiusTop, r);
mu = clamp(mu, -1.0, 1.0);
float H = SafeSqrt(radiusTop * radiusTop - radiusBottom * radiusBottom);
float rho = SafeSqrt(r * r - radiusBottom * radiusBottom);
float d = DistanceToTopAtmosphereBoundary(radiusTop, r, mu);
float dMin = radiusTop - r;
float dMax = rho + H;
float xMu = (d - dMin) / max(dMax - dMin, 1e-6);
float xR = rho / max(H, 1e-6);
xMu = clamp(xMu, 0.0, 1.0);
xR = clamp(xR, 0.0, 1.0);
return vec2(GetTextureCoordFromUnitRange(xMu, int(transmittanceWH.x)),
GetTextureCoordFromUnitRange(xR, int(transmittanceWH.y)));
} Now that we have all the functions we can wire them together inside a single GetTransmittance function that will sample the transmittance for the point that we are currently shading.
Note: This function assumes that:
- The world-space position of the fragment is stored in
worldPositionand passed from the vertex shader.- The transmittance texture is named
transmittanceand is of typesampler2D.If your shader uses different names, replace them accordingly.
vec3 GetTransmittance() {
// 6360 is a distance from the core of the planet to the surface
vec3 worldPosInAtmosphere =
worldPosition.xyz * 10.0 + vec3(0.0, 6360.0, 0.0);
// `r` parameter, distance from earth`s core to the shaded point
float radius = length(worldPosInAtmosphere);
if (radius <= 0.0) {
return vec3(0.0);
}
// define zenith
vec3 upVector = worldPosInAtmosphere / radius;
// This assumes sunDirection points from the sample toward the Sun.
vec3 directionToSun = normalize(sunDirection);
// returns cosine
float mu = dot(directionToSun, upVector);
// transmitance texture dimensions used for `transmittanceWH`
ivec2 textureDimensions = textureSize(transmittance, 0);
vec2 transmittanceWH = vec2(textureDimensions);
// calculated uv coordinates that we can use to sample the transmittance
vec2 transmittanceUv =
GetTransmittanceTextureUvFromRMu(transmittanceWH, radius, mu);
// return the transmittance.
return texture(transmittance, transmittanceUv).rgb;
} No we can multiple the final irradiance from the sun with the transmittance.
vec3 T = GetTransmittance();
sunContribution *= T; This ensures tha the sun reflected from the water does not have constant white reflections. This little improvement made sure that the water looks as believable as possible.
God rays and shadows
The title page uses a single light source (the sun) to illuminate the world. The sun is defined in the atmosphere implementation and, similarly to the rest of the atmosphere, it is defined in the ECEF coordinate space, which means it is far too far away for us to use for shadow mapping.
For this reason, another light source is used, which I have labeled Shadow caster. This is a very dim directional light used only to generate the shadow map, allowing god rays and shadows to be rendered. The shadow caster mirrors the direction of the atmosphere sun, creating the illusion that it is casting the shadows.
For this to work, the atmosphere`s sun direction had to be converted to the world coordinate system which was achieved by the following code.
// called in constructor
private CalculateGeodeticPositionsAndBasis() {
const geodetic = new Geodetic(0, radians(67), 1000);
this._position = geodetic.toECEF();
// atmosphere basis vectors
this._up = Ellipsoid.WGS84.getSurfaceNormal(this._position).normalize();
this._east.crossVectors(new THREE.Vector3(0, 0, -1), this._up).normalize();
this._north.crossVectors(this._up, this._east).normalize();
}
// three.js world basis vectors
const worldEast = new THREE.Vector3(1, 0, 0);
const worldUp = new THREE.Vector3(0, 1, 0);
const worldNorth = new THREE.Vector3(0, 0, -1);
// coordinate system conversions
const e = this._sunDirection.dot(this._east);
const n = this._sunDirection.dot(this._north);
const u = this._sunDirection.dot(this._up);
return this._worldSunDirection
.copy(worldEast)
.multiplyScalar(e)
.addScaledVector(worldNorth, n)
.addScaledVector(worldUp, u)
.normalize();