feat(render3d): the air and the light are the hour's, not one constant apiece

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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-19 14:42:30 +03:00
parent f094acfdb5
commit d34fb5bc63
59 changed files with 1560 additions and 1035 deletions

View file

@ -0,0 +1,35 @@
bump: minor
type: feat
The air and the light are the hour's, not one constant apiece.
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 own elevation, and overcast thickens the air
while flattening the scatter. The term that was missing entirely is distance
DESATURATION: a surface is pulled toward its own luminance faster than the fog itself
arrives. Without it, blending a saturated ridge toward a saturated blue noon sky left
a saturated ridge, and a midday frame had a mountain three kilometres off reading as
vividly as a bench two metres from the camera.
The grade is the hour's too. White balance, the shadows' floor, contrast and
saturation were nine literals bound at the draw; daylight.ludic writes them 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 between it and the lit ground, and dawn and dusk get the reverse.
Gain is deliberately left alone - the grade is `c * gain + lift * (1 - c)`, so it
warms the whole frame rather than the highlights.
The visible sky is relit. It is one HDRI turned on its axis so its sun sits where the
hour wants it, and turning a photograph does not change what colour it was taken at -
so a sunset had a mid-morning blue overhead. An analytic sky supplies the chroma while
the photograph keeps the luminance, so the cloud stays where it is and goes orange at
dusk, and the zenith goes deep blue at noon, with no second sky shipped. It fades out
under the horizon, where the night's own tint, stars and moon take over, and eases off
under heavy cloud.
And the ground bounce follows the ground: the colour a surface is filled with from
below is the map's now, crossing from meadow to rock at its treeline, instead of one
green constant everywhere including above the scree.
R3D_NOAIR=1 restores all of it to what it was, so a before-and-after comes from one
binary at one hour. Measured at 400 frames: no cost on either backend.

View file

@ -142,8 +142,80 @@ function daylight_set(hours: int) -> void {
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

View file

@ -27,6 +27,23 @@ var post_lum: words = null
var post_mips: int = 0
var post_adapt: int = 0 # smoothed exposure (float bits)
var post_exposure_max: int = 0x41A00000 # 20: the ceiling auto-exposure may reach (night lowers it)
# ---- the grade ------------------------------------------------------------------------------
# White balance, the shadows' floor and the highlights' gain. These were nine literals bound at
# the draw, so the game had the same colour at seven in the morning as at one in the afternoon -
# every knob a grade needs, and no hand on any of them. daylight.ludic owns them now and writes
# them from the sun's own elevation; the values here are what they used to be hard-coded to, so a
# program that never starts a day looks exactly as it did.
# They are plain ints rather than a v3 on purpose: a grade is written by daylight_set, which can
# run before post_init has allocated anything, and nine ints cannot be null.
var post_wb_r: int = 0x3F828F5C # 1.02
var post_wb_g: int = 0x3F800000 # 1.0
var post_wb_b: int = 0x3F7851EC # 0.97
var post_lift_r: int = 0x3B83126F # 0.004
var post_lift_g: int = 0x3B83126F # 0.004
var post_lift_b: int = 0x3C449BA6 # 0.012
var post_gain_r: int = 0x3F7D70A4 # 0.99
var post_gain_g: int = 0x3F7EB852 # 0.995
var post_gain_b: int = 0x3F800000 # 1.0
var post_ao: Target = null
var post_ao_blur: Target = null
@ -324,9 +341,9 @@ function post_tonemap(color_tex: int) -> void {
u_f(gpu_uniform(prog, "u_vignette"), post_vignette)
u_f(gpu_uniform(prog, "u_saturation"), post_saturation)
u_f(gpu_uniform(prog, "u_contrast"), post_contrast)
u_f3(gpu_uniform(prog, "u_wb"), fl(1.02), F_ONE, fl(0.97))
u_f3(gpu_uniform(prog, "u_lift"), fl(0.004), fl(0.004), fl(0.012))
u_f3(gpu_uniform(prog, "u_gain"), fl(0.99), fl(0.995), fl(1.0))
u_f3(gpu_uniform(prog, "u_wb"), post_wb_r, post_wb_g, post_wb_b)
u_f3(gpu_uniform(prog, "u_lift"), post_lift_r, post_lift_g, post_lift_b)
u_f3(gpu_uniform(prog, "u_gain"), post_gain_r, post_gain_g, post_gain_b)
# the HDR10 variant's display calibration (the SDR program has none of these, and -1 sets nothing)
u_f(gpu_uniform(prog, "u_hdr_peak"), r3d_hdr_peak_nits())
u_f(gpu_uniform(prog, "u_hdr_paper"), r3d_hdr_paper_nits())

View file

@ -10,6 +10,23 @@ var r3d_fog_density: int = 0
var r3d_fog_scale: int = 0x3F800000 # float bits: a setting's multiplier over the density the day sets
var r3d_fog_falloff: int = 0
var r3d_fog_base: int = 0 # the height fog is measured from (float bits); 0 = y = 0
# Both of these are the DAY's, set by daylight_set from the sun's own elevation. The inscatter
# is how hard the air scatters the sun forward - nearly nothing at noon, and the glow a ridge
# is silhouetted against at dusk. The desaturation is how fast distance takes a surface's own
# colour away, which is the term that was missing entirely and is most of why a midday frame
# had no depth in it at all.
var r3d_fog_inscatter: int = 0x3CA3D70A # 0.02, what the shader used to hard-code
var r3d_fog_desat: int = 0x3FACCCCD # 1.35
# How much of the visible sky's colour comes from the analytic model rather than from the
# photograph (sky.frag). The day fades it in: at night the sky has its own tint, a star field
# and a moon, and relighting on top of those would only wash them out.
var r3d_sky_relight: int = 0
# What the ground reflects back up, low and high, and the height they cross at. The defaults
# are Maroon Lake's - a green basin under a grey-rock treeline at about 480 m over the datum.
var r3d_ground_alb_r: int = 0x3E99999A; var r3d_ground_alb_g: int = 0x3EAE147B; var r3d_ground_alb_b: int = 0x3E0F5C29
var r3d_ground_hi_r: int = 0x3E93F7CF; var r3d_ground_hi_g: int = 0x3E8F5C29; var r3d_ground_hi_b: int = 0x3E851EB8
var r3d_ground_hi_y: int = 0x43F00000 # 480
var r3d_ground_hi_w: int = 0x43160000 # 150
var r3d_time: int = 0
var r3d_ready: bool = false
# set before r3d_init to build the landscape from a real height map
@ -39,6 +56,12 @@ function fog_bind(prog: int) -> void {
u_f(gpu_uniform(prog, "u_fog_density"), r3d_fog_density)
u_f(gpu_uniform(prog, "u_fog_height_falloff"), r3d_fog_falloff)
u_f(gpu_uniform(prog, "u_fog_base"), r3d_fog_base)
u_f(gpu_uniform(prog, "u_fog_inscatter"), r3d_fog_inscatter)
u_f(gpu_uniform(prog, "u_fog_desat"), r3d_fog_desat)
u_f3(gpu_uniform(prog, "u_ground_alb"), r3d_ground_alb_r, r3d_ground_alb_g, r3d_ground_alb_b)
u_f3(gpu_uniform(prog, "u_ground_alb_hi"), r3d_ground_hi_r, r3d_ground_hi_g, r3d_ground_hi_b)
u_f(gpu_uniform(prog, "u_ground_hi_y"), r3d_ground_hi_y)
u_f(gpu_uniform(prog, "u_ground_hi_w"), r3d_ground_hi_w)
var cs = r3d_cloud_shadow
if r3d_env_has("R3D_NOCLOUD") { cs = F_ZERO }
u_f(gpu_uniform(prog, "u_cloud_shadow"), cs)
@ -117,7 +140,11 @@ function r3d_load_step(i: int) -> bool {
} else if i == 3 + TERRAIN_INIT_STEPS {
grass_init()
r3d_sky_prog = r3d_program("fullscreen.vert", "sky.frag", "")
r3d_fog_density = fl(0.00014)
# The base is NOON's air, and it was tuned when nothing desaturated with distance - so it
# was doing its whole job through colour and could not be raised without the valley going
# blue. With the desaturation term carrying the depth, the air can be as thick as real air
# at 2900 m and the far ridge recedes instead of tinting.
r3d_fog_density = fl(0.00024)
r3d_fog_falloff = fl(0.002)
r3d_ready = true
gpu_check("r3d init")
@ -136,6 +163,7 @@ function r3d_draw_sky() -> void {
sky_bind_lighting(p)
u_mat4(gpu_uniform(p, "u_inv_vp"), cam_inv_vp)
u_f(gpu_uniform(p, "u_sky_gain"), fl(0.95))
u_f(gpu_uniform(p, "u_sky_relight"), r3d_sky_relight)
u_f(gpu_uniform(p, "u_sky_sat"), fl(1.35))
u_f(gpu_uniform(p, "u_time"), r3d_time)
mesh_draw(sky_fullscreen)

View file

@ -39,6 +39,20 @@ vec2 skyUV(vec3 d) {
// whose normal points down — the underside of a needle card, the lower half of a crown —
// was lighting itself from that grey and came out white. Below the horizon the light is
// what the ground reflects: the horizon sky times a meadow albedo.
// What the ground under a surface reflects back up at it. It was one green constant for the
// whole world, which is right in a meadow, wrong on scree, and wrong under a cliff - and on a
// map that is not this one it is wrong everywhere. The colours are the map's now, and they
// cross over at its treeline, so a boulder's underside up in the talus is filled with grey
// rock light and one down by the lake is filled with green.
// (Sampling the terrain's own albedo would be better still and wants a texture bound to every
// program; that is phase 60's, where the terrain materials are being reworked anyway.)
uniform vec3 u_ground_alb; // the low ground: meadow and forest
uniform vec3 u_ground_alb_hi; // above the treeline: rock, scree, snow
uniform float u_ground_hi_y; // the height they cross at (world units)
uniform float u_ground_hi_w; // over how many metres
vec3 groundAlbAt(float y) {
return mix(u_ground_alb, u_ground_alb_hi, smoothstep(u_ground_hi_y, u_ground_hi_y + u_ground_hi_w, y));
}
const vec3 GROUND_ALB = vec3(0.30, 0.34, 0.14);
// the time of day (daylight.ludic): the sky's light scaled toward night, and the campfire
uniform vec3 u_ibl_scale;
@ -248,7 +262,7 @@ vec3 shade(vec3 wpos, vec3 n, vec3 albedo, float rough, float metal, float ao, f
vec2 brdf = texture(u_brdf, vec2(NoV, rough)).rg;
vec3 specIBL = pre * (Fr * brdf.x + brdf.y) * u_spec_scale;
// one bounce off the sunlit ground onto whatever faces it (a warm fill from below)
vec3 groundAlb = vec3(0.16, 0.2, 0.07);
vec3 groundAlb = groundAlbAt(wpos.y) * 0.55;
vec3 bounce = kdi * albedo * groundAlb * (u_sun_color * max(u_sun_dir.y, 0.0) / PI + irr) * clamp(0.5 - 0.5 * n.y, 0.0, 1.0) * 0.5;
// specular occlusion from ao
float so = clamp(pow(NoV + ao, exp2(-16.0 * rough - 1.0)) - 1.0 + ao, 0.0, 1.0);
@ -258,7 +272,12 @@ vec3 shade(vec3 wpos, vec3 n, vec3 albedo, float rough, float metal, float ao, f
return sane(min(c, vec3(4096.0)));
}
// aerial perspective: exponential height fog toward the horizon sky, with sun inscatter
// aerial perspective: exponential height fog toward the horizon sky, with sun inscatter.
// The strength of all three terms is the DAY's (daylight.ludic), not a constant: a low sun
// is shining through far more air than a high one, and the whole look of a valley at dawn
// is that air.
uniform float u_fog_inscatter; // how hard the air scatters the sun forward
uniform float u_fog_desat; // how fast distance takes a surface's own colour away
vec3 applyFog(vec3 col, vec3 wpos, float dist) {
vec3 dir = normalize(wpos - u_cam_pos);
float hf = u_fog_height_falloff;
@ -267,9 +286,18 @@ vec3 applyFog(vec3 col, vec3 wpos, float dist) {
// measured from u_fog_base: two maps on one height datum, one a thousand metres lower,
// would otherwise have that one's air exp(falloff * 1000) times thicker
float fogAmt = u_fog_density * exp(-(u_cam_pos.y - u_fog_base) * hf) * integ;
float f = 1.0 - exp(-fogAmt);
float f = clamp(1.0 - exp(-fogAmt), 0.0, 1.0);
vec3 fogCol = skyPrefiltered(vec3(dir.x, max(dir.y, 0.02), dir.z), 0.6);
float sunAmt = pow(max(dot(dir, u_sun_dir), 0.0), 8.0);
fogCol += u_sun_color * 0.02 * sunAmt;
return mix(col, fogCol, clamp(f, 0.0, 1.0));
fogCol += u_sun_color * u_fog_inscatter * sunAmt;
// DISTANCE TAKES SATURATION BEFORE IT TAKES CONTRAST, and the mix below cannot do that on
// its own: blending a saturated green ridge toward a saturated blue sky leaves a saturated
// ridge. That is why the noon frame had a mountain three kilometres off reading as vividly
// as the workbench two metres from the camera, while the same scene at dusk - where the fog
// colour happens to be a warm grey - looked like a photograph. Pull the surface toward its
// own luminance first, faster than the fog itself arrives, and the ridge recedes at every
// hour rather than only at the one where the sky was already grey.
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(col, vec3(l), clamp(f * u_fog_desat, 0.0, 1.0));
return mix(col, fogCol, f);
}

View file

@ -59,11 +59,43 @@ vec3 moonColour(vec3 dir, float night) {
return col * night * (1.0 - 0.9 * u_moon_haze);
}
// ---- relighting the photograph ---------------------------------------------------------------
// The sky is ONE HDRI - a South African morning - turned on its axis so its sun disc sits where
// the hour wants it. Turning a photograph does not change what colour it was taken at, so every
// hour of the day was lit by the same late morning: a sunset with a mid-morning blue overhead.
//
// What is worth keeping from the photograph is its STRUCTURE - the cloud, the gradient, the
// sun's own disc - and what is worth replacing is its COLOUR. So the analytic sky below supplies
// the chroma and the photograph keeps the luminance: clouds stay exactly where they are and go
// orange at dusk the way real ones do, and the zenith goes deep blue at noon without a second
// photograph being shipped.
uniform float u_sky_relight; // 0 leaves the photograph alone; the day fades this in
vec3 analyticSky(vec3 dir) {
float ct = clamp(dir.y, 0.0, 1.0);
float cs = clamp(dot(dir, u_sun_dir), -1.0, 1.0);
float lowsun = 1.0 - smoothstep(0.0, 0.45, u_sun_dir.y);
// Rayleigh: deep overhead, pale at the horizon where the line of sight is longest
vec3 base = mix(vec3(0.60, 0.70, 0.85), vec3(0.16, 0.33, 0.74), pow(ct, 0.55));
// the sun's warmth spreads into the sky around it, and spreads FURTHER the lower it is
float glow = pow(max(cs, 0.0), mix(9.0, 2.0, lowsun));
base = mix(base, vec3(1.0, 0.55, 0.22), glow * mix(0.22, 0.88, lowsun));
// and at dusk the whole horizon band warms, not only the part near the sun
float band = (1.0 - smoothstep(0.0, 0.32, ct)) * lowsun;
base = mix(base, vec3(0.96, 0.52, 0.28), band * 0.55);
return base;
}
void main() {
vec4 a = u_inv_vp * vec4(v_uv * 2.0 - 1.0, 1.0, 1.0);
vec3 dir = normalize(a.xyz / a.w - u_cam_pos);
// level 0: the equirect seam (atan wraps) would otherwise pick the smallest mip along one column
vec3 col = min(textureLod(u_sky, skyUV(dir), 0.0).rgb, vec3(4096.0)) * u_sky_gain;
if (u_sky_relight > 0.001) {
vec3 sk = analyticSky(dir);
float sl = max(dot(sk, vec3(0.2126, 0.7152, 0.0722)), 1e-4);
float cl = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(col, sk * (cl / sl), u_sky_relight);
}
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = max(mix(vec3(l), col, u_sky_sat), vec3(0.0));
// the photograph's sky dims with the day (daylight.ludic); the night adds its own

File diff suppressed because it is too large Load diff