UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

992 lines (849 loc) 37.3 kB
// Procedural sky ESM script. // // The daytime atmosphere is a port of the three.js `Sky` shader (MIT licensed), which implements // the Preetham et al. analytic daylight model: // - Model: A. J. Preetham, P. Shirley, B. Smits, "A Practical Analytic Model for Daylight" (1999) // - Shader: three.js `Sky` - Simon Wallner (original atmospheric scattering), // Martin Upitis (improved Rayleigh/Mie), Joshua Koo / zz85 (three.js port). // https://threejs.org (MIT) // // The night sky, RGBM image-based-lighting bake, day/night blend and moon are original additions. import { ADDRESS_CLAMP_TO_EDGE, Color, EnvLighting, FILTER_LINEAR, Mat4, PIXELFORMAT_RGBA8, Quat, RenderTarget, Script, SEMANTIC_POSITION, SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL, ShaderChunks, ShaderUtils, TEXTUREPROJECTION_EQUIRECT, TEXTURETYPE_RGBM, Texture, Vec3, drawQuadWithShader } from 'playcanvas'; // Atmosphere constants from the three.js `Sky` shader, used on the CPU to precompute the // view-independent scattering terms (see _updateAtmosphere). const SKY_TOTAL_RAYLEIGH = [5.804542996261093e-6, 1.3562911419845635e-5, 3.0265902468824876e-5]; const SKY_MIE_CONST = [1.8399918514433978e14, 2.7798023919660528e14, 4.0790479543861094e14]; const SKY_EE = 1000.0; const SKY_CUTOFF = 1.6110731556870734; const SKY_STEEPNESS = 1.5; /** * Shared core of the daytime sky model - a port of the three.js `Sky` fragment shader (see the * credit block at the top of the file). The values three.js precomputes per-frame in its vertex * shader are inlined here. Given a world-space `dir`, returns the linear HDR sky radiance. * * Inputs come from globally-scoped uniforms, prefixed `procSky` so they cannot clash with any * other engine/material uniform (there is no public API to set uniforms on the engine's internally * created sky material, so we feed these through `device.scope`). */ const SKY_CORE_GLSL = /* glsl */ ` uniform vec3 procSkySunDir; // normalized direction towards the sun (sky space) uniform vec3 procSkyBetaR; // rayleigh scattering coefficient (CPU-precomputed) uniform vec3 procSkyBetaM; // mie scattering coefficient (CPU-precomputed) uniform float procSkySunE; // sun intensity (CPU-precomputed) uniform float procSkyMieG; // mie directional anisotropy uniform float procSkyLuminance; // output HDR scale const float PI_SKY = 3.141592653589793; const vec3 SKY_UP = vec3(0.0, 1.0, 0.0); const float SKY_RAYLEIGH_ZENITH = 8.4e3; const float SKY_MIE_ZENITH = 1.25e3; const float SKY_SUN_ANGULAR_COS = 0.999956676; // cos of the sun disc half angle float skyRayleighPhase(float cosT) { return (3.0 / (16.0 * PI_SKY)) * (1.0 + cosT * cosT); } float skyHgPhase(float cosT, float g) { float g2 = g * g; float inv = 1.0 / pow(max(1.0 - 2.0 * g * cosT + g2, 0.0), 1.5); return (1.0 / (4.0 * PI_SKY)) * ((1.0 - g2) * inv); } vec3 proceduralSky(vec3 dirIn) { vec3 dir = normalize(dirIn); vec3 sunDir = normalize(procSkySunDir); // betaR, betaM and sunE are view-independent (constant across the sky), so they are // computed once per frame on the CPU and supplied as uniforms (see _updateAtmosphere). // three.js computes these per-vertex; on the CPU is cheaper still. vec3 betaR = procSkyBetaR; vec3 betaM = procSkyBetaM; float sunE = procSkySunE; // optical length along the view direction float zenithAngle = acos(max(0.0, dot(SKY_UP, dir))); float denom = cos(zenithAngle) + 0.15 * pow(max(93.885 - degrees(zenithAngle), 0.0), -1.253); float sR = SKY_RAYLEIGH_ZENITH / denom; float sM = SKY_MIE_ZENITH / denom; vec3 Fex = exp(-(betaR * sR + betaM * sM)); // in-scattering float cosTheta = dot(dir, sunDir); vec3 betaRTheta = betaR * skyRayleighPhase(cosTheta * 0.5 + 0.5); vec3 betaMTheta = betaM * skyHgPhase(cosTheta, procSkyMieG); vec3 betaSum = betaR + betaM; vec3 Lin = pow(sunE * ((betaRTheta + betaMTheta) / betaSum) * (1.0 - Fex), vec3(1.5)); Lin *= mix( vec3(1.0), pow(sunE * ((betaRTheta + betaMTheta) / betaSum) * Fex, vec3(0.5)), clamp(pow(1.0 - dot(SKY_UP, sunDir), 5.0), 0.0, 1.0) ); // sky base + sun disc vec3 L0 = vec3(0.1) * Fex; float sundisk = smoothstep(SKY_SUN_ANGULAR_COS, SKY_SUN_ANGULAR_COS + 0.00002, cosTheta); L0 += sunE * 19000.0 * Fex * sundisk; vec3 col = (Lin + L0) * 0.04 + vec3(0.0, 0.0003, 0.00075); // Clamp the sun disc to a value the bloom can handle. The WebGPU bloom down/upsample // shaders sum 4 neighbour taps in f16 (half) before scaling, and accumulate mip levels // additively - so a very bright pixel overflows those intermediate f16 sums (max 65504) // to Inf/NaN and corrupts the whole screen. 6000 keeps every intermediate well in range. return min(col * procSkyLuminance, vec3(6000.0)); } `; const SKY_CORE_WGSL = /* wgsl */ ` uniform procSkySunDir: vec3f; uniform procSkyBetaR: vec3f; uniform procSkyBetaM: vec3f; uniform procSkySunE: f32; uniform procSkyMieG: f32; uniform procSkyLuminance: f32; const PI_SKY: f32 = 3.141592653589793; const SKY_UP = vec3f(0.0, 1.0, 0.0); const SKY_RAYLEIGH_ZENITH: f32 = 8.4e3; const SKY_MIE_ZENITH: f32 = 1.25e3; const SKY_SUN_ANGULAR_COS: f32 = 0.999956676; fn skyRayleighPhase(cosT: f32) -> f32 { return (3.0 / (16.0 * PI_SKY)) * (1.0 + cosT * cosT); } fn skyHgPhase(cosT: f32, g: f32) -> f32 { let g2 = g * g; let inv = 1.0 / pow(max(1.0 - 2.0 * g * cosT + g2, 0.0), 1.5); return (1.0 / (4.0 * PI_SKY)) * ((1.0 - g2) * inv); } fn proceduralSky(dirIn: vec3f) -> vec3f { let dir = normalize(dirIn); let sunDir = normalize(uniform.procSkySunDir); // betaR, betaM and sunE are view-independent - computed once per frame on the CPU // (see _updateAtmosphere) and supplied as uniforms. let betaR = uniform.procSkyBetaR; let betaM = uniform.procSkyBetaM; let sunE = uniform.procSkySunE; let zenithAngle = acos(max(0.0, dot(SKY_UP, dir))); let denom = cos(zenithAngle) + 0.15 * pow(max(93.885 - degrees(zenithAngle), 0.0), -1.253); let sR = SKY_RAYLEIGH_ZENITH / denom; let sM = SKY_MIE_ZENITH / denom; let Fex = exp(-(betaR * sR + betaM * sM)); let cosTheta = dot(dir, sunDir); let betaRTheta = betaR * skyRayleighPhase(cosTheta * 0.5 + 0.5); let betaMTheta = betaM * skyHgPhase(cosTheta, uniform.procSkyMieG); let betaSum = betaR + betaM; var Lin = pow(sunE * ((betaRTheta + betaMTheta) / betaSum) * (vec3f(1.0) - Fex), vec3f(1.5)); Lin = Lin * mix( vec3f(1.0), pow(sunE * ((betaRTheta + betaMTheta) / betaSum) * Fex, vec3f(0.5)), vec3f(clamp(pow(1.0 - dot(SKY_UP, sunDir), 5.0), 0.0, 1.0)) ); var L0 = vec3f(0.1) * Fex; let sundisk = smoothstep(SKY_SUN_ANGULAR_COS, SKY_SUN_ANGULAR_COS + 0.00002, cosTheta); L0 = L0 + sunE * 19000.0 * Fex * sundisk; let col = (Lin + L0) * 0.04 + vec3f(0.0, 0.0003, 0.00075); // Clamp the sun disc to a value the bloom can handle. The WebGPU bloom down/upsample // shaders sum 4 neighbour taps in f16 (half) before scaling, and accumulate mip levels // additively - so a very bright pixel overflows those intermediate f16 sums (max 65504) // to Inf/NaN and corrupts the whole screen. 6000 keeps every intermediate well in range. return min(col * uniform.procSkyLuminance, vec3f(6000.0)); } `; // Override of the built-in `skyboxPS` chunk: renders the visible (infinite) skydome with the // procedural model instead of sampling a cubemap / env-atlas. We keep the same includes, prepass // branch and tonemap/expose/gamma output as the original so it composes identically with // CameraFrame (HDR), exposure and tonemapping. // Procedural night sky: a deep gradient, a warm twilight band at the set-sun azimuth, size- and // brightness-varied hash stars, and a soft moon disk. Blended in by procSkyNightBlend. Only added // to the visible sky (not the IBL bake), so it lives outside SKY_CORE. const NIGHT_CORE_GLSL = /* glsl */ ` uniform float procSkyNightBlend; // 0 = day, 1 = night uniform vec3 procSkyNightColor; // deep night sky base colour uniform float procSkyNightBrightness; uniform float procSkyStarBrightness; uniform float procSkyStarDensity; // 0..1 fraction of cells holding a star uniform float procSkyStarSize; // multiplier on the star radius uniform float procSkyTwilightGlow; uniform vec3 procSkyMoonDir; // sky-space moon direction (matches the moonlight) uniform vec3 procSkyMoonColor; uniform float procSkyMoonSize; // angular radius of the moon disk (radians) uniform float procSkyMoonGlow; float skyHash13(vec3 p3) { p3 = fract(p3 * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } vec3 proceduralNight(vec3 dirIn) { vec3 dir = normalize(dirIn); // base gradient: darker overhead, a touch lighter near the horizon vec3 col = procSkyNightColor * procSkyNightBrightness * mix(1.1, 0.4, max(dir.y, 0.0)); // twilight: a warm afterglow centered on the horizon point directly below the sun (the // sun's azimuth at the horizon), fading out as the sun sinks well below the horizon. Using // the angle to that single point gives a glow that sits ON the sunset position rather than // smearing along the whole horizon. vec3 sunH = normalize(vec3(procSkySunDir.x, 0.0, procSkySunDir.z) + vec3(1e-5, 0.0, 0.0)); float toSun = max(dot(dir, sunH), 0.0); float belowFade = clamp(1.0 + procSkySunDir.y * 4.0, 0.0, 1.0); col += vec3(1.0, 0.45, 0.15) * (pow(toSun, 5.0) * belowFade * procSkyTwilightGlow); // stars: one candidate per grid cell sampled along the view direction, each with a random // size and brightness. The grid scale is low enough that stars are resolvable (not // sub-pixel), and the edge is anti-aliased against the pixel footprint (length(fwidth(p))) // so each star stays a stable ~1px+ dot and does not twinkle as the camera rotates. vec3 p = dir * 70.0; vec3 cell = floor(p); float present = step(1.0 - procSkyStarDensity, skyHash13(cell)); vec3 sp = vec3(0.3) + 0.4 * vec3(skyHash13(cell + 11.0), skyHash13(cell + 23.0), skyHash13(cell + 37.0)); float sizeRnd = skyHash13(cell + 53.0); float radius = mix(0.05, 0.22, sizeRnd * sizeRnd) * procSkyStarSize; // biased small, with occasional larger stars float d = length(fract(p) - sp); float aa = length(fwidth(p)) + 1e-4; float star = present * mix(0.3, 1.0, skyHash13(cell + 71.0)) * (1.0 - smoothstep(radius - aa, radius + aa, d)); col += vec3(star * procSkyStarBrightness * smoothstep(0.0, 0.04, dir.y)); // moon: a small, fairly sharp disk with no surrounding glow (edge AA'd via fwidth) float c = dot(dir, normalize(procSkyMoonDir)); float aaMoon = max(fwidth(c), 1e-5); float disk = smoothstep(cos(procSkyMoonSize) - aaMoon, cos(procSkyMoonSize) + aaMoon, c); col += procSkyMoonColor * procSkyMoonGlow * disk; return col; } `; const NIGHT_CORE_WGSL = /* wgsl */ ` uniform procSkyNightBlend: f32; uniform procSkyNightColor: vec3f; uniform procSkyNightBrightness: f32; uniform procSkyStarBrightness: f32; uniform procSkyStarDensity: f32; uniform procSkyStarSize: f32; uniform procSkyTwilightGlow: f32; uniform procSkyMoonDir: vec3f; uniform procSkyMoonColor: vec3f; uniform procSkyMoonSize: f32; uniform procSkyMoonGlow: f32; fn skyHash13(p3in: vec3f) -> f32 { var p3 = fract(p3in * 0.1031); p3 = p3 + dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } fn proceduralNight(dirIn: vec3f) -> vec3f { let dir = normalize(dirIn); var col = uniform.procSkyNightColor * uniform.procSkyNightBrightness * mix(1.1, 0.4, max(dir.y, 0.0)); let sunH = normalize(vec3f(uniform.procSkySunDir.x, 0.0, uniform.procSkySunDir.z) + vec3f(1e-5, 0.0, 0.0)); let toSun = max(dot(dir, sunH), 0.0); let belowFade = clamp(1.0 + uniform.procSkySunDir.y * 4.0, 0.0, 1.0); col = col + vec3f(1.0, 0.45, 0.15) * (pow(toSun, 5.0) * belowFade * uniform.procSkyTwilightGlow); let p = dir * 70.0; let cell = floor(p); let present = step(1.0 - uniform.procSkyStarDensity, skyHash13(cell)); let sp = vec3f(0.3) + 0.4 * vec3f(skyHash13(cell + 11.0), skyHash13(cell + 23.0), skyHash13(cell + 37.0)); let sizeRnd = skyHash13(cell + 53.0); let radius = mix(0.05, 0.22, sizeRnd * sizeRnd) * uniform.procSkyStarSize; let d = length(fract(p) - sp); let aa = length(fwidth(p)) + 1e-4; let star = present * mix(0.3, 1.0, skyHash13(cell + 71.0)) * (1.0 - smoothstep(radius - aa, radius + aa, d)); col = col + vec3f(star * uniform.procSkyStarBrightness * smoothstep(0.0, 0.04, dir.y)); let c = dot(dir, normalize(uniform.procSkyMoonDir)); let aaMoon = max(fwidth(c), 1e-5); let disk = smoothstep(cos(uniform.procSkyMoonSize) - aaMoon, cos(uniform.procSkyMoonSize) + aaMoon, c); col = col + uniform.procSkyMoonColor * uniform.procSkyMoonGlow * disk; return col; } `; const SKYBOX_OVERRIDE_GLSL = /* glsl */ ` #define LIT_SKYBOX_INTENSITY #include "envProcPS" #include "gammaPS" #include "tonemappingPS" #ifdef PREPASS_PASS varying float vLinearDepth; #include "floatAsUintPS" #endif varying vec3 vViewDir; ${SKY_CORE_GLSL} ${NIGHT_CORE_GLSL} void main(void) { #ifdef PREPASS_PASS gl_FragColor = float2vec4(vLinearDepth); #else // flip X to match the engine's env-atlas sampling convention, so the visible sun and // the IBL reflections of the sun line up vec3 dir = normalize(vViewDir); dir.x *= -1.0; // blend the day model with the procedural night sky by the time-of-day night factor. // The branch is coherent (same value for every sky pixel), so only one model is // evaluated except during the brief twilight crossfade. vec3 linear; float nb = procSkyNightBlend; if (nb <= 0.0) { linear = proceduralSky(dir); } else if (nb >= 1.0) { linear = proceduralNight(dir); } else { linear = mix(proceduralSky(dir), proceduralNight(dir), nb); } gl_FragColor = vec4(gammaCorrectOutput(toneMap(processEnvironment(linear))), 1.0); #endif } `; const SKYBOX_OVERRIDE_WGSL = /* wgsl */ ` #define LIT_SKYBOX_INTENSITY #include "envProcPS" #include "gammaPS" #include "tonemappingPS" #ifdef PREPASS_PASS varying vLinearDepth: f32; #include "floatAsUintPS" #endif varying vViewDir: vec3f; ${SKY_CORE_WGSL} ${NIGHT_CORE_WGSL} @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { var output: FragmentOutput; #ifdef PREPASS_PASS output.color = float2vec4(input.vLinearDepth); #else var dir = normalize(input.vViewDir); dir.x = dir.x * -1.0; // coherent branch: only the day or the night model runs except during the crossfade var linear: vec3f; let nb = uniform.procSkyNightBlend; if (nb <= 0.0) { linear = proceduralSky(dir); } else if (nb >= 1.0) { linear = proceduralNight(dir); } else { linear = mix(proceduralSky(dir), proceduralNight(dir), nb); } output.color = vec4f(gammaCorrectOutput(toneMap(processEnvironment(linear))), 1.0); #endif return output; } `; // Fullscreen pass that renders the procedural sky into an equirectangular RGBM render target, // which is then prefiltered by EnvLighting into the scene's env-atlas (the lighting source). const EQUIRECT_VS_GLSL = /* glsl */ ` attribute vec2 vertex_position; varying vec2 vUv0; void main(void) { gl_Position = vec4(vertex_position, 0.5, 1.0); vUv0 = vertex_position.xy * 0.5 + 0.5; } `; const EQUIRECT_VS_WGSL = /* wgsl */ ` attribute vertex_position: vec2f; varying vUv0: vec2f; @vertex fn vertexMain(input: VertexInput) -> VertexOutput { var output: VertexOutput; output.position = vec4f(input.vertex_position, 0.5, 1.0); output.vUv0 = input.vertex_position.xy * 0.5 + 0.5; return output; } `; const EQUIRECT_FS_GLSL = /* glsl */ ` varying vec2 vUv0; ${SKY_CORE_GLSL} // RGBM encode matching the engine's decodeRGBM (self contained - avoids relying on the // material pipeline's helpers such as saturate) vec4 encodeSkyRGBM(vec3 color) { vec3 c = pow(color, vec3(0.5)) * (1.0 / 8.0); float a = clamp(max(max(c.r, c.g), max(c.b, 1.0 / 255.0)), 0.0, 1.0); a = ceil(a * 255.0) / 255.0; return vec4(c / a, a); } void main(void) { // Map equirect uv -> direction so the baked equirect matches the orientation the engine's // reproject (sampleEquirect) expects. NOTE: the V flip differs between backends because // WebGPU renders to render targets Y-flipped while WebGL2 does not - see the WGSL variant. // This GLSL path only runs on WebGL2, so it uses (1.0 - vUv0.y). vec2 sph = (vec2(vUv0.x, 1.0 - vUv0.y) * 2.0 - 1.0) * vec2(PI_SKY, PI_SKY * 0.5); vec3 dir = vec3(cos(sph.y) * sin(sph.x), sin(sph.y), cos(sph.y) * cos(sph.x)); gl_FragColor = encodeSkyRGBM(proceduralSky(dir)); } `; const EQUIRECT_FS_WGSL = /* wgsl */ ` varying vUv0: vec2f; ${SKY_CORE_WGSL} // RGBM encode matching the engine's decodeRGBM (self contained) fn encodeSkyRGBM(color: vec3f) -> vec4f { let c = pow(color, vec3f(0.5)) * (1.0 / 8.0); var a = clamp(max(max(c.r, c.g), max(c.b, 1.0 / 255.0)), 0.0, 1.0); a = ceil(a * 255.0) / 255.0; return vec4f(c / a, a); } @fragment fn fragmentMain(input: FragmentInput) -> FragmentOutput { var output: FragmentOutput; // This WGSL path only runs on WebGPU, which renders to render targets Y-flipped relative to // WebGL2. To make the baked equirect match the orientation the engine's reproject expects // (and so match the WebGL2 result), the V coordinate is NOT flipped here - the opposite of // the GLSL variant. Without this the IBL ambient samples the wrong hemisphere and the scene // renders too bright (shadows washed out) on WebGPU. let sph = (vec2f(input.vUv0.x, input.vUv0.y) * 2.0 - 1.0) * vec2f(PI_SKY, PI_SKY * 0.5); let dir = vec3f(cos(sph.y) * sin(sph.x), sin(sph.y), cos(sph.y) * cos(sph.x)); output.color = encodeSkyRGBM(proceduralSky(dir)); return output; } `; const tmpX = new Vec3(); const tmpZ = new Vec3(); const tmpSrc = new Vec3(); const tmpMoon = new Vec3(); const sunRotMat = new Mat4(); const sunRotQuat = new Quat(); /** * Renders a procedurally generated sky (analytic Preetham daylight) and uses it to drive the * scene's image-based lighting. The visible skydome reuses the engine's built-in infinite sky by * overriding its `skyboxPS` shader chunk, so no extra mesh is created. The env-atlas used for * ambient and reflections is regenerated only when the sun moves (or sky parameters change), * keeping it cheap enough to update continuously for a day/night cycle. * * An optional directional light is kept in sync with the sun, so direct lighting and shadows match * the visible sky and its image-based lighting. * @category Rendering */ class ProceduralSky extends Script { static scriptName = 'proceduralSky'; /** * The sun azimuth in degrees (compass direction). * * @attribute * @range [0, 360] * @type {number} */ azimuth = 0; /** * The sun elevation in degrees above the horizon. Negative values place the sun below the * horizon (night). * * @attribute * @range [-90, 90] * @type {number} */ elevation = 25; /** * Atmosphere haziness. Higher values give a milkier sky and a larger sun glow. * * @attribute * @range [1, 20] * @type {number} */ turbidity = 2; /** * Rayleigh scattering amount, controls the overall blueness of the sky. * * @attribute * @range [0, 4] * @type {number} */ rayleigh = 1.5; /** * Mie (aerosol) scattering amount, controls the brightness of the glow around the sun. * * @attribute * @range [0, 0.1] * @type {number} */ mieCoefficient = 0.005; /** * Mie directional anisotropy, controls how tightly the glow hugs the sun. * * @attribute * @range [0, 1] * @type {number} */ mieDirectionalG = 0.8; /** * Overall HDR luminance scale of the sky radiance. * * @attribute * @range [0, 4] * @type {number} */ luminance = 1; /** * Resolution (height in texels) of the intermediate equirectangular sky used to generate the * lighting. The width is twice this. Small values are sufficient as the sky is low frequency. * * @attribute * @type {number} */ lightingResolution = 128; /** * Size of the prefiltered env-atlas used for ambient and reflections. * * @attribute * @type {number} */ atlasSize = 256; /** * The sun moves more than this many degrees before the (relatively cheaper) lighting is * regenerated. Keeps the cost down during a continuous day/night cycle. * * @attribute * @range [0, 10] * @type {number} */ lightingThreshold = 0.5; /** * Optional directional light entity kept in sync with the sun direction, color and intensity. * The light's own `intensity` (set by the user) is captured once and used as the daytime peak - * the script then fades it out below the horizon and crossfades to the moonlight at night. * * @attribute * @type {Entity} */ sunLight = null; /** * Daytime peak intensity, captured from the linked light's `intensity` on first use. * * @type {number|null} * @private */ _baseSunIntensity = null; /** * Colour of the moonlight the directional light fades to once the sun is below the horizon. * * @attribute * @type {Color} */ moonColor = new Color(0.792, 0.918, 1.0); /** * Intensity of the moonlight - the night key light, a dim cool light from above so the night * keeps subtle cool-toned shadows. * * @attribute * @range [0, 5] * @type {number} */ moonIntensity = 1.0; /** * World-space direction towards the moon; the night key light comes from here (typically high * in the sky). Normalized internally. * * @attribute * @type {Vec3} */ moonDirection = new Vec3(-1.53, 0.85, 0.35); /** * Deep night sky base colour. * * @attribute * @type {Color} */ nightColor = new Color(0.114, 0.247, 0.408); /** * Overall brightness of the night sky gradient. * * @attribute * @range [0, 1] * @type {number} */ nightBrightness = 0.052; /** * Star brightness (0 disables stars). * * @attribute * @range [0, 10] * @type {number} */ starBrightness = 0.05; /** * Star density - fraction of sky cells that contain a star. * * @attribute * @range [0, 1] * @type {number} */ starDensity = 0.8; /** * Star size multiplier applied to the per-star radius. * * @attribute * @range [0, 2] * @type {number} */ starSize = 0.5; /** * Strength of the warm twilight glow at the horizon as the sun sits just below it. * * @attribute * @range [0, 5] * @type {number} */ twilightGlow = 0.52; /** * Angular radius of the moon disk, in degrees. * * @attribute * @range [0, 10] * @type {number} */ moonSize = 0.6; /** * Brightness of the moon disk in the night sky (tinted by moonColor). * * @attribute * @range [0, 20] * @type {number} */ moonGlow = 3; /** @type {RenderTarget|null} */ _equirectRT = null; /** @type {import('playcanvas').Shader|null} */ _equirectShader = null; /** @type {Texture|null} */ _lightingSource = null; /** @type {Texture|null} */ _envAtlas = null; /** @private */ _origSkyboxGLSL = null; /** @private */ _origSkyboxWGSL = null; /** the sky-space sun direction last used for a lighting bake */ _bakedDir = new Vec3(0, -1, 0); /** signature of the sky parameters last used for a lighting bake */ _bakedParams = ''; /** reused world-space sun direction */ _sunDir = new Vec3(); /** reused sky-space sun direction (world with X flipped) */ _skyDir = new Vec3(); _warmColor = new Color(1.0, 0.6, 0.35); _zenithColor = new Color(1.0, 1.0, 0.98); _sunColor = new Color(); initialize() { const device = this.app.graphicsDevice; // override the built-in sky fragment shader so the engine's own infinite skydome renders // our procedural sky. Remember the originals so we can restore them on destroy. const glsl = ShaderChunks.get(device, SHADERLANGUAGE_GLSL); const wgsl = ShaderChunks.get(device, SHADERLANGUAGE_WGSL); this._origSkyboxGLSL = glsl.get('skyboxPS'); this._origSkyboxWGSL = wgsl.get('skyboxPS'); glsl.set('skyboxPS', SKYBOX_OVERRIDE_GLSL); wgsl.set('skyboxPS', SKYBOX_OVERRIDE_WGSL); // shader for the equirectangular sky -> lighting source pass this._equirectShader = ShaderUtils.createShader(device, { uniqueName: 'ProceduralSkyEquirect', attributes: { vertex_position: SEMANTIC_POSITION }, vertexGLSL: EQUIRECT_VS_GLSL, fragmentGLSL: EQUIRECT_FS_GLSL, vertexWGSL: EQUIRECT_VS_WGSL, fragmentWGSL: EQUIRECT_FS_WGSL }); // force a lighting bake on the first update this._bakedParams = ''; this.on('destroy', this._onDestroy, this); } /** @private */ _allocResources() { const device = this.app.graphicsDevice; const h = Math.max(8, Math.floor(this.lightingResolution)); const w = h * 2; // RGBM equirect render target - RGBA8 works on every device (no float-renderable needed) const colorBuffer = new Texture(device, { name: 'procedural-sky-equirect', width: w, height: h, format: PIXELFORMAT_RGBA8, type: TEXTURETYPE_RGBM, projection: TEXTUREPROJECTION_EQUIRECT, addressU: ADDRESS_CLAMP_TO_EDGE, addressV: ADDRESS_CLAMP_TO_EDGE, minFilter: FILTER_LINEAR, magFilter: FILTER_LINEAR, mipmaps: false }); this._equirectRT = new RenderTarget({ name: 'procedural-sky-equirect-rt', colorBuffer, depth: false }); } /** * Computes the world-space direction towards the sun from the azimuth / elevation. * * @param {Vec3} out - The vector to receive the result. * @returns {Vec3} The world-space sun direction. * @private */ _computeSunDir(out) { const el = this.elevation * Math.PI / 180; const az = this.azimuth * Math.PI / 180; const cosEl = Math.cos(el); return out.set(cosEl * Math.sin(az), Math.sin(el), cosEl * Math.cos(az)).normalize(); } /** * Computes the view-independent atmosphere terms (rayleigh/mie scattering coefficients and sun * intensity) and uploads them as uniforms. These are constant across the whole sky, so doing * them once per frame here avoids recomputing them for every sky pixel in the shader. (three.js * does the equivalent in its vertex shader; on the CPU is cheaper still since they don't vary * per vertex either.) * * @param {object} scope - The device scope to set the uniforms on. * @private */ _updateAtmosphere(scope) { const sunY = this._skyDir.y; // = sin(elevation) = dot(sunDir, up) // rayleigh const sunfade = 1.0 - Math.min(Math.max(1.0 - Math.exp(sunY), 0), 1); const rayleighCoeff = this.rayleigh - (1.0 - sunfade); scope.resolve('procSkyBetaR').setValue([ SKY_TOTAL_RAYLEIGH[0] * rayleighCoeff, SKY_TOTAL_RAYLEIGH[1] * rayleighCoeff, SKY_TOTAL_RAYLEIGH[2] * rayleighCoeff ]); // mie const mieC = 0.434 * (0.2 * this.turbidity * 1e-18) * this.mieCoefficient; scope.resolve('procSkyBetaM').setValue([ SKY_MIE_CONST[0] * mieC, SKY_MIE_CONST[1] * mieC, SKY_MIE_CONST[2] * mieC ]); // sun intensity const zenithCos = Math.max(-1, Math.min(1, sunY)); scope.resolve('procSkySunE').setValue( SKY_EE * Math.max(0, 1.0 - Math.exp(-((SKY_CUTOFF - Math.acos(zenithCos)) / SKY_STEEPNESS))) ); } /** * Regenerates the equirect sky and prefilters it into the env-atlas used for lighting. * * @private */ _bakeLighting() { const device = this.app.graphicsDevice; if (!this._equirectRT) { this._allocResources(); } // render the procedural sky into the equirect RGBM target (uniforms are already set on the // device scope by update()) drawQuadWithShader(device, this._equirectRT, this._equirectShader); // prefilter into a lighting source cubemap and then the env-atlas, reusing the targets this._lightingSource = EnvLighting.generateLightingSource(this._equirectRT.colorBuffer, { target: this._lightingSource, size: 64 }); const firstBake = !this._envAtlas; this._envAtlas = EnvLighting.generateAtlas(this._lightingSource, { target: this._envAtlas, size: this.atlasSize, numReflectionSamples: 256, numAmbientSamples: 512 }); // assign once - later bakes reuse the same texture object, so materials pick up the new // content without recreating the sky mesh if (firstBake) { this.app.scene.envAtlas = this._envAtlas; } } /** @private */ _updateSunLight() { const light = this.sunLight?.light; if (!light) return; // capture the user-authored light intensity as the daytime peak (before we start // overwriting it each frame) if (this._baseSunIntensity === null) { this._baseSunIntensity = light.intensity; } // crossfade the key light from the sun (day) to the dim cold moon from above (night). // The crossfade only starts once the sun dips below the horizon, so while the sun is // visible the light direction matches it exactly (specular reflections line up) const nightFactor = Math.max(0, Math.min(1, -this.elevation / 6)); // light source direction: towards the sun by day, towards the moon by night tmpMoon.copy(this.moonDirection).normalize(); const src = tmpSrc.lerp(this._sunDir, tmpMoon, nightFactor).normalize(); // A directional light emits along its entity's -Y (down) axis (see forward-renderer: // `wtm.getY(_direction).mulScalar(-1)`). Orient the entity so its +Y axis points towards // the light source, so the light travels along -src (from the source into the scene). // (lookAt would set the -Z axis instead, leaving the light pointing roughly straight down.) const ref = Math.abs(src.y) > 0.99 ? Vec3.RIGHT : Vec3.UP; tmpX.cross(src, ref).normalize(); tmpZ.cross(tmpX, src).normalize(); sunRotMat.set([ tmpX.x, tmpX.y, tmpX.z, 0, src.x, src.y, src.z, 0, tmpZ.x, tmpZ.y, tmpZ.z, 0, 0, 0, 0, 1 ]); sunRotQuat.setFromMat4(sunRotMat); this.sunLight.setRotation(sunRotQuat); // colour: warm -> white sun by day, cold moon by night const y = this._sunDir.y; const t = Math.max(0, Math.min(1, y / 0.4)); this._sunColor.lerp(this._warmColor, this._zenithColor, t); this._sunColor.lerp(this._sunColor, this.moonColor, nightFactor); light.color = this._sunColor; // intensity: the sun fades out below the horizon while the moon fades in const sunPart = this._baseSunIntensity * Math.max(0, Math.min(1, y / 0.1)); const moonPart = this.moonIntensity * nightFactor; light.intensity = sunPart + moonPart; } update(dt) { const device = this.app.graphicsDevice; // world-space sun direction, and sky-space direction (X flipped to match the engine's // env-atlas sampling convention used by the visible sky and reflections) this._computeSunDir(this._sunDir); this._skyDir.set(-this._sunDir.x, this._sunDir.y, this._sunDir.z); // push sky uniforms to the global scope - read by both the visible skybox and the // equirect lighting pass const scope = device.scope; scope.resolve('procSkySunDir').setValue([this._skyDir.x, this._skyDir.y, this._skyDir.z]); this._updateAtmosphere(scope); scope.resolve('procSkyMieG').setValue(this.mieDirectionalG); scope.resolve('procSkyLuminance').setValue(this.luminance); // night sky: blend factor from the sun elevation (day above +2 deg, night below -8 deg), // plus the night layer parameters. The moon disk reuses the moon light direction/colour. scope.resolve('procSkyNightBlend').setValue(Math.max(0, Math.min(1, (2 - this.elevation) / 10))); scope.resolve('procSkyNightColor').setValue([this.nightColor.r, this.nightColor.g, this.nightColor.b]); scope.resolve('procSkyNightBrightness').setValue(this.nightBrightness); scope.resolve('procSkyStarBrightness').setValue(this.starBrightness); scope.resolve('procSkyStarDensity').setValue(this.starDensity); scope.resolve('procSkyStarSize').setValue(this.starSize); scope.resolve('procSkyTwilightGlow').setValue(this.twilightGlow); scope.resolve('procSkyMoonColor').setValue([this.moonColor.r, this.moonColor.g, this.moonColor.b]); scope.resolve('procSkyMoonSize').setValue(this.moonSize * Math.PI / 180); scope.resolve('procSkyMoonGlow').setValue(this.moonGlow); tmpMoon.copy(this.moonDirection).normalize(); scope.resolve('procSkyMoonDir').setValue([-tmpMoon.x, tmpMoon.y, tmpMoon.z]); this._updateSunLight(); // regenerate the lighting only when the sun moved enough or the sky parameters changed const params = `${this.rayleigh}|${this.turbidity}|${this.mieCoefficient}|${this.mieDirectionalG}|${this.luminance}|${this.atlasSize}|${this.lightingResolution}`; const cosThreshold = Math.cos(this.lightingThreshold * Math.PI / 180); const moved = this._skyDir.dot(this._bakedDir) < cosThreshold; if (moved || params !== this._bakedParams) { this._bakeLighting(); this._bakedDir.copy(this._skyDir); this._bakedParams = params; } } /** @private */ _onDestroy() { const device = this.app.graphicsDevice; // restore the original sky shader chunks if (this._origSkyboxGLSL !== null) { ShaderChunks.get(device, SHADERLANGUAGE_GLSL).set('skyboxPS', this._origSkyboxGLSL); } if (this._origSkyboxWGSL !== null) { ShaderChunks.get(device, SHADERLANGUAGE_WGSL).set('skyboxPS', this._origSkyboxWGSL); } if (this.app.scene.envAtlas === this._envAtlas) { this.app.scene.envAtlas = null; } this._equirectRT?.colorBuffer?.destroy(); this._equirectRT?.destroy(); this._lightingSource?.destroy(); this._envAtlas?.destroy(); this._equirectRT = null; this._lightingSource = null; this._envAtlas = null; } } export { ProceduralSky };