Devlog #11 — Lantern Light in the Fog
Date: July 1, 2026
Focus: Rendering — how a lantern beam manages to look expensive while mostly being bullied by noise
I would like to say I built this because I had a refined theory of atmospheric scattering. That would be flattering, and not entirely true. The real reason is simpler: flat fog looks dead, and dead fog makes every other part of the scene feel like a cardboard cutout.
So the shader had to do a little more than tint the screen gray. It had to make the lantern feel like it was pushing through something with weight, which is a ridiculous thing to ask from a fragment shader, and yet here we are.
1. Fog First, Pride Second
The lantern effect only works because the fog is already there before the light arrives. That sounds obvious now, which is usually how I know it took too long to arrive at the idea.
float fog_shape = 0.18
+ edge_depth * 0.82
+ lower_depth * 0.24
+ broad_noise * 0.44
+ rolling_band * 0.42
+ fine_noise * 0.10;
float raw_fog = clamp(u_fog_density * fog_shape, 0.0, 1.0);
I wanted the fog to feel like a medium, not a filter. That means it needs structure: broad density, vertical bias, motion, and enough internal variance that the lantern can reveal different things depending on where it lands.
The actual ingredients are not exotic. Broad noise gives the fog body, lower-screen weighting makes it collect near the ground, and the rolling band term keeps it from looking like a screenshot of weather rather than weather itself.
vec2 fog_uv = uv * vec2(2.1, 1.35);
fog_uv.x += u_time * (0.014 + u_wind_strength * 0.05);
fog_uv.y += sin(u_time * 0.07 + uv.x * 5.0) * 0.035;
float broad_noise = fbm(fog_uv * 1.85);
float fine_noise = fbm(uv * vec2(6.5, 3.1) + vec2(u_time * 0.05, -u_time * 0.025));
float edge_depth = smoothstep(0.12, 0.86, length(centered));
float lower_depth = smoothstep(0.28, 1.0, uv.y);
float ribbon_wave = sin((uv.y * 13.0 + uv.x * 4.0) + u_time * (0.18 + u_wind_strength * 0.18));
float rolling_band = smoothstep(0.35, 0.86, broad_noise + ribbon_wave * 0.12)
* lower_depth;
If I had done this the lazy way, the effect would have been: dark scene, bright cone, job done, nobody happy. Instead the light has to negotiate with the fog, which is a more annoying arrangement but at least it produces something worth looking at.
2. A Beam Needs Direction
A lantern is not a sun. It points somewhere. That sounds like a banal distinction until you omit it and discover you have built a warm circular embarrassment.
vec2 pre_lantern_dir_raw = vec2(u_lantern_dir.x * aspect.x, u_lantern_dir.y);
vec2 pre_lantern_dir = length(pre_lantern_dir_raw) > 0.0001
? normalize(pre_lantern_dir_raw)
: vec2(0.0, -1.0);
vec2 pre_lantern_vec = (uv - u_lantern_pos) * aspect;
float pre_lantern_dist = length(pre_lantern_vec);
vec2 pre_to_frag = pre_lantern_dist > 0.0001 ? pre_lantern_vec / pre_lantern_dist : pre_lantern_dir;
float pre_forward = dot(pre_to_frag, pre_lantern_dir);
float player_beam_clear = smoothstep(0.24, 0.86, pre_forward)
* (1.0 - smoothstep(0.10, 0.50, pre_lantern_dist))
* u_lantern_intensity;
This gives the fog a directional cue. Pixels in front of the lantern receive more light; pixels to the side or too far away receive less. Nothing fancy, just enough geometry to stop the result from looking like a flashlight glued to the camera.
There is also a separate local pool term, because a carried lantern should still illuminate the space around the player instead of behaving like a suspiciously narrow stage spotlight.
float player_pool_clear = 1.0 - smoothstep(0.045, 0.31, pre_lantern_dist);
float light_clear = clamp(
player_pool_clear * 0.92
+ player_beam_clear * 0.66
+ party_pool_clear * 0.50
+ party_beam_clear * 0.34,
0.0,
1.0
);
The important part is that this is the clean signal. Later on, the visible lantern gets to be messy and emotional, which is a pretty accurate description of how most lighting work goes in practice.
3. Noise Is Doing the Heavy Lifting
The beam would be boring if it were smooth. Unfortunately, smoothness is also the easiest thing for a shader to do, which means it is usually the first thing you have to distrust.
float fog_rays = fbm(uv * vec2(7.5, 3.4) + vec2(u_time * 0.035, -u_time * 0.014));
float light_diffraction = raw_fog
* (player_beam_clear * 0.82 + party_beam_clear * 0.46)
* (0.46 + fog_rays * 0.54)
* (0.28 + u_fog_density * 0.72);
lit += vec3(1.0, 0.62, 0.28) * light_diffraction * 0.070;
This is not actual diffraction in any scientific sense, unless one is being very generous and also slightly dishonest. It is the visual equivalent of convincing the eye that the air contains enough suspended junk to catch the beam in streaks and patches.
The useful part is the multiplication. raw_fog says whether there is enough medium to see anything at all. The beam terms say whether the lantern is meaningfully aimed at the pixel. fog_rays breaks up the result so it does not read like a smooth decal.
If any one of those terms collapses, the whole effect weakens, which is exactly what should happen. Light should not bloom in a vacuum, and fog should not glow for free.
4. Clearance Is More Important Than Brightness
Bright light is easy. Readable light is harder. The shader therefore does something that feels more useful than clever: it actually reduces fog around the lantern so the scene opens up locally instead of just glowing harder.
float fog_clearance = clamp(light_clear * 0.72 + flash * 0.34, 0.0, 1.0);
float fog = raw_fog * (1.0 - fog_clearance * (0.42 + 0.20 * daylight));
vec3 fog_color = mix(u_fog_tint, vec3(0.50, 0.58, 0.74), rolling_band * 0.22);
lit = mix(lit, fog_color, fog * 0.76);
lit += fog_color * rolling_band * u_fog_density * 0.025;
That is the difference between “there is a light source” and “the player can see where they are going, sort of, while still feeling mildly threatened.” The latter is much more useful in a night scene.
What matters here is the push-pull. The lantern clears part of the haze, but not all of it, and the remaining fog still catches warm light. That is a better result than simply adding a bright cone on top of opaque mist and hoping nobody looks too closely.
I also like that lightning can temporarily improve visibility in the same framework. It means the weather and the light are competing in the same language instead of each being bolted on separately, which is usually where rendering starts to feel embarrassed.
5. Warm Light, Cold Air
Color does a lot of the storytelling here. The air is cold, the lantern is warm, and the contrast is doing most of the persuasion with very little supervision.
lit = mix(lit, fog_color, fog * 0.76);
lit += lantern_color * lantern * fog * u_lantern_intensity * 0.085;
lit += lantern_color
* raw_fog
* (lantern * 0.72 + haze * 0.38)
* (0.42 + fog_rays * 0.58)
* u_lantern_intensity
* 0.090;
I could pretend this is a carefully balanced physically based model, but honestly it is mostly a warm ember tone trying to survive in a blue-gray atmosphere. That tension is what makes the image feel alive.
The subtle part is that the lantern doesn’t just brighten the scene — it also brightens the fog around itself, so the air appears to respond. That is usually where the effect stops feeling like a blob of additive light and starts feeling like a place.
There is also a daytime counterpart for this idea, which helps the atmosphere feel coherent instead of having one rule for lanterns and another for the sun.
float day_diffraction = raw_fog * sunshaft * (0.34 + fog_rays * 0.66) * calm_day;
lit += vec3(0.52, 0.48, 0.34) * day_diffraction * 0.050;
That symmetry matters more than I expected. Once the fog understands how to catch light in general, the whole weather system starts to feel less like a pile of independent effects and more like one unpleasant but consistent environment.
6. The Lantern Is Allowed to Misbehave
A perfect beam is usually a boring beam. Real lantern light jitters, breathes, and gets interrupted by a hand, a shoulder, or the fact that flame is not especially interested in your rendering pipeline.
float ember_noise = fbm(uv * vec2(24.0, 17.0) + vec2(u_time * 0.31, -u_time * 0.47));
vec2 lantern_offset = vec2(
sin(u_time * 1.7 + ember_noise * 4.0),
cos(u_time * 1.1 + ember_noise * 3.0)
) * 0.006 * u_lantern_intensity;
vec2 lantern_vec = (uv - u_lantern_pos + lantern_offset) * aspect;
float lantern_dist = length(lantern_vec);
vec2 to_frag = lantern_dist > 0.0001 ? lantern_vec / lantern_dist : lantern_dir;
vec2 lantern_side = vec2(-lantern_dir.y, lantern_dir.x);
float forward = dot(to_frag, lantern_dir);
float side = dot(to_frag, lantern_side);
float ragged_edge = fbm(vec2(side * 3.8 + u_time * 0.19, lantern_dist * 8.0 - u_time * 0.34));
float dancing_shadow = fbm(uv * vec2(11.0, 8.0) + vec2(-u_time * 0.52, u_time * 0.37));
These tiny oscillations matter more than they should. Without them, the light feels procedural. With them, it feels like something unstable is being carried through the dark by a tired person who would rather not trip on a root.
The actual beam shape is built out of several imperfect parts rather than one clean cone: a directional cone, a reach term, a bright core, and a low haze contribution around the edges.
float cone_edge = mix(0.20, 0.54, ragged_edge);
float cone = smoothstep(cone_edge, 0.93, forward);
float reach_dist = lantern_dist + (ragged_edge - 0.5) * 0.045 + abs(side) * 0.018;
float reach = 1.0 - smoothstep(0.10, 0.43 + ragged_edge * 0.09, reach_dist);
float core = 1.0 - smoothstep(0.015, 0.13 + ember_noise * 0.04, lantern_dist);
float haze = (1.0 - smoothstep(0.08, 0.55 + dancing_shadow * 0.08, lantern_dist))
* (0.28 + ragged_edge * 0.42);
I also used noise to shape the beam edge and the local reach. That keeps the lantern from looking like a technical cone, which is a very fast way to make a scene look like a shader demo instead of a world.
7. Flicker Is a Structural Feature
I initially thought of flicker as polish. It turned out to be structure. Once the beam is moving through fog, tiny intensity changes make the whole medium feel reactive, which is a much better use of sine waves than whatever else I was going to do with them.
float flicker = 0.88
+ sin(u_time * 9.7) * 0.06
+ sin(u_time * 14.6 + ember_noise * 5.0) * 0.04
+ sin(u_time * 22.0 + ragged_edge * 3.0) * 0.025;
float broken_gaps = mix(0.72, 1.18, dancing_shadow);
float lantern = clamp(
(cone * reach * broken_gaps + core * 0.68 + haze * 0.24)
* u_lantern_intensity
* flicker,
0.0,
1.0
);
This is where the light stops being a stable UI instrument and starts behaving like a flame. Not a dramatic flame, not a theatrical one, just one that cannot quite decide whether it wants to cooperate.
The nice side effect is that the fog around the lantern inherits that instability. The beam breathes because the source breathes, which is a far better relationship than adding independent random pulsing to both and pretending they are connected.
8. Occlusion Saves It From Looking Fake
Light without interruption tends to look cheap. The shader has a whole occluder path specifically so beams can get cut, softened, and partially erased by world geometry.
float occluder_shadow(vec4 occ, vec2 lamp, vec2 uv, vec2 aspect, float lamp_intensity) {
if (occ.w <= 0.001 || occ.z <= 0.001 || lamp_intensity <= 0.001) {
return 0.0;
}
vec2 lamp_a = lamp * aspect;
vec2 occ_a = occ.xy * aspect;
vec2 frag_a = uv * aspect;
vec2 to_occ = occ_a - lamp_a;
vec2 to_frag = frag_a - lamp_a;
float occ_dist = length(to_occ);
if (occ_dist <= occ.z * 1.2) {
return 0.0;
}
vec2 dir = to_occ / occ_dist;
float along = dot(to_frag, dir);
float behind = smoothstep(occ_dist - occ.z * 0.35, occ_dist + occ.z * 1.15, along);
float tail = max(along - occ_dist, 0.0);
float side = abs(to_frag.x * dir.y - to_frag.y * dir.x);
float penumbra = occ.z * (0.95 + tail * 3.25);
float umbra = 1.0 - smoothstep(occ.z * 0.24, penumbra, side);
float reach = 1.0 - smoothstep(0.05, 0.74, tail);
float near_lamp_fade = smoothstep(occ.z * 1.4, occ.z * 4.0, occ_dist);
return clamp(umbra * behind * reach * near_lamp_fade * occ.w * lamp_intensity, 0.0, 1.0);
}
This function is doing the deeply glamorous work of making the world exist between the lamp and the fragment. If something blocks the light, the beam should not politely ignore that fact.
The final attenuation is straightforward, but it matters.
float lantern_shadowing = lantern_shadow(u_lantern_pos, uv, aspect, u_lantern_intensity);
lantern *= 1.0 - lantern_shadowing * 0.78;
haze *= 1.0 - lantern_shadowing * 0.36;
Once occluders get involved, the whole thing becomes more convincing. The light does not simply stop; it gets interrupted, thinned, and partially erased, which is how it should behave if the world has objects in it and not just vibes.
9. The Part I Would Pretend Was Intentional
I would love to claim there was a single elegant insight behind all of this. There was not. It was more like several acceptable ideas stacked on top of each other until the result stopped offending me.
vec3 lantern_color = mix(vec3(1.0, 0.48, 0.16), vec3(1.18, 0.78, 0.34), ember_noise);
vec3 lantern_lift = max(lit, base.rgb * (1.0 + lantern_color * 0.48) + lantern_color * 0.09);
lit = mix(lit, lantern_lift, lantern * 0.66);
lit += lantern_color * haze * u_lantern_intensity * 0.055;
lit += lantern_color * lantern * fog * u_lantern_intensity * 0.085;
That is basically the whole project in miniature: keep the useful signal, let the weather distort it, and do not let the result get so polished that it stops feeling like a night scene. A lantern in fog is mostly a negotiation between readability and mood, and mood is allowed to win by a small margin.
That seems to be the right balance here, which is convenient, because I would not like to revisit the entire thing unless absolutely necessary.
If I were to reduce this post to one sentence, it would be: the lantern works because the fog is not a backdrop, it is a participant.

Reader notes
No notes yet.
Sign in with GitHub to leave a note.
Continue with GitHub