Aerial perspective is a curve now. A low sun shines through far more air than a high one and shines ALONG the ground rather than down onto it, so density, height falloff and forward scatter all ride the sun's elevation; overcast thickens the air and flattens the scatter, because a grey sky has no disc to scatter from. `lowsun` falls away BELOW the horizon as well as above it, or the middle of the night gets a dawn's haze with no dawn to justify it. The term that was missing entirely is distance DESATURATION. Blending a saturated green ridge toward a saturated blue noon sky leaves a saturated ridge - which is why the same valley read as a photograph at dusk, where the fog colour happened to be a warm grey, and as a toy at one o'clock. A surface is now pulled toward its own luminance faster than the fog itself arrives. Measured far/near saturation at the camp: 07:00 1.11 -> 0.89, 09:00 1.04 -> 0.93, 13:00 0.98 -> 0.89. The grade is the hour's too - nine literals bound at the draw, written by daylight_set now. Noon is the case worth naming: direct sun is warm-white and the only thing filling a midday shadow is a blue sky, so noon gets a cool balance over a blue-lifted shadow with hard contrast, and dawn and dusk the reverse. Ground R-B, lit vs shadowed: 07:00 +42.8/+14.2 -> +48.9/+15.1, 13:00 +32.2/+14.8 -> +25.2/+2.9. Gain is left alone deliberately: the grade is `c * gain + lift * (1 - c)`, so warming it warms the whole frame, and warming it at noon made one o'clock yellower than seven in the morning - the opposite of the point. The visible sky is relit. Turning a photograph on its axis does not change what colour it was taken at, so every sunset had a mid-morning blue overhead. An analytic sky supplies the chroma and the photograph keeps the luminance: the cloud stays where it is and goes orange at dusk, the zenith goes deep blue at noon, and no second sky is shipped. It fades out under the horizon and eases off under cloud. The ground bounce follows the ground, crossing meadow to rock at the map's treeline instead of being one green constant everywhere including above the scree. R3D_NOAIR=1 restores all of it, so a before-and-after comes from one binary at one hour; it joins R3D_NOCLOUD / R3D_NOSHADOW / R3D_NOGI. Verified on macOS OpenGL, macOS Vulkan (MoltenVK) and Windows Vulkan (RTX 3070 Ti). Backends agree: mean difference 0.15-0.88/255 within a machine. Across machines the ORIGINAL renderer already differed by 5.02/255 at 19:12 and this build differs by 2.80, so cross-platform variance is pre-existing and did not grow. 400 frames: GL 7.4 s before and after, VK 7.0 s before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
16 KiB
Text
277 lines
16 KiB
Text
# ============================================================================
|
|
# 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.
|
|
# ============================================================================
|
|
|
|
var day_on: bool = false
|
|
var day_hours: int = 0 # float bits, 0 .. 24
|
|
var day_light: int = 0 # 0 night .. 1 full day (float bits)
|
|
var day_ibl: words = null # rgb scale on the sky's light
|
|
var day_sun_base: words = null # the HDRI's sun radiance, kept
|
|
var day_az0: int = 0 # the sun's azimuth at the reference hour (radians, yaw convention)
|
|
var day_yaw0: int = 0 # the sky yaw the scene was tuned at
|
|
var day_hour0: int = 0 # the hour the photograph was taken (10.5)
|
|
var day_dir: int = 0 # +1 / -1: which way the sun travels in yaw
|
|
var day_az: int = 0
|
|
var day_el: int = 0
|
|
var day_gen: int = 0 # bumps when the light moved enough to rebake the terrain shadow
|
|
var day_baked_az: int = 0
|
|
var day_baked_el: int = 0
|
|
var day_sky_baked: int = 0 # the sky yaw the convolutions were baked at
|
|
var fire_pos: words = null
|
|
var fire_color: words = null
|
|
var day_moon: bool = false
|
|
# 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.
|
|
var day_moon_phase: int = 0x3F000000 # 0.5: full, which is where the game used to be
|
|
var day_moon_illum: int = 0x3F800000 # the lit fraction, derived from the phase
|
|
var day_moon_dir: words = null # toward the moon, whether or not it is up
|
|
var hand_pos: words = null
|
|
var hand_color: words = null
|
|
var hand_dir: words = null
|
|
var hand_cone: int = 0
|
|
var day_overcast: int = 0 # 0 clear .. 1 a low grey sky (float bits): dims the sun and the sky's light
|
|
var day_flash: int = 0 # a lightning flash this frame (0..1): the sun brightens for it
|
|
var day_fog_mul: int = 0x3F800000 # multiplies the base fog density (rain and snow thicken the air)
|
|
var day_fog_base: int = 0
|
|
|
|
function daylight_init() -> void {
|
|
day_ibl = v3_new(F_ONE, F_ONE, F_ONE)
|
|
day_sun_base = v3_new(sun_color[0], sun_color[1], sun_color[2])
|
|
fire_pos = v3_new(F_ZERO, fi(-1000), F_ZERO)
|
|
fire_color = v3_new(F_ZERO, F_ZERO, F_ZERO)
|
|
day_moon_dir = v3_new(F_ZERO, F_ONE, F_ZERO)
|
|
hand_pos = v3_new(F_ZERO, fi(-1000), F_ZERO); hand_color = v3_new(F_ZERO, F_ZERO, F_ZERO); hand_dir = v3_new(F_ZERO, F_ZERO, f_neg(F_ONE)); hand_cone = f_neg(F_TWO)
|
|
day_light = F_ONE
|
|
}
|
|
|
|
# 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(yaw0: int, hour0: int, set_yaw: int) -> void {
|
|
day_yaw0 = yaw0; day_hour0 = hour0
|
|
day_az0 = f_atan2(f_neg(sun_dir[0]), f_neg(sun_dir[2]))
|
|
day_sky_baked = yaw0
|
|
# which way does the sun travel? the way that puts it nearest `set_yaw` at 19:00
|
|
let step = f_mul(f_sub(fl(19.0), hour0), f_rad(fi(15)))
|
|
let da = f_abs(day_wrap(f_sub(f_add(day_az0, step), set_yaw)))
|
|
let db = f_abs(day_wrap(f_sub(f_sub(day_az0, step), set_yaw)))
|
|
day_dir = F_ONE
|
|
if f_ls(db, da) { day_dir = f_neg(F_ONE) }
|
|
day_on = true
|
|
day_baked_az = fi(1000)
|
|
daylight_set(hour0)
|
|
}
|
|
function day_wrap(a: int) -> int {
|
|
let two_pi = f_mul(F_TWO, F_PI)
|
|
var d = a
|
|
while f_gt(d, F_PI) { d = f_sub(d, two_pi) }
|
|
while f_ls(d, f_neg(F_PI)) { d = f_add(d, two_pi) }
|
|
return d
|
|
}
|
|
function smoothf(a: int, b: int, x: int) -> int {
|
|
let t = f_clamp(f_div(f_sub(x, a), f_sub(b, a)), F_ZERO, F_ONE)
|
|
return f_mul(f_mul(t, t), f_sub(fi(3), f_mul(F_TWO, t)))
|
|
}
|
|
|
|
function daylight_set(hours: int) -> void {
|
|
var h = f_mod(hours, fi(24))
|
|
if f_ls(h, F_ZERO) { h = f_add(h, fi(24)) }
|
|
day_hours = h
|
|
# the arc: up at 5:30, highest (about 57 degrees) at 12:45, down at 20:00
|
|
let t = f_mul(f_div(f_sub(h, fl(5.5)), fl(14.5)), F_PI)
|
|
let el = f_rad(f_add(fi(-6), f_mul(fi(63), f_sin(t))))
|
|
let az = f_add(day_az0, f_mul(f_mul(f_sub(h, day_hour0), f_rad(fi(15))), day_dir))
|
|
day_az = az; day_el = el
|
|
let d = smoothf(f_rad(fi(-8)), f_rad(fi(12)), el)
|
|
day_light = d
|
|
# the sun, warm and dim near the horizon; past dusk, the moon from across the sky
|
|
var laz = az; var lel = f_max(el, f_rad(fi(3)))
|
|
var warm_r = F_ONE; var warm_g = F_ONE; var warm_b = F_ONE
|
|
let low = smoothf(F_ZERO, f_rad(fi(24)), el)
|
|
warm_g = f_lerp(fl(0.55), F_ONE, low); warm_b = f_lerp(fl(0.28), F_ONE, low)
|
|
# an overcast sky: the sun goes diffuse and grey, a lightning flash brings it back white
|
|
let oc = f_clamp(day_overcast, F_ZERO, F_ONE)
|
|
let sunk = f_add(f_sub(F_ONE, f_mul(fl(0.92), oc)), f_mul(fl(2.5), day_flash))
|
|
var sr = f_mul(day_sun_base[0], f_mul(f_mul(d, warm_r), sunk))
|
|
var sg = f_mul(day_sun_base[1], f_mul(f_mul(d, warm_g), sunk))
|
|
var sb = f_mul(day_sun_base[2], f_mul(f_mul(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 = f_mul(f_mul(F_TWO, F_PI), day_moon_phase)
|
|
let maz = f_sub(az, f_mul(moon_lag, day_dir))
|
|
let mel = f_rad(f_add(fi(-6), f_mul(fi(63), f_sin(f_sub(t, moon_lag)))))
|
|
let mce = f_cos(mel)
|
|
v3_set(day_moon_dir, f_neg(f_mul(f_sin(maz), mce)), f_sin(mel), f_neg(f_mul(f_cos(maz), mce)))
|
|
day_moon = false
|
|
if f_ls(el, f_rad(fi(-7))) {
|
|
day_moon = true
|
|
laz = maz
|
|
lel = f_clamp(mel, f_rad(fi(6)), f_rad(fi(70)))
|
|
let m = smoothf(f_rad(fi(-7)), f_rad(fi(-16)), 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(f_rad(fi(-4)), f_rad(fi(8)), mel)
|
|
let lit = f_mul(f_mul(m, up), f_add(fl(0.06), f_mul(fl(0.94), day_moon_illum)))
|
|
sr = f_mul(day_sun_base[0], f_mul(fl(0.0130), lit))
|
|
sg = f_mul(day_sun_base[1], f_mul(fl(0.0165), lit))
|
|
sb = f_mul(day_sun_base[2], f_mul(fl(0.0250), lit))
|
|
}
|
|
let ce = f_cos(lel)
|
|
v3_set(sun_dir, f_neg(f_mul(f_sin(laz), ce)), f_sin(lel), f_neg(f_mul(f_cos(laz), ce)))
|
|
v3_set(sun_color, sr, sg, sb)
|
|
# the sky's light: full by day, a deep blue by night, amber through the dusk
|
|
let dusk = f_mul(smoothf(f_rad(fi(-10)), f_rad(fi(2)), el), f_sub(F_ONE, smoothf(f_rad(fi(2)), f_rad(fi(18)), el)))
|
|
# a full moon lifts the night's own ambient nearly threefold; a new moon leaves it alone
|
|
var moonlit = F_ONE
|
|
if day_moon { moonlit = f_add(F_ONE, f_mul(fl(1.8), day_moon_illum)) }
|
|
v3_set(day_ibl, f_lerp(f_mul(fl(0.020), moonlit), F_ONE, d), f_lerp(f_mul(fl(0.026), moonlit), F_ONE, d), f_lerp(f_mul(fl(0.045), moonlit), F_ONE, d))
|
|
day_ibl[0] = f_mul(day_ibl[0], f_add(F_ONE, f_mul(fl(0.35), dusk)))
|
|
day_ibl[2] = f_mul(day_ibl[2], f_sub(F_ONE, f_mul(fl(0.25), dusk)))
|
|
# clouds: less light, and greyer (the blue and the warmth both fade)
|
|
let grey = f_add(f_mul(f_add(day_ibl[0], f_add(day_ibl[1], day_ibl[2])), fl(0.3333)), f_mul(fl(0.6), day_flash))
|
|
let dim = f_sub(F_ONE, f_mul(fl(0.55), oc))
|
|
for i in 0 .. 3 { day_ibl[i] = f_mul(f_lerp(day_ibl[i], grey, f_mul(fl(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 day_fog_base == 0 { day_fog_base = r3d_fog_density }
|
|
let fog_rise = smoothf(f_rad(fi(-12)), f_rad(fi(1)), el)
|
|
let fog_high = smoothf(f_rad(fi(2)), f_rad(fi(26)), el)
|
|
let lowsun = f_mul(fog_rise, f_sub(F_ONE, fog_high))
|
|
var dens = f_mul(day_fog_base, f_add(F_ONE, f_mul(fl(1.5), lowsun)))
|
|
dens = f_mul(dens, f_add(F_ONE, f_mul(fl(1.2), oc)))
|
|
r3d_fog_density = f_mul(f_mul(dens, day_fog_mul), 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
|
|
r3d_fog_falloff = f_mul(fl(0.002), f_add(F_ONE, f_mul(fl(1.6), lowsun)))
|
|
# the glow a ridge is silhouetted against when you look into a low sun
|
|
r3d_fog_inscatter = f_mul(f_add(fl(0.012), f_mul(fl(0.16), lowsun)), f_sub(F_ONE, f_mul(fl(0.7), oc)))
|
|
# and distance takes colour away at every hour, harder under cloud
|
|
r3d_fog_desat = f_add(fl(1.9), f_mul(fl(0.6), 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 = f_sub(F_ONE, smoothf(f_rad(fi(-14)), f_rad(fi(-4)), el))
|
|
let hi = fog_high
|
|
post_wb_r = f_add(fl(1.02), f_sub(f_mul(fl(0.10), lowsun), f_add(f_mul(fl(0.03), hi), f_mul(fl(0.06), night))))
|
|
post_wb_g = f_add(F_ONE, f_mul(fl(0.012), lowsun))
|
|
post_wb_b = f_add(fl(0.97), f_sub(f_add(f_mul(fl(0.055), hi), f_mul(fl(0.13), night)), f_mul(fl(0.09), lowsun)))
|
|
# the shadows' floor: warm and open into a low sun, blue at noon, cold and crushed at night
|
|
post_lift_r = f_sub(f_add(fl(0.004), f_mul(fl(0.013), lowsun)), f_mul(fl(0.002), f_add(hi, night)))
|
|
post_lift_g = f_sub(f_add(fl(0.004), f_mul(fl(0.008), lowsun)), f_mul(fl(0.001), night))
|
|
post_lift_b = f_add(fl(0.012), f_add(f_mul(fl(0.004), lowsun), f_mul(fl(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.
|
|
post_gain_r = fl(0.99)
|
|
post_gain_g = fl(0.995)
|
|
post_gain_b = F_ONE
|
|
post_contrast = f_sub(f_add(fl(1.12), f_mul(fl(0.11), hi)), f_add(f_mul(fl(0.17), oc), f_mul(fl(0.06), night)))
|
|
post_saturation = f_sub(f_add(fl(1.04), f_mul(fl(0.12), lowsun)), f_add(f_mul(fl(0.20), oc), f_mul(fl(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.
|
|
r3d_sky_relight = f_mul(smoothf(f_rad(fi(-10)), f_rad(fi(1)), el), f_sub(F_ONE, f_mul(fl(0.75), oc)))
|
|
|
|
# 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("R3D_NOAIR") {
|
|
r3d_fog_density = f_mul(f_mul(day_fog_base, day_fog_mul), r3d_fog_scale)
|
|
r3d_fog_falloff = fl(0.002)
|
|
r3d_fog_inscatter = fl(0.02)
|
|
r3d_fog_desat = F_ZERO
|
|
post_wb_r = fl(1.02); post_wb_g = F_ONE; post_wb_b = fl(0.97)
|
|
post_lift_r = fl(0.004); post_lift_g = fl(0.004); post_lift_b = fl(0.012)
|
|
post_gain_r = fl(0.99); post_gain_g = fl(0.995); post_gain_b = F_ONE
|
|
post_contrast = fl(1.12); post_saturation = fl(1.04)
|
|
r3d_sky_relight = F_ZERO
|
|
}
|
|
# 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 = fl(4.5)
|
|
if day_moon { night_max = f_add(fl(4.5), f_mul(fl(3.5), day_moon_illum)) }
|
|
post_exposure_max = f_lerp(night_max, fi(20), d)
|
|
# the visible sky turns with the sun (cheap); its convolutions rebake when far off
|
|
let sky_yaw_now = f_add(day_yaw0, f_mul(f_mul(f_sub(h, day_hour0), f_rad(fi(15))), day_dir))
|
|
sky_set_rot(sky_yaw_now)
|
|
if f_gt(f_abs(day_wrap(f_sub(sky_yaw_now, day_sky_baked))), f_rad(fi(35))) and f_gt(d, fl(0.05)) {
|
|
day_sky_baked = sky_yaw_now
|
|
sky_precompute()
|
|
}
|
|
# the terrain's baked shadow follows the light in steps
|
|
if f_gt(f_abs(day_wrap(f_sub(laz, day_baked_az))), f_rad(fi(4))) or f_gt(f_abs(f_sub(lel, day_baked_el)), f_rad(fi(3))) {
|
|
day_baked_az = laz; day_baked_el = lel
|
|
day_gen += 1
|
|
}
|
|
}
|
|
|
|
# where the moon is in its month; the game advances this each morning
|
|
function daylight_moon(phase: int) -> void {
|
|
var p = f_mod(phase, F_ONE)
|
|
if f_ls(p, F_ZERO) { p = f_add(p, F_ONE) }
|
|
day_moon_phase = p
|
|
# illuminated fraction: (1 - cos(2 pi p)) / 2, which is 0 at new and 1 at full
|
|
day_moon_illum = f_mul(f_sub(F_ONE, f_cos(f_mul(f_mul(F_TWO, F_PI), p))), F_HALF)
|
|
daylight_set(day_hours)
|
|
}
|
|
|
|
# the weather over the valley: overcast 0..1, a fog multiplier, a lightning flash 0..1
|
|
function daylight_weather(overcast: int, fog_mul: int, flash: int) -> void {
|
|
day_overcast = overcast; day_fog_mul = fog_mul; day_flash = flash
|
|
}
|
|
# the campfire: a point light at (x, y, z) of `strength` (0 = out)
|
|
function daylight_fire(x: int, y: int, z: int, strength: int) -> void {
|
|
v3_set(fire_pos, x, y, z)
|
|
v3_set(fire_color, f_mul(fl(9.0), strength), f_mul(fl(4.6), strength), f_mul(fl(1.4), strength))
|
|
}
|
|
|
|
# the light in the hand: a point light (cone < -1) or a cone along dir (cone = cos half-angle)
|
|
function daylight_hand(x: int, y: int, z: int, dx: int, dy: int, dz: int, cone: int, r: int, g: int, b: int) -> void {
|
|
v3_set(hand_pos, x, y, z); v3_set(hand_dir, dx, dy, dz); hand_cone = cone
|
|
v3_set(hand_color, r, g, b)
|
|
}
|
|
function daylight_bind(prog: int) -> void {
|
|
u_v3(gpu_uniform(prog, "u_hand_pos"), hand_pos)
|
|
u_v3(gpu_uniform(prog, "u_hand_color"), hand_color)
|
|
u_v3(gpu_uniform(prog, "u_hand_dir"), hand_dir)
|
|
u_f(gpu_uniform(prog, "u_hand_cone"), hand_cone)
|
|
u_v3(gpu_uniform(prog, "u_ibl_scale"), day_ibl)
|
|
u_v3(gpu_uniform(prog, "u_moon_dir"), day_moon_dir)
|
|
u_f(gpu_uniform(prog, "u_moon_phase"), day_moon_phase)
|
|
u_f(gpu_uniform(prog, "u_moon_illum"), day_moon_illum)
|
|
u_f(gpu_uniform(prog, "u_moon_haze"), day_overcast)
|
|
u_f(gpu_uniform(prog, "u_daylight"), day_light)
|
|
u_v3(gpu_uniform(prog, "u_fire_pos"), fire_pos)
|
|
u_v3(gpu_uniform(prog, "u_fire_color"), fire_color)
|
|
}
|