# ============================================================================ # daylight.ludic — a time of day over the HDRI sky. The photograph is one late # morning; the game wants a whole day. The sun's direction and colour follow a # simple solar arc from the time (`daylight_set`), the sky image turns to keep # its disc under that sun, the image-based light is scaled toward a deep-blue # night (`u_ibl_scale`), and after dusk the light becomes a moon so the world # still has shadows and form. A campfire is the one point light (`u_fire_*`). # The convolved sky maps are rebuilt only when the sky has turned far from the # angle they were baked at; the height-field shadow rebakes as the sun moves. # ============================================================================ # The moon's place in its month, 0 new .. 0.5 full .. 1 new again. It decides the disc's # terminator, how much light reaches the ground, and where in the sky it rides: a full # moon is opposite the sun and rises at sunset, a new one travels with it. function daylight_init(render3d_st: mut Render3dState) -> void { render3d_st.day_ibl = v3_new(1.0, 1.0, 1.0) render3d_st.day_sun_base = v3_new(render3d_st.sun_color[0], render3d_st.sun_color[1], render3d_st.sun_color[2]) render3d_st.fire_pos = v3_new(0.0, -1000.0, 0.0) render3d_st.fire_color = v3_new(0.0, 0.0, 0.0) render3d_st.day_moon_dir = v3_new(0.0, 1.0, 0.0) render3d_st.hand_pos = v3_new(0.0, -1000.0, 0.0); render3d_st.hand_color = v3_new(0.0, 0.0, 0.0); render3d_st.hand_dir = v3_new(0.0, 0.0, -1.0); render3d_st.hand_cone = -2.0; render3d_st.hand_reach = 12.0 render3d_st.day_light = 1.0 } # Start the clock: the scene as tuned (sky yaw `yaw0`) is the photograph's hour `hour0`; # the sun rises and sets toward `set_yaw` (the direction it should be in at 19:00). function daylight_start(render3d_st: mut Render3dState, yaw0: float, hour0: float, set_yaw: float) -> void { render3d_st.day_yaw0 = yaw0; render3d_st.day_hour0 = hour0 render3d_st.day_az0 = Math.atan2(-render3d_st.sun_dir[0], -render3d_st.sun_dir[2]) render3d_st.day_sky_baked = yaw0 # which way does the sun travel? the way that puts it nearest `set_yaw` at 19:00 let step = (19.0 - hour0) * Math.deg_to_rad(15.0) let da = Math.abs(day_wrap(render3d_st.day_az0 + step - set_yaw)) let db = Math.abs(day_wrap(render3d_st.day_az0 - step - set_yaw)) render3d_st.day_dir = 1.0 if db < da { render3d_st.day_dir = -1.0 } render3d_st.day_on = true render3d_st.day_baked_az = 1000.0 daylight_set(render3d_st, hour0) } function day_wrap(a: float) -> float { let two_pi = 2.0 * PI var d = a while d > PI { d = d - two_pi } while d < -PI { d = d + two_pi } return d } function smoothf(a: float, b: float, x: float) -> float { let t = Math.clamp((x - a) / (b - a), 0.0, 1.0) return t * t * (3.0 - 2.0 * t) } function daylight_set(render3d_st: mut Render3dState, hours: float) -> void { var h = hours % 24.0 if h < 0.0 { h = h + 24.0 } render3d_st.day_hours = h # the arc: up at 5:30, highest (about 57 degrees) at 12:45, down at 20:00 let t = (h - 5.5) / 14.5 * PI let el = Math.deg_to_rad(-6.0 + 63.0 * Math.sin(t)) let az = render3d_st.day_az0 + (h - render3d_st.day_hour0) * Math.deg_to_rad(15.0) * render3d_st.day_dir render3d_st.day_az = az; render3d_st.day_el = el let d = smoothf(Math.deg_to_rad(-8.0), Math.deg_to_rad(12.0), el) render3d_st.day_light = d # the sun, warm and dim near the horizon; past dusk, the moon from across the sky var laz = az; var lel = Math.max(el, Math.deg_to_rad(3.0)) var warm_r = 1.0; var warm_g = 1.0; var warm_b = 1.0 let low = smoothf(0.0, Math.deg_to_rad(24.0), el) warm_g = Math.lerp(0.55, 1.0, low); warm_b = Math.lerp(0.28, 1.0, low) # an overcast sky: the sun goes diffuse and grey, a lightning flash brings it back white let oc = Math.clamp(render3d_st.day_overcast, 0.0, 1.0) let sunk = 1.0 - 0.92 * oc + 2.5 * render3d_st.day_flash var sr = render3d_st.day_sun_base[0] * (d * warm_r * sunk) var sg = render3d_st.day_sun_base[1] * (d * warm_g * sunk) var sb = render3d_st.day_sun_base[2] * (d * warm_b * sunk) # The moon rides a lag behind the sun that is its phase: full is opposite (half a turn), # new is alongside. Its elevation follows the same arc, offset by the same amount, so a # full moon rises as the sun sets and a new moon is up all day and invisible. # The moon is where the sun was `lag` of a day ago: at full that is half a day, so it # rises as the sun sets; at new it is alongside the sun and up all day, invisible. The # sign matters — a waxing crescent has to set AFTER the sun, not before it. let moon_lag = 2.0 * PI * render3d_st.day_moon_phase let maz = az - moon_lag * render3d_st.day_dir let mel = Math.deg_to_rad(-6.0 + 63.0 * Math.sin(t - moon_lag)) let mce = Math.cos(mel) v3_set(render3d_st.day_moon_dir, -(Math.sin(maz) * mce), Math.sin(mel), -(Math.cos(maz) * mce)) render3d_st.day_moon = false if el < Math.deg_to_rad(-7.0) { render3d_st.day_moon = true laz = maz lel = Math.clamp(mel, Math.deg_to_rad(6.0), Math.deg_to_rad(70.0)) let m = smoothf(Math.deg_to_rad(-7.0), Math.deg_to_rad(-16.0), el) # what the moon is worth on the ground, by how much of it is lit. A new moon is a # properly dark night, which is what makes a torch and a lantern matter. let up = smoothf(Math.deg_to_rad(-4.0), Math.deg_to_rad(8.0), mel) let lit = m * up * (0.06 + 0.94 * render3d_st.day_moon_illum) sr = render3d_st.day_sun_base[0] * (0.0130 * lit) sg = render3d_st.day_sun_base[1] * (0.0165 * lit) sb = render3d_st.day_sun_base[2] * (0.0250 * lit) } let ce = Math.cos(lel) v3_set(render3d_st.sun_dir, -(Math.sin(laz) * ce), Math.sin(lel), -(Math.cos(laz) * ce)) v3_set(render3d_st.sun_color, sr, sg, sb) # the sky's light: full by day, a deep blue by night, amber through the dusk let dusk = smoothf(Math.deg_to_rad(-10.0), Math.deg_to_rad(2.0), el) * (1.0 - smoothf(Math.deg_to_rad(2.0), Math.deg_to_rad(18.0), el)) # a full moon lifts the night's own ambient nearly threefold; a new moon leaves it alone var moonlit = 1.0 if render3d_st.day_moon { moonlit = 1.0 + 1.8 * render3d_st.day_moon_illum } v3_set(render3d_st.day_ibl, Math.lerp(0.020 * moonlit, 1.0, d), Math.lerp(0.026 * moonlit, 1.0, d), Math.lerp(0.045 * moonlit, 1.0, d)) render3d_st.day_ibl[0] = render3d_st.day_ibl[0] * (1.0 + 0.35 * dusk) render3d_st.day_ibl[2] = render3d_st.day_ibl[2] * (1.0 - 0.25 * dusk) # clouds: less light, and greyer (the blue and the warmth both fade) let grey = (render3d_st.day_ibl[0] + (render3d_st.day_ibl[1] + render3d_st.day_ibl[2])) * 0.3333 + 0.6 * render3d_st.day_flash let dim = 1.0 - 0.55 * oc for i in 0 .. 3 { render3d_st.day_ibl[i] = Math.lerp(render3d_st.day_ibl[i], grey, 0.7 * oc) * dim } # ---- the air --------------------------------------------------------------------------- # Aerial perspective is a CURVE, not the one constant it was. A low sun is shining through # far more air than a high one, and it is shining ALONG the ground rather than down onto it, # so three things move together with its elevation: how much air there is, how hard that air # scatters the sun forward, and how low in the valley it lies. `lowsun` peaks at the horizon # and falls away on both sides - it has to fall away BELOW it too, or the middle of the night # gets a dawn's haze with no dawn to justify it. Overcast thickens the air and flattens the # scatter, because a grey sky has no disc to scatter from. The player's slider still # multiplies the result, so nobody loses the setting they chose. if render3d_st.day_fog_base == 0.0 { render3d_st.day_fog_base = render3d_st.r3d_fog_density } let fog_rise = smoothf(Math.deg_to_rad(-12.0), Math.deg_to_rad(1.0), el) let fog_high = smoothf(Math.deg_to_rad(2.0), Math.deg_to_rad(26.0), el) let lowsun = fog_rise * (1.0 - fog_high) var dens = render3d_st.day_fog_base * (1.0 + 1.5 * lowsun) dens = dens * (1.0 + 1.2 * oc) render3d_st.r3d_fog_density = dens * render3d_st.day_fog_mul * render3d_st.r3d_fog_scale # how fast the haze thins with height: a settled morning's lies IN the valley, a noon sky is # thin all the way up, so the falloff rises as the sun drops render3d_st.r3d_fog_falloff = 0.002 * (1.0 + 1.6 * lowsun) # the glow a ridge is silhouetted against when you look into a low sun render3d_st.r3d_fog_inscatter = (0.012 + 0.16 * lowsun) * (1.0 - 0.7 * oc) # and distance takes colour away at every hour, harder under cloud # 1.9 was tuned on a valley whose rock was grey anyway. Maroon's air is thin, dry and at # 2900 m, and the Bells are only a couple of kilometres from the lake - in the photograph # everybody knows, they are still plainly RED at that distance. Desaturating them to a pale # grey-pink is physically defensible and loses the one thing the range is named for, so the # basin's own air gets a gentler figure and overcast still takes colour away faster. render3d_st.r3d_fog_desat = 1.15 + 0.7 * oc # ---- the grade ------------------------------------------------------------------------- # The look of an hour is not only how much air is in front of the mountain; it is what colour # the light is and what the shadows are filled with. Noon is the case worth naming: direct # sun is warm-white and the ONLY thing filling a midday shadow is a blue sky, so a midday # frame wants a cool, lifted shadow and a hard contrast between it and the lit ground. Dawn # and dusk are the opposite - a warm low sun, warm shadow fill off the whole sky, and less # contrast because the light is coming through so much air. Overcast flattens and drains both. # `night` is deliberately NOT `1 - d`: d is already falling while the sun is still on the # horizon, so the two curves would fight and the warmest minute of the day would come out # half-cooled. It only begins under the horizon. let night = 1.0 - smoothf(Math.deg_to_rad(-14.0), Math.deg_to_rad(-4.0), el) let hi = fog_high render3d_st.post_wb_r = 1.02 + (0.10 * lowsun - (0.03 * hi + 0.06 * night)) render3d_st.post_wb_g = 1.0 + 0.012 * lowsun render3d_st.post_wb_b = 0.97 + (0.055 * hi + 0.13 * night - 0.09 * lowsun) # the shadows' floor: warm and open into a low sun, blue at noon, cold and crushed at night render3d_st.post_lift_r = 0.004 + 0.013 * lowsun - 0.002 * (hi + night) render3d_st.post_lift_g = 0.004 + 0.008 * lowsun - 0.001 * night render3d_st.post_lift_b = 0.012 + (0.004 * lowsun + 0.013 * hi) # Gain is left alone on purpose. It looks like a highlight control and is not: the grade is # `c * gain + lift * (1 - c)`, so warming the gain warms the WHOLE frame, and warming it at # noon undid the cool white balance above and turned one o'clock yellower than seven in the # morning - the opposite of the thing this grade exists to do. Midday's punch comes from # contrast, and midday's colour from a cool balance over a blue shadow. render3d_st.post_gain_r = 0.99 render3d_st.post_gain_g = 0.995 render3d_st.post_gain_b = 1.0 render3d_st.post_contrast = 1.12 + 0.11 * hi - (0.17 * oc + 0.06 * night) render3d_st.post_saturation = 1.04 + 0.12 * lowsun - (0.20 * oc + 0.12 * night) # The visible sky is relit by the hour (sky.frag). It fades out under the horizon, where the # night's own tint, stars and moon take over, and it eases off under heavy cloud - a relit # overcast is still overcast, and driving a clear-sky model hard through one paints a blue # zenith onto a grey day. render3d_st.r3d_sky_relight = smoothf(Math.deg_to_rad(-10.0), Math.deg_to_rad(1.0), el) * (1.0 - 0.75 * oc) # ---- what is IN the air ------------------------------------------------------------- # The volumetric march (post.ludic) needs the same story the analytic fog tells, or the # shafts and the haze disagree. Density rides the sun's elevation like everything else; the # MIST is separate and is the thing a valley actually does - a shallow layer that forms in # the cold at either end of the day, lies ON the ground rather than filling the basin, and # burns off by mid-morning. Cloud thickens the air and kills the shafts, because a shaft # needs a disc to come from. render3d_st.post_vol_density = (0.00030 + 0.00070 * lowsun) * (1.0 - 0.55 * oc) render3d_st.post_vol_mist = 0.40 * lowsun * (1.0 - 0.4 * oc) render3d_st.post_vol_falloff = 0.006 + 0.004 * lowsun # R3D_NOAIR=1: the air as it was before any of the above - one density, one falloff, the # inscatter the shader used to hard-code, and no distance desaturation at all. It is here # so a before-and-after can be shot from ONE binary at one hour, which is the only kind of # comparison worth looking at, and it joins R3D_NOCLOUD / R3D_NOSHADOW / R3D_NOGI. if r3d_env_has(render3d_st, "R3D_NOAIR") { render3d_st.r3d_fog_density = render3d_st.day_fog_base * render3d_st.day_fog_mul * render3d_st.r3d_fog_scale render3d_st.r3d_fog_falloff = 0.002 render3d_st.r3d_fog_inscatter = 0.02 render3d_st.r3d_fog_desat = 0.0 render3d_st.post_wb_r = 1.02; render3d_st.post_wb_g = 1.0; render3d_st.post_wb_b = 0.97 render3d_st.post_lift_r = 0.004; render3d_st.post_lift_g = 0.004; render3d_st.post_lift_b = 0.012 render3d_st.post_gain_r = 0.99; render3d_st.post_gain_g = 0.995; render3d_st.post_gain_b = 1.0 render3d_st.post_contrast = 1.12; render3d_st.post_saturation = 1.04 render3d_st.r3d_sky_relight = 0.0 render3d_st.post_vol_density = 0.0; render3d_st.post_vol_mist = 0.0 } # exposure: auto-exposure must not turn the night into day # the ceiling has to move with the moon or auto-exposure eats the difference between a # full-moon night and a new-moon one var night_max = 4.5 if render3d_st.day_moon { night_max = 4.5 + 3.5 * render3d_st.day_moon_illum } render3d_st.post_exposure_max = Math.lerp(night_max, 20.0, d) # the visible sky turns with the sun (cheap); its convolutions rebake when far off let sky_yaw_now = render3d_st.day_yaw0 + (h - render3d_st.day_hour0) * Math.deg_to_rad(15.0) * render3d_st.day_dir sky_set_rot(render3d_st, sky_yaw_now) if Math.abs(day_wrap(sky_yaw_now - render3d_st.day_sky_baked)) > Math.deg_to_rad(35.0) and d > 0.05 { render3d_st.day_sky_baked = sky_yaw_now sky_precompute(render3d_st) } # the terrain's baked shadow follows the light in steps if Math.abs(day_wrap(laz - render3d_st.day_baked_az)) > Math.deg_to_rad(4.0) or Math.abs(lel - render3d_st.day_baked_el) > Math.deg_to_rad(3.0) { render3d_st.day_baked_az = laz; render3d_st.day_baked_el = lel render3d_st.day_gen += 1 } } # where the moon is in its month; the game advances this each morning function daylight_moon(render3d_st: mut Render3dState, phase: float) -> void { var p = phase % 1.0 if p < 0.0 { p = p + 1.0 } render3d_st.day_moon_phase = p # illuminated fraction: (1 - cos(2 pi p)) / 2, which is 0 at new and 1 at full render3d_st.day_moon_illum = (1.0 - Math.cos(2.0 * PI * p)) * 0.5 daylight_set(render3d_st, render3d_st.day_hours) } # the weather over the valley: overcast 0..1, a fog multiplier, a lightning flash 0..1 function daylight_weather(render3d_st: mut Render3dState, overcast: float, fog_mul: float, flash: float) -> void { render3d_st.day_overcast = overcast; render3d_st.day_fog_mul = fog_mul; render3d_st.day_flash = flash } # the campfire: a point light at (x, y, z) of `strength` (0 = out) function daylight_fire(render3d_st: Render3dState, x: float, y: float, z: float, strength: float) -> void { v3_set(render3d_st.fire_pos, x, y, z) v3_set(render3d_st.fire_color, 9.0 * strength, 4.6 * strength, 1.4 * strength) } # the light in the hand: a point light (cone < -1) or a cone along dir (cone = cos half-angle) # A torch or a flashlight in the hand. `reach` is how far it carries, in metres: the # falloff is a gentle power out to it and nothing past it (lighting.glsl handLight), which # is what a pool of firelight looks like. It used to be a windowed inverse square with the # window and the scale both hard-coded, so every hand light in every game had the reach of # a candle whatever it was meant to be. function daylight_hand(render3d_st: mut Render3dState, x: float, y: float, z: float, dx: float, dy: float, dz: float, cone: float, reach: float, r: float, g: float, b: float) -> void { v3_set(render3d_st.hand_pos, x, y, z); v3_set(render3d_st.hand_dir, dx, dy, dz); render3d_st.hand_cone = cone v3_set(render3d_st.hand_color, r, g, b) render3d_st.hand_reach = reach } function daylight_bind(render3d_st: mut Render3dState, prog: int) -> void { u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_hand_pos"), render3d_st.hand_pos) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_hand_color"), render3d_st.hand_color) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_hand_dir"), render3d_st.hand_dir) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_hand_cone"), render3d_st.hand_cone) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_hand_reach"), render3d_st.hand_reach) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_ibl_scale"), render3d_st.day_ibl) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_moon_dir"), render3d_st.day_moon_dir) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_moon_phase"), render3d_st.day_moon_phase) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_moon_illum"), render3d_st.day_moon_illum) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_moon_haze"), render3d_st.day_overcast) u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_daylight"), render3d_st.day_light) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_fire_pos"), render3d_st.fire_pos) u_v3(render3d_st, gpu_uniform(render3d_st, prog, "u_fire_color"), render3d_st.fire_color) }