From 39f0ea1706cff74c6b737a0802fdefe2c53dc71e Mon Sep 17 00:00:00 2001
From: Filament Bot
The GLSL implementation of the NDF, shown in listing 1, is simple and efficient. -
float D_GGX(float NoH, float roughness) {
- float a = NoH * roughness;
- float k = roughness / (1.0 - NoH * NoH + a * a);
+float D_GGX(float NoH, float roughness) {
+ float a = NoH * roughness;
+ float k = roughness / (1.0 - NoH * NoH + a * a);
return k * k * (1.0 / PI);
}
@@ -590,10 +590,10 @@ V(v,l,\alpha) = \frac{0.5}{\NoL \sqrt{(\NoV)^2 (1 - \aa) + \aa} + \NoV \sqrt{(\N
\end{equation}$$
The GLSL implementation of the visibility term, shown in listing 3, is a bit more expensive than we would like since it requires two sqrt operations.
-
float V_SmithGGXCorrelated(float NoV, float NoL, float roughness) {
- float a2 = roughness * roughness;
- float GGXV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
- float GGXL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
+float V_SmithGGXCorrelated(float NoV, float NoL, float roughness) {
+ float a2 = roughness * roughness;
+ float GGXV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
+ float GGXL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
return 0.5 / (GGXV + GGXL);
}
@@ -604,10 +604,10 @@ V(v,l,\alpha) = \frac{0.5}{\NoL (\NoV (1 - \alpha) + \alpha) + \NoV (\NoL (1 - \
\end{equation}$$
This approximation is mathematically wrong but saves two square root operations and is good enough for real-time mobile applications, as shown in listing 4.
-
float V_SmithGGXCorrelatedFast(float NoV, float NoL, float roughness) {
- float a = roughness;
- float GGXV = NoL * (NoV * (1.0 - a) + a);
- float GGXL = NoV * (NoL * (1.0 - a) + a);
+float V_SmithGGXCorrelatedFast(float NoV, float NoL, float roughness) {
+ float a = roughness;
+ float GGXV = NoL * (NoV * (1.0 - a) + a);
+ float GGXL = NoV * (NoL * (1.0 - a) + a);
return 0.5 / (GGXV + GGXL);
}
@@ -659,7 +659,7 @@ $$\begin{equation}
\end{equation}$$
In practice, the diffuse reflectance \(\sigma\) is multiplied later, as shown in listing 8.
-
float Fd_Lambert() {
+float Fd_Lambert() {
return 1.0 / PI;
}
@@ -680,14 +680,14 @@ Where:
$$\begin{equation}
\fGrazing=0.5 + 2 \cdot \alpha cos^2(\theta_d)
\end{equation}$$
-float F_Schlick(float u, float f0, float f90) {
- return f0 + (f90 - f0) * pow(1.0 - u, 5.0);
+float F_Schlick(float u, float f0, float f90) {
+ return f0 + (f90 - f0) * pow(1.0 - u, 5.0);
}
-float Fd_Burley(float NoV, float NoL, float LoH, float roughness) {
- float f90 = 0.5 + 2.0 * roughness * LoH * LoH;
- float lightScatter = F_Schlick(NoL, 1.0, f90);
- float viewScatter = F_Schlick(NoV, 1.0, f90);
+float Fd_Burley(float NoV, float NoL, float LoH, float roughness) {
+ float f90 = 0.5 + 2.0 * roughness * LoH * LoH;
+ float lightScatter = F_Schlick(NoL, 1.0, f90);
+ float viewScatter = F_Schlick(NoV, 1.0, f90);
return lightScatter * viewScatter * (1.0 / PI);
}
@@ -704,47 +704,47 @@ We could allow artists/developers to choose the Disney diffuse BRDF depending on
Diffuse term: a Lambertian diffuse model.
The full GLSL implementation of the standard model is shown in listing 9.
-
float D_GGX(float NoH, float a) {
- float a2 = a * a;
- float f = (NoH * a2 - NoH) * NoH + 1.0;
+float D_GGX(float NoH, float a) {
+ float a2 = a * a;
+ float f = (NoH * a2 - NoH) * NoH + 1.0;
return a2 / (PI * f * f);
}
-vec3 F_Schlick(float u, vec3 f0) {
- return f0 + (vec3(1.0) - f0) * pow(1.0 - u, 5.0);
+vec3 F_Schlick(float u, vec3 f0) {
+ return f0 + (vec3(1.0) - f0) * pow(1.0 - u, 5.0);
}
-float V_SmithGGXCorrelated(float NoV, float NoL, float a) {
- float a2 = a * a;
- float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2);
- float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2);
+float V_SmithGGXCorrelated(float NoV, float NoL, float a) {
+ float a2 = a * a;
+ float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2);
+ float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2);
return 0.5 / (GGXV + GGXL);
}
-float Fd_Lambert() {
+float Fd_Lambert() {
return 1.0 / PI;
}
-void BRDF(...) {
- vec3 h = normalize(v + l);
+void BRDF(...) {
+ vec3 h = normalize(v + l);
- float NoV = abs(dot(n, v)) + 1e-5;
- float NoL = clamp(dot(n, l), 0.0, 1.0);
- float NoH = clamp(dot(n, h), 0.0, 1.0);
- float LoH = clamp(dot(l, h), 0.0, 1.0);
+ float NoV = abs(dot(n, v)) + 1e-5;
+ float NoL = clamp(dot(n, l), 0.0, 1.0);
+ float NoH = clamp(dot(n, h), 0.0, 1.0);
+ float LoH = clamp(dot(l, h), 0.0, 1.0);
// perceptually linear roughness to roughness (see parameterization)
- float roughness = perceptualRoughness * perceptualRoughness;
+ float roughness = perceptualRoughness * perceptualRoughness;
- float D = D_GGX(NoH, roughness);
- vec3 F = F_Schlick(LoH, f0);
- float V = V_SmithGGXCorrelated(NoV, NoL, roughness);
+ float D = D_GGX(NoH, roughness);
+ vec3 F = F_Schlick(LoH, f0);
+ float V = V_SmithGGXCorrelated(NoV, NoL, roughness);
// specular BRDF
- vec3 Fr = (D * V) * F;
+ vec3 Fr = (D * V) * F;
// diffuse BRDF
- vec3 Fd = diffuseColor * Fd_Lambert();
+ vec3 Fd = diffuseColor * Fd_Lambert();
// apply lighting...
}
@@ -965,7 +965,7 @@ $$\begin{equation}
\end{equation}$$
Listing 12 shows how \(\fNormal\) is computed for both dielectric and metallic materials. It shows that the color of the specular reflectance is derived from the base color in the metallic case.
-
vec3 f0 = 0.16 * reflectance * reflectance * (1.0 - metallic) + baseColor * metallic;
+vec3 f0 = 0.16 * reflectance * reflectance * (1.0 - metallic) + baseColor * metallic;
Roughness remapping and clamping
The roughness set by the user, called perceptualRoughness here, is remapped to a perceptually linear range using the following formulation:
@@ -1054,7 +1054,7 @@ V(l,h) = \frac{1}{4(\LoH)^2}
This masking-shadowing function is not physically based, as shown in [Heitz14], but its simplicity makes it desirable for real-time rendering.
In summary, our clear coat BRDF is a Cook-Torrance specular microfacet model, with a GGX normal distribution function, a Kelemen visibility function, and a Schlick Fresnel function. Listing 13 shows how trivial the GLSL implementation is.
-
float V_Kelemen(float LoH) {
+float V_Kelemen(float LoH) {
return 0.25 / (LoH * LoH);
}
@@ -1097,18 +1097,18 @@ The clear coat roughness parameter is remapped and clamped in a similar way to t
Listing 14 shows the GLSL implementation of the clear coat material model after remapping, parameterization and integration in the standard surface response.
-
void BRDF(...) {
+void BRDF(...) {
// compute Fd and Fr from standard model
// remapping and linearization of clear coat roughness
- clearCoatPerceptualRoughness = clamp(clearCoatPerceptualRoughness, 0.089, 1.0);
+ clearCoatPerceptualRoughness = clamp(clearCoatPerceptualRoughness, 0.089, 1.0);
clearCoatRoughness = clearCoatPerceptualRoughness * clearCoatPerceptualRoughness;
// clear coat BRDF
- float Dc = D_GGX(clearCoatRoughness, NoH);
- float Vc = V_Kelemen(clearCoatRoughness, LoH);
- float Fc = F_Schlick(0.04, LoH) * clearCoat; // clear coat strength
- float Frc = (Dc * Vc) * Fc;
+ float Dc = D_GGX(clearCoatRoughness, NoH);
+ float Vc = V_Kelemen(clearCoatRoughness, LoH);
+ float Fc = F_Schlick(0.04, LoH) * clearCoat; // clear coat strength
+ float Frc = (Dc * Vc) * Fc;
// account for energy loss in the base layer
return color * ((Fd + Fr * (1.0 - Fc)) * (1.0 - Fc) + Frc);
@@ -1294,14 +1294,14 @@ f_{r}(v,h,\alpha) = \frac{D_{velvet}(v,h,\alpha)}{4(\NoL + \NoV - (\NoL)(\NoV))}
\end{equation}$$
The implementation of the velvet NDF is presented in listing 17, optimized to properly fit in half float formats and to avoid computing a costly cotangent, relying instead on trigonometric identities. Note that we removed the Fresnel component from this BRDF.
-
float D_Ashikhmin(float roughness, float NoH) {
+float D_Ashikhmin(float roughness, float NoH) {
// Ashikhmin 2007, "Distribution-based BRDFs"
- float a2 = roughness * roughness;
- float cos2h = NoH * NoH;
- float sin2h = max(1.0 - cos2h, 0.0078125); // 2^(-14/2), so sin2h^2 > 0 in fp16
- float sin4h = sin2h * sin2h;
- float cot2 = -cos2h / (a2 * sin2h);
- return 1.0 / (PI * (4.0 * a2 + 1.0) * sin4h) * (4.0 * exp(cot2) + sin4h);
+ float a2 = roughness * roughness;
+ float cos2h = NoH * NoH;
+ float sin2h = max(1.0 - cos2h, 0.0078125); // 2^(-14/2), so sin2h^2 > 0 in fp16
+ float sin4h = sin2h * sin2h;
+ float cot2 = -cos2h / (a2 * sin2h);
+ return 1.0 / (PI * (4.0 * a2 + 1.0) * sin4h) * (4.0 * exp(cot2) + sin4h);
}
In [Estevez17] Estevez and Kulla propose a different NDF (called the “Charlie” sheen) that is based on an exponentiated sinusoidal instead of an inverted Gaussian. This NDF is appealing for several reasons: its parameterization feels more natural and intuitive, it provides a softer appearance and, as shown in equation (\ref{charlieNDF}), its implementation is simpler:
@@ -1312,11 +1312,11 @@ D(m) = \frac{(2 + \frac{1}{\alpha}) sin(\theta)^{\frac{1}{\alpha}}}{2 \pi}
[Estevez17] also presents a new shadowing term that we omit here because of its cost. We instead rely on the visibility term from [Neubelt13] (shown in equation \(\ref{clothSpecularBRDF}\) above).
The implementation of this NDF is presented in listing 18, optimized to properly fit in half float formats.
-
float D_Charlie(float roughness, float NoH) {
+float D_Charlie(float roughness, float NoH) {
// Estevez and Kulla 2017, "Production Friendly Microfacet Sheen BRDF"
- float invAlpha = 1.0 / roughness;
- float cos2h = NoH * NoH;
- float sin2h = max(1.0 - cos2h, 0.0078125); // 2^(-14/2), so sin2h^2 > 0 in fp16
+ float invAlpha = 1.0 / roughness;
+ float cos2h = NoH * NoH;
+ float sin2h = max(1.0 - cos2h, 0.0078125); // 2^(-14/2), so sin2h^2 > 0 in fp16
return (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI);
}
Sheen color
@@ -1745,21 +1745,21 @@ The photometric attenuation function can be easily implemented in GLSL by adding
}
The light intensity is computed CPU-side (listing 23) and depends on whether the photometric profile is used as a mask.
-float multiplier;
+float multiplier;
// Photometric profile used as a mask
-if (photometricLight.isMasked()) {
+if (photometricLight.isMasked()) {
// The desired intensity is set by the artist
// The integrated intensity comes from a Monte-Carlo
// integration over the unit sphere around the luminaire
- multiplier = photometricLight.getDesiredIntensity() /
- photometricLight.getIntegratedIntensity();
+ multiplier = photometricLight.getDesiredIntensity() /
+ photometricLight.getIntegratedIntensity();
} else {
// Multiplier provided for convenience, set to 1.0 by default
- multiplier = photometricLight.getMultiplier();
+ multiplier = photometricLight.getMultiplier();
}
// The max intensity in cd comes from the IES profile
-float lightIntensity = photometricLight.getMaxIntensity() * multiplier;
+float lightIntensity = photometricLight.getMaxIntensity() * multiplier;
4 The XArrow profile declares a luminous intensity of 1,750 lm but a Monte-Carlo integration shows an intensity of only 350 lm.
@@ -2058,19 +2058,19 @@ In practice only 4 or 9 coefficients (i.e.: 2 or 3 bands) are enough for \(\cosT
In practice we pre-convolve \(\Lt\) with \(\cosTheta\) and pre-scale these coefficients by the basis scaling factors \(K_l^m\) so that the reconstruction code is as simple as possible in the shader:
-
vec3 irradianceSH(vec3 n) {
- // uniform vec3 sphericalHarmonics[9]
- // We can use only the first 2 bands for better performance
- return
- sphericalHarmonics[0]
- + sphericalHarmonics[1] * (n.y)
- + sphericalHarmonics[2] * (n.z)
- + sphericalHarmonics[3] * (n.x)
- + sphericalHarmonics[4] * (n.y * n.x)
- + sphericalHarmonics[5] * (n.y * n.z)
- + sphericalHarmonics[6] * (3.0 * n.z * n.z - 1.0)
- + sphericalHarmonics[7] * (n.z * n.x)
- + sphericalHarmonics[8] * (n.x * n.x - n.y * n.y);
+vec3 irradianceSH(vec3 n) {
+ // uniform vec3 sphericalHarmonics[9]
+ // We can use only the first 2 bands for better performance
+ return
+ sphericalHarmonics[0]
+ + sphericalHarmonics[1] * (n.y)
+ + sphericalHarmonics[2] * (n.z)
+ + sphericalHarmonics[3] * (n.x)
+ + sphericalHarmonics[4] * (n.y * n.x)
+ + sphericalHarmonics[5] * (n.y * n.z)
+ + sphericalHarmonics[6] * (3.0 * n.z * n.z - 1.0)
+ + sphericalHarmonics[7] * (n.z * n.x)
+ + sphericalHarmonics[8] * (n.x * n.x - n.y * n.y);
}
Note that with 2 bands, the computation above becomes a single (4 \times 4) matrix-by-vector multiply.
@@ -2443,7 +2443,7 @@ LD(n, \alpha) &= \frac{\sum_i^N V(l_i, n,
$$
These two new \(DFG\) terms simply need to replace the ones used in the implementation shown in section 9.5:
-
float Fc = pow(1 - VoH, 5.0f);
+float Fc = pow(1 - VoH, 5.0f);
r.x += Gv * Fc;
r.y += Gv;
@@ -2507,11 +2507,11 @@ using an environment made of colored vertical stripes (skybox hidden).
When sampling the IBL, the clear coat layer is calculated as a second specular lobe. This specular lobe is oriented along the view direction since we cannot reasonably integrate over the hemisphere. Listing 31 demonstrates this approximation in practice. It also shows the energy conservation step. It is important to note that this second specular lobe is computed exactly the same way as the main specular lobe, using the same DFG approximation.
// clearCoat_NoV == shading_NoV if the clear coat layer doesn't have its own normal map
-float Fc = F_Schlick(0.04, 1.0, clearCoat_NoV) * clearCoat;
+float Fc = F_Schlick(0.04, 1.0, clearCoat_NoV) * clearCoat;
// base layer attenuation for energy compensation
iblDiffuse *= 1.0 - Fc;
-iblSpecular *= sq(1.0 - Fc);
-iblSpecular += specularIBL(r, clearCoatPerceptualRoughness) * Fc;
+iblSpecular *= sq(1.0 - Fc);
+iblSpecular += specularIBL(r, clearCoatPerceptualRoughness) * Fc;
Anisotropy
[McAuley15] describes a technique called “bent reflection vector”, based [Revie12]. The bent reflection vector is a rough approximation of anisotropic lighting but the alternative is to use importance sampling. This approximation is sufficiently cheap to compute and provides good results, as shown in figure 59 and figure 60.
@@ -2550,17 +2550,17 @@ The DG term is generated using uniform sampling as recommended in [
Figure 62: DFG LUT with a 3rd channel encoding the DG term of the cloth BRDF
The remainder of the image-based lighting implementation follows the same steps as the implementation of regular lights, including the optional subsurface scattering term and its wrap diffuse component. Just as with the clear coat IBL implementation, we cannot integrate over the hemisphere and use the view direction as the dominant light direction to compute the wrap diffuse component.
-
float diffuse = Fd_Lambert() * ambientOcclusion;
-#if defined(SHADING_MODEL_CLOTH)
-#if defined(MATERIAL_HAS_SUBSURFACE_COLOR)
-diffuse *= saturate((NoV + 0.5) / 2.25);
-#endif
-#endif
+float diffuse = Fd_Lambert() * ambientOcclusion;
+#if defined(SHADING_MODEL_CLOTH)
+#if defined(MATERIAL_HAS_SUBSURFACE_COLOR)
+diffuse *= saturate((NoV + 0.5) / 2.25);
+#endif
+#endif
-vec3 indirectDiffuse = irradianceIBL(n) * diffuse;
-#if defined(SHADING_MODEL_CLOTH) && defined(MATERIAL_HAS_SUBSURFACE_COLOR)
-indirectDiffuse *= saturate(subsurfaceColor + NoV);
-#endif
+vec3 indirectDiffuse = irradianceIBL(n) * diffuse;
+#if defined(SHADING_MODEL_CLOTH) && defined(MATERIAL_HAS_SUBSURFACE_COLOR)
+indirectDiffuse *= saturate(subsurfaceColor + NoV);
+#endif
vec3 ibl = diffuseColor * indirectDiffuse + indirectSpecular * specularColor;
@@ -2982,20 +2982,20 @@ L_{max} &= 2^{EV_{100}} \times 1.2
// aperture in f-stops
// shutterSpeed in seconds
// sensitivity in ISO
-float exposureSettings(float aperture, float shutterSpeed, float sensitivity) {
+float exposureSettings(float aperture, float shutterSpeed, float sensitivity) {
return log2((aperture * aperture) / shutterSpeed * 100.0 / sensitivity);
}
// Computes the exposure normalization factor from
// the camera's EV100
-float exposure(float ev100) {
- return 1.0 / (pow(2.0, ev100) * 1.2);
+float exposure(float ev100) {
+ return 1.0 / (pow(2.0, ev100) * 1.2);
}
-float ev100 = exposureSettings(aperture, shutterSpeed, sensitivity);
-float exposure = exposure(ev100);
+float ev100 = exposureSettings(aperture, shutterSpeed, sensitivity);
+float exposure = exposure(ev100);
-vec4 color = evaluateLighting();
+vec4 color = evaluateLighting();
color.rgb *= exposure;
In practice the exposure factor can be pre-computed on the CPU to save shader instructions.
@@ -3466,9 +3466,9 @@ Our implementation is presented in listing&
// Data source:
// http://cvrl.ioo.ucl.ac.uk/cmfs.htm
// http://cvrl.ioo.ucl.ac.uk/database/text/cmfs/ciexyz31.htm
-const size_t CIE_XYZ_START = 360;
-const size_t CIE_XYZ_COUNT = 471;
-const float3 CIE_XYZ[CIE_XYZ_COUNT] = { ... };
+const size_t CIE_XYZ_START = 360;
+const size_t CIE_XYZ_COUNT = 471;
+const float3 CIE_XYZ[CIE_XYZ_COUNT] = { ... };
// CIE Standard Illuminant D65 relative spectral power distribution,
// from 300nm to 830, at 5nm intervals
@@ -3476,51 +3476,51 @@ Our implementation is presented in listing&
// Data source:
// https://en.wikipedia.org/wiki/Illuminant_D65
// https://cielab.xyz/pdf/CIE_sel_colorimetric_tables.xls
-const size_t CIE_D65_INTERVAL = 5;
-const size_t CIE_D65_START = 300;
-const size_t CIE_D65_END = 830;
-const size_t CIE_D65_COUNT = 107;
-const float CIE_D65[CIE_D65_COUNT] = { ... };
+const size_t CIE_D65_INTERVAL = 5;
+const size_t CIE_D65_START = 300;
+const size_t CIE_D65_END = 830;
+const size_t CIE_D65_COUNT = 107;
+const float CIE_D65[CIE_D65_COUNT] = { ... };
-struct Sample {
- float w = 0.0f; // wavelength
- std::complex<float> ior; // complex IOR, n + ik
+struct Sample {
+ float w = 0.0f; // wavelength
+ std::complex<float> ior; // complex IOR, n + ik
};
-static float illuminantD65(float w) {
- auto i0 = size_t((w - CIE_D65_START) / CIE_D65_INTERVAL);
- uint2 indexBounds{i0, std::min(i0 + 1, CIE_D65_END)};
+static float illuminantD65(float w) {
+ auto i0 = size_t((w - CIE_D65_START) / CIE_D65_INTERVAL);
+ uint2 indexBounds{i0, std::min(i0 + 1, CIE_D65_END)};
float2 wavelengthBounds = CIE_D65_START + float2{indexBounds} * CIE_D65_INTERVAL;
- float t = (w - wavelengthBounds.x) / (wavelengthBounds.y - wavelengthBounds.x);
- return lerp(CIE_D65[indexBounds.x], CIE_D65[indexBounds.y], t);
+ float t = (w - wavelengthBounds.x) / (wavelengthBounds.y - wavelengthBounds.x);
+ return lerp(CIE_D65[indexBounds.x], CIE_D65[indexBounds.y], t);
}
// For std::lower_bound
-bool operator<(const Sample& lhs, const Sample& rhs) {
+bool operator<(const Sample& lhs, const Sample& rhs) {
return lhs.w < rhs.w;
}
// The wavelength w must be between 360nm and 830nm
-static std::complex<float> findSample(const std::vector<sample>& samples, float w) {
- auto i1 = std::lower_bound(
- samples.begin(), samples.end(), Sample{w, 0.0f + 0.0if});
+static std::complex<float> findSample(const std::vector<sample>& samples, float w) {
+ auto i1 = std::lower_bound(
+ samples.begin(), samples.end(), Sample{w, 0.0f + 0.0if});
auto i0 = i1 - 1;
// Interpolate the complex IORs
- float t = (w - i0->w) / (i1->w - i0->w);
- float n = lerp(i0->ior.real(), i1->ior.real(), t);
- float k = lerp(i0->ior.imag(), i1->ior.imag(), t);
+ float t = (w - i0->w) / (i1->w - i0->w);
+ float n = lerp(i0->ior.real(), i1->ior.real(), t);
+ float k = lerp(i0->ior.imag(), i1->ior.imag(), t);
return { n, k };
}
-static float fresnel(const std::complex<float>& sample) {
- return (((sample - (1.0f + 0if)) * (std::conj(sample) - (1.0f + 0if))) /
- ((sample + (1.0f + 0if)) * (std::conj(sample) + (1.0f + 0if)))).real();
+static float fresnel(const std::complex<float>& sample) {
+ return (((sample - (1.0f + 0if)) * (std::conj(sample) - (1.0f + 0if))) /
+ ((sample + (1.0f + 0if)) * (std::conj(sample) + (1.0f + 0if)))).real();
}
-static float3 XYZ_to_sRGB(const float3& v) {
- const mat3f XYZ_sRGB{
+static float3 XYZ_to_sRGB(const float3& v) {
+ const mat3f XYZ_sRGB{
3.2404542f, -0.9692660f, 0.0556434f,
-1.5371385f, 1.8760108f, -0.2040259f,
-0.4985314f, 0.0415560f, 1.0572252f
@@ -3529,21 +3529,21 @@ Our implementation is presented in listing&
}
// Outputs a linear sRGB color
-static float3 computeColor(const std::vector<sample>& samples) {
+static float3 computeColor(const std::vector<sample>& samples) {
float3 xyz{0.0f};
- float y = 0.0f;
+ float y = 0.0f;
- for (size_t i = 0; i < CIE_XYZ_COUNT; i++) {
+ for (size_t i = 0; i < CIE_XYZ_COUNT; i++) {
// Current wavelength
- float w = CIE_XYZ_START + i;
+ float w = CIE_XYZ_START + i;
// Find most appropriate CIE XYZ sample for the wavelength
- auto sample = findSample(samples, w);
+ auto sample = findSample(samples, w);
// Compute Fresnel reflectance at normal incidence
- float f0 = fresnel(sample);
+ float f0 = fresnel(sample);
// We need to multiply by the spectral power distribution of the illuminant
- float d65 = illuminantD65(w);
+ float d65 = illuminantD65(w);
xyz += f0 * CIE_XYZ[i] * d65;
y += CIE_XYZ[i].y * d65;
@@ -3552,10 +3552,10 @@ Our implementation is presented in listing&
// Normalize so that 100% reflectance at every wavelength yields Y=1
xyz /= y;
- float3 linear = XYZ_to_sRGB(xyz);
+ float3 linear = XYZ_to_sRGB(xyz);
// Normalize out-of-gamut values
- if (any(greaterThan(linear, float3{1.0f}))) linear *= 1.0f / max(linear);
+ if (any(greaterThan(linear, float3{1.0f}))) linear *= 1.0f / max(linear);
return linear;
}
@@ -3735,47 +3735,47 @@ l &= \{ cos\phi sin\theta,sin\phi sin\theta,cos\theta \}
vec2f hammersley(uint i, float numSamples) {
uint bits = i;
bits = (bits << 16) | (bits >> 16);
- bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
- bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
- bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
- bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
- return vec2f(i / numSamples, bits / exp2(32));
+ bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
+ bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
+ bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
+ bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
+ return vec2f(i / numSamples, bits / exp2(32));
}
C++ implementation of a Hammersley sequence generator
Precomputing L for image-based lighting
The term ( L_{DFG} ) is only dependent on ( \NoV ). Below, the normal is arbitrarily set to ( n=\left[0, 0, 1\right] ) and (v) is chosen to satisfy ( \NoV ). The vector ( h_i ) is the ( D_{GGX}(\alpha) ) important direction sample (i).
-float GDFG(float NoV, float NoL, float a) {
- float a2 = a * a;
- float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2);
- float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2);
+float GDFG(float NoV, float NoL, float a) {
+ float a2 = a * a;
+ float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2);
+ float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2);
return (2 * NoL) / (GGXV + GGXL);
}
-float2 DFG(float NoV, float a) {
+float2 DFG(float NoV, float a) {
float3 V;
- V.x = sqrt(1.0f - NoV*NoV);
- V.y = 0.0f;
+ V.x = sqrt(1.0f - NoV*NoV);
+ V.y = 0.0f;
V.z = NoV;
- float2 r = 0.0f;
- for (uint i = 0; i < sampleCount; i++) {
- float2 Xi = hammersley(i, sampleCount);
- float3 H = importanceSampleGGX(Xi, a, N);
- float3 L = 2.0f * dot(V, H) * H - V;
+ float2 r = 0.0f;
+ for (uint i = 0; i < sampleCount; i++) {
+ float2 Xi = hammersley(i, sampleCount);
+ float3 H = importanceSampleGGX(Xi, a, N);
+ float3 L = 2.0f * dot(V, H) * H - V;
- float VoH = saturate(dot(V, H));
- float NoL = saturate(L.z);
- float NoH = saturate(H.z);
+ float VoH = saturate(dot(V, H));
+ float NoL = saturate(L.z);
+ float NoH = saturate(H.z);
- if (NoL > 0.0f) {
- float G = GDFG(NoV, NoL, a);
- float Gv = G * VoH / NoH;
- float Fc = pow(1 - VoH, 5.0f);
+ if (NoL > 0.0f) {
+ float G = GDFG(NoV, NoL, a);
+ float Gv = G * VoH / NoH;
+ float Fc = pow(1 - VoH, 5.0f);
r.x += Gv * (1 - Fc);
r.y += Gv * Fc;
}
}
- return r * (1.0f / sampleCount);
+ return r * (1.0f / sampleCount);
}
C++ implementation of the \( L_{DFG} \) term
Spherical Harmonics
@@ -3851,56 +3851,56 @@ sin(m \phi + \phi) &= sin(m \phi) cos(\phi) + cos(m \phi) sin(\phi) \Leftrightar
\end{align*}$$
Listing 47 shows the C++ code to compute the non-normalized SH basis \(\frac{y^m_l(s)}{\sqrt{2} K^m_l}\):
-
static inline size_t SHindex(ssize_t m, size_t l) {
+static inline size_t SHindex(ssize_t m, size_t l) {
return l * (l + 1) + m;
}
-void computeShBasis(
- double* const SHb,
- size_t numBands,
- const vec3& s)
+void computeShBasis(
+ double* const SHb,
+ size_t numBands,
+ const vec3& s)
{
// handle m=0 separately, since it produces only one coefficient
- double Pml_2 = 0;
- double Pml_1 = 1;
+ double Pml_2 = 0;
+ double Pml_1 = 1;
SHb[0] = Pml_1;
- for (ssize_t l = 1; l < numBands; l++) {
- double Pml = ((2 * l - 1) * Pml_1 * s.z - (l - 1) * Pml_2) / l;
+ for (ssize_t l = 1; l < numBands; l++) {
+ double Pml = ((2 * l - 1) * Pml_1 * s.z - (l - 1) * Pml_2) / l;
Pml_2 = Pml_1;
Pml_1 = Pml;
- SHb[SHindex(0, l)] = Pml;
+ SHb[SHindex(0, l)] = Pml;
}
- double Pmm = 1;
- for (ssize_t m = 1; m < numBands ; m++) {
+ double Pmm = 1;
+ for (ssize_t m = 1; m < numBands ; m++) {
Pmm = (1 - 2 * m) * Pmm;
- double Pml_2 = Pmm;
- double Pml_1 = (2 * m + 1)*Pmm*s.z;
+ double Pml_2 = Pmm;
+ double Pml_1 = (2 * m + 1)*Pmm*s.z;
// l == m
- SHb[SHindex(-m, m)] = Pml_2;
- SHb[SHindex( m, m)] = Pml_2;
+ SHb[SHindex(-m, m)] = Pml_2;
+ SHb[SHindex( m, m)] = Pml_2;
if (m + 1 < numBands) {
// l == m+1
- SHb[SHindex(-m, m + 1)] = Pml_1;
- SHb[SHindex( m, m + 1)] = Pml_1;
- for (ssize_t l = m + 2; l < numBands; l++) {
- double Pml = ((2 * l - 1) * Pml_1 * s.z - (l + m - 1) * Pml_2)
+ SHb[SHindex(-m, m + 1)] = Pml_1;
+ SHb[SHindex( m, m + 1)] = Pml_1;
+ for (ssize_t l = m + 2; l < numBands; l++) {
+ double Pml = ((2 * l - 1) * Pml_1 * s.z - (l + m - 1) * Pml_2)
/ (l - m);
Pml_2 = Pml_1;
Pml_1 = Pml;
- SHb[SHindex(-m, l)] = Pml;
- SHb[SHindex( m, l)] = Pml;
+ SHb[SHindex(-m, l)] = Pml;
+ SHb[SHindex( m, l)] = Pml;
}
}
}
- double Cm = s.x;
- double Sm = s.y;
- for (ssize_t m = 1; m <= numBands ; m++) {
- for (ssize_t l = m; l < numBands ; l++) {
- SHb[SHindex(-m, l)] *= Sm;
- SHb[SHindex( m, l)] *= Cm;
+ double Cm = s.x;
+ double Sm = s.y;
+ for (ssize_t m = 1; m <= numBands ; m++) {
+ for (ssize_t l = m; l < numBands ; l++) {
+ SHb[SHindex(-m, l)] *= Sm;
+ SHb[SHindex( m, l)] *= Cm;
}
- double Cm1 = Cm * s.x - Sm * s.y;
- double Sm1 = Sm * s.x + Cm * s.y;
+ double Cm1 = Cm * s.x - Sm * s.y;
+ double Sm1 = Sm * s.x + Cm * s.y;
Cm = Cm1;
Sm = Sm1;
}
@@ -3979,10 +3979,10 @@ $$\begin{equation}
\end{equation}$$
Here is the C++ code to compute \(\hat{C}_l\):
-
static double factorial(size_t n, size_t d = 1);
+static double factorial(size_t n, size_t d = 1);
// < cos(theta) > SH coefficients pre-multiplied by 1 / K(0,l)
-double computeTruncatedCosSh(size_t l) {
+double computeTruncatedCosSh(size_t l) {
if (l == 0) {
return M_PI;
} else if (l == 1) {
@@ -3990,17 +3990,17 @@ Here is the C++ code to compute \(\hat{C}_l\):
} else if (l & 1) {
return 0;
}
- const size_t l_2 = l / 2;
- double A0 = ((l_2 & 1) ? 1.0 : -1.0) / ((l + 2) * (l - 1));
- double A1 = factorial(l, l_2) / (factorial(l_2) * (1 << l));
+ const size_t l_2 = l / 2;
+ double A0 = ((l_2 & 1) ? 1.0 : -1.0) / ((l + 2) * (l - 1));
+ double A1 = factorial(l, l_2) / (factorial(l_2) * (1 << l));
return 2 * M_PI * A0 * A1;
}
// returns n! / d!
-double factorial(size_t n, size_t d ) {
- d = std::max(size_t(1), d);
- n = std::max(size_t(1), n);
- double r = 1.0;
+double factorial(size_t n, size_t d ) {
+ d = std::max(size_t(1), d);
+ n = std::max(size_t(1), n);
+ double r = 1.0;
if (n == d) {
// intentionally left blank
} else if (n > d) {
diff --git a/docs/wip/sky/BUILDING.md b/docs/wip/sky/BUILDING.md
new file mode 100644
index 0000000000..dee231c42c
--- /dev/null
+++ b/docs/wip/sky/BUILDING.md
@@ -0,0 +1,40 @@
+# Building Skybox Assets
+
+This directory contains the source code for the Simulated Skybox sample. The assets (material and textures) are pre-built in the `assets/` directory.
+
+If you need to modify the material or regenerate the moon textures, you can use the scripts provided in the `tools/` directory.
+
+## Prerequisites
+
+- **Filament**: You must have a built version of Filament. The scripts assume a standard CMake build output structure (e.g., `out/cmake-release`).
+- **Python 3**: Required for texture generation.
+- **Python Dependencies**: `numpy`, `Pillow` (automatically installed if missing, via pip).
+
+## Rebuilding the Material
+
+If you modify `simulated_skybox.mat`, you must recompile it into a `.filamat` file.
+
+```bash
+# Run from the sample root or tools directory
+./tools/build_material.sh
+```
+
+This will update `assets/simulated_skybox.filamat`.
+
+## Regenerating Moon Textures
+
+If you want to change the moon's resolution, bump scale, or blur, you can regenerate the textures.
+
+```bash
+# Run from the sample root or tools directory
+./tools/generate_moon_assets.sh [size]
+```
+
+Example:
+```bash
+./tools/generate_moon_assets.sh 512
+```
+
+This will download raw NASA data (if not present) and generate:
+- `assets/moon_disk.png`
+- `assets/moon_normal.png`
diff --git a/docs/wip/sky/SimulatedSkybox.js b/docs/wip/sky/SimulatedSkybox.js
index 80b5099699..a2e7593bba 100644
--- a/docs/wip/sky/SimulatedSkybox.js
+++ b/docs/wip/sky/SimulatedSkybox.js
@@ -37,6 +37,15 @@ class SimulatedSkybox {
// x=cos(rad), y=sin(rad) [Precision Fix], z=intensity, w=enabled
this.moonHalo = [Math.cos(0.5 * Math.PI / 180.0), Math.sin(0.5 * Math.PI / 180.0), 1.0, 0.0]; // Disabled by default
+ this.moonTextureObj = null;
+ this.moonNormalObj = null;
+ this.milkyWayTextureObj = null;
+
+ // Milky Way Parameters
+ // x=Intensity, y=Saturation, z=Unused
+ this.milkyWayControl = [1.0, 1.0, 0.07];
+ this.milkyWayEnabled = true;
+ this.milkyWayRotation = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // Identity by default
this.initEntity();
}
@@ -53,6 +62,184 @@ class SimulatedSkybox {
rcm.setMaterialInstanceAt(instance, 0, this.materialInstance);
console.log("Material loaded and bound.");
+
+ // Load Moon Texture
+ try {
+ const texUrl = 'assets/moon_disk.png';
+ const Texture = Filament.Texture;
+ const TextureSampler = Filament.TextureSampler;
+ const PixelDataFormat = Filament.PixelDataFormat;
+ const PixelDataType = Filament.PixelDataType;
+ const TextureUsage = Filament.Texture$Usage;
+ const TextureFormat = Filament.Texture$InternalFormat;
+ const MinFilter = Filament.MinFilter;
+ const MagFilter = Filament.MagFilter;
+ const WrapMode = Filament.WrapMode;
+ const texResponse = await fetch(texUrl);
+ const texBlob = await texResponse.blob();
+ const bitmap = await createImageBitmap(texBlob);
+
+ const width = bitmap.width;
+ const height = bitmap.height;
+
+ this.moonTextureObj = Texture.Builder()
+ .width(width)
+ .height(height)
+ .levels(0xff)
+ .format(TextureFormat.SRGB8_A8)
+ .usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
+ .build(this.engine);
+
+ // Extract Data using a Canvas
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bitmap, 0, 0);
+ const imageData = ctx.getImageData(0, 0, width, height);
+
+ // Use RGBA data directly (Uint8ClampedArray)
+ // Filament supports RGBA upload for RGB/SRGB internal formats (drops alpha).
+ // This matches Canvas layout (Top-Down, RGBA) and is 4-byte aligned.
+ const pbd = Filament.PixelBuffer(
+ new Uint8Array(imageData.data.buffer), // Wrap buffer to ensure Uint8Array
+ PixelDataFormat.RGBA,
+ PixelDataType.UBYTE
+ );
+
+ this.moonTextureObj.setImage(this.engine, 0, pbd);
+ this.moonTextureObj.generateMipmaps(this.engine);
+
+ const sampler = new TextureSampler(
+ MinFilter.LINEAR,
+ MagFilter.LINEAR,
+ WrapMode.CLAMP_TO_EDGE
+ );
+
+ this.materialInstance.setTextureParameter('moonTexture', this.moonTextureObj, sampler);
+
+ } catch (e) {
+ console.error("Failed to load moon texture:", e);
+ }
+
+ // Load Moon Normal
+ try {
+ const texUrl = 'assets/moon_normal.png';
+ const Texture = Filament.Texture;
+ const TextureSampler = Filament.TextureSampler;
+ const PixelDataFormat = Filament.PixelDataFormat;
+ const PixelDataType = Filament.PixelDataType;
+ const TextureUsage = Filament.Texture$Usage;
+ const TextureFormat = Filament.Texture$InternalFormat;
+ const MinFilter = Filament.MinFilter;
+ const MagFilter = Filament.MagFilter;
+ const WrapMode = Filament.WrapMode;
+ const texResponse = await fetch(texUrl);
+ const texBlob = await texResponse.blob();
+ const bitmap = await createImageBitmap(texBlob);
+
+ const width = bitmap.width;
+ const height = bitmap.height;
+
+ this.moonNormalObj = Texture.Builder()
+ .width(width)
+ .height(height)
+ .levels(0xff)
+ .format(TextureFormat.RGBA8)
+ .usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
+ .build(this.engine);
+
+ // Extract Data using a Canvas
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bitmap, 0, 0);
+ const imageData = ctx.getImageData(0, 0, width, height);
+
+ // Use RGBA data directly
+ const pbd = Filament.PixelBuffer(
+ new Uint8Array(imageData.data.buffer),
+ PixelDataFormat.RGBA,
+ PixelDataType.UBYTE
+ );
+
+ this.moonNormalObj.setImage(this.engine, 0, pbd);
+ this.moonNormalObj.generateMipmaps(this.engine);
+
+ const sampler = new TextureSampler(
+ MinFilter.LINEAR_MIPMAP_LINEAR,
+ MagFilter.LINEAR,
+ WrapMode.CLAMP_TO_EDGE
+ );
+
+ this.materialInstance.setTextureParameter('moonNormal', this.moonNormalObj, sampler);
+
+ } catch (e) {
+ console.error("Failed to load moon normal:", e);
+ }
+
+ // Load Milky Way Texture
+ try {
+ const texUrl = 'assets/milkyway.png';
+ const Texture = Filament.Texture;
+ const TextureSampler = Filament.TextureSampler;
+ const PixelDataFormat = Filament.PixelDataFormat;
+ const PixelDataType = Filament.PixelDataType;
+ const TextureUsage = Filament.Texture$Usage;
+ const TextureFormat = Filament.Texture$InternalFormat;
+ const MinFilter = Filament.MinFilter;
+ const MagFilter = Filament.MagFilter;
+ const WrapMode = Filament.WrapMode;
+ const texResponse = await fetch(texUrl);
+ const texBlob = await texResponse.blob();
+ const bitmap = await createImageBitmap(texBlob);
+
+ const width = bitmap.width;
+ const height = bitmap.height;
+
+ this.milkyWayTextureObj = Texture.Builder()
+ .width(width)
+ .height(height)
+ .levels(0xff)
+ .format(TextureFormat.SRGB8_A8)
+ .usage(TextureUsage.SAMPLEABLE.value | TextureUsage.UPLOADABLE.value | TextureUsage.GEN_MIPMAPPABLE.value)
+ .build(this.engine);
+
+ // Extract Data using a Canvas
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bitmap, 0, 0);
+ try {
+ const imageData = ctx.getImageData(0, 0, width, height);
+
+ // Use RGBA data directly
+ const pbd = Filament.PixelBuffer(
+ new Uint8Array(imageData.data.buffer),
+ PixelDataFormat.RGBA,
+ PixelDataType.UBYTE
+ );
+
+ this.milkyWayTextureObj.setImage(this.engine, 0, pbd);
+ this.milkyWayTextureObj.generateMipmaps(this.engine);
+
+ const sampler = new TextureSampler(
+ MinFilter.LINEAR_MIPMAP_LINEAR,
+ MagFilter.LINEAR,
+ WrapMode.REPEAT // Equirectangular wraps horizontally
+ );
+
+ this.materialInstance.setTextureParameter('milkyWayTexture', this.milkyWayTextureObj, sampler);
+ } catch (err) {
+ console.warn("Milky Way texture data access failed (CORS?):", err);
+ }
+
+ } catch (e) {
+ console.error("Failed to load milky way texture:", e);
+ }
+
this.updateCoefficients();
}
@@ -93,16 +280,16 @@ class SimulatedSkybox {
this.ib.setBuffer(this.engine, TRIANGLE_INDICES);
- // We create a dummy material first or wait?
- // In JS we usually can't block. We'll rely on loadMaterial being called.
- // For now, we build the Renderable without material, then set it later.
+ // Build the Renderable without material; it will be set later by loadMaterial.
+
RenderableManager.Builder(1)
.geometry(0, PrimitiveType.TRIANGLES, this.vb, this.ib)
.culling(false)
.castShadows(false)
.receiveShadows(false)
- .priority(7) // Render behind translucent objects? 7 is skybox priority typically.
+ .priority(7) // Skybox priority
+
.build(this.engine, this.entity);
}
@@ -292,6 +479,27 @@ class SimulatedSkybox {
this.updateCoefficients();
}
+ setMilkyWayControl(intensity, saturation, blackPoint) {
+ this.milkyWayControl[0] = Math.max(0.0, intensity);
+ this.milkyWayControl[1] = Math.max(0.0, saturation);
+ if (blackPoint !== undefined) {
+ this.milkyWayControl[2] = Math.max(0.0, blackPoint);
+ }
+ this.updateCoefficients();
+ }
+
+ setMilkyWayEnabled(enabled) {
+ this.milkyWayEnabled = !!enabled;
+ this.updateCoefficients();
+ }
+
+ setMilkyWayRotation(rotationMatrix) {
+ if (rotationMatrix && rotationMatrix.length === 9) {
+ this.milkyWayRotation = rotationMatrix;
+ this.updateCoefficients();
+ }
+ }
+
updateCoefficients() {
if (!this.materialInstance) {
console.warn("updateCoefficients called before material loaded");
@@ -402,11 +610,23 @@ class SimulatedSkybox {
this.sunDirection[2] * this.moonDirection[2];
// Final Intensity = Peak * Scale (No Phase Factor - Phase is handled in Shader via N.L)
- const MOON_PEAK_LUX = 5000.0;
+ const MOON_PEAK_LUX = 1.0;
const finalMoonIntensity = MOON_PEAK_LUX * this.moonIntensity;
this.materialInstance.setFloatParameter('sunIntensity2', finalMoonIntensity);
+ // Scale Milky Way by Sun Intensity (Pre-exposed) to match dynamic range
+ // Calibration: 1.0 User Intensity ~ 0.025 Lux Relative (approx 2.5% of Sun Pixel Value at Day)
+ // At Sunny 16 (Sun ~ 2.6), this gives ~0.065 pixel brightness, which is visible but dim.
+ const mwIntensity = this.milkyWayEnabled ? this.milkyWayControl[0] : 0.0;
+ const mwUniform = [
+ mwIntensity * this.sunIntensity * 0.025,
+ this.milkyWayControl[1],
+ this.milkyWayControl[2]
+ ];
+ this.materialInstance.setFloat3Parameter('milkyWayControl', new Float32Array(mwUniform));
+ this.materialInstance.setMat3Parameter('milkyWayRotation', new Float32Array(this.milkyWayRotation));
+
// Moon Halo Upload (Disk Visualization)
// Multiplier = 1.0 / SolidAngle
const moonSolidAngle = 2.0 * F_PI * (1.0 - this.moonHalo[0]);
@@ -473,11 +693,10 @@ class SimulatedSkybox {
const part1 = r1sq * Math.acos(c1);
const part2 = r2sq * Math.acos(c2);
- // Heron's formula for the triangle area * 2 (or just 0.5 * sin(angle) *r*r but we have sides)
- // part3 is Area of kite? No, part3 is sum of two triangles?
- // Formula: Area = r1^2 * acos(c1) + r2^2 * acos(c2) - 0.5 * sqrt...
+ // Heron's formula for the triangle area * 2
// The sqrt term represents the area of the two triangles formed by the chord and centers.
+
// Robust sqrt
const val = (-d + r1 + r2) * (d + r1 - r2) * (d - r1 + r2) * (d + r1 + r2);
const part3 = 0.5 * Math.sqrt(Math.max(0.0, val));
diff --git a/docs/wip/sky/assets/milkyway.png b/docs/wip/sky/assets/milkyway.png
new file mode 100644
index 0000000000000000000000000000000000000000..6f2d9fa0d9d5cdda8345698607cde4c3b20babfb
GIT binary patch
literal 609774
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