DramaticShapeVoxelMod archival mirror · read-only
442e9d26d56e54f98723b7a946259fa58a32ee62

add fog/god rays to viridian forest

DramaticShape · 2026-08-06 · parent 6240b50cec

add fog/god rays to viridian forest

Changed files

CHANGELOG.md                  |  33 +++
 LICENSE                       |  21 --
 README.md                     |   2 -
 data/map_atmosphere.lua       |  63 +++++
 lib/BattleScene.lua           |  11 +
 lib/ForestAtmos.lua           | 822 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/Voxel3D.lua               |  54 +++++
 lib/VoxelScene.lua            |  14 ++
 main.lua                      |  20 ++
 manifest.json                 |   2 +-
 tests/dramatic_shape_test.lua | 103 +++++++-
 tests/forest_fog_shots.lua    | 137 +++++++++++
 12 files changed, 1252 insertions(+), 30 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6d4873..f6f3a69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,38 @@
 # Changelog
 
+## 1.6.2
+
+### Added
+
+- **The air of Viridian Forest: volumetric god rays, ground fog, and a
+  FOREST FX row.** An invisible jungle canopy now hangs above the forest's
+  real trees, and light comes down through it as true volumetric beams: a
+  per-pixel march reads the frame's own depth buffer and the sun's own
+  shadow map, so shafts stand exactly where light really breaks between
+  the tree hulls, trunks and passing characters carve dark columns
+  through them, and a wind-blown leaf field at the canopy plane opens and
+  closes the beams like foliage moving overhead. The beams are alpha zero
+  at the canopy and fade in as they descend -- light below the leaves,
+  never a lid above them -- and a forward-scattering term blooms them for
+  a camera looking up into the light, first person especially.
+
+  The scene shader gains a height-and-distance fog every surface sinks
+  into, and the rays are that fog lit: one shared ramp off the day/night
+  clock colours both, gold spears of sun by day, silver moon rays after
+  dark, dying back through the twilights as one hands over to the other.
+  Pollen drifts through the day's beams and fireflies blink low over the
+  floor at night, all shader-animated and deterministic. A fight staged
+  on the forest floor sits in the same haze at half density.
+
+  The direction never moves: a canopy map's light is pinned to noon (see
+  DayNight.CANOPY), so the beams always agree with the shadows on the
+  floor. Everything is authored per map in `data/map_atmosphere.lua` --
+  a map with no entry spends nothing -- and the **FOREST FX** row (FULL /
+  LOW / OFF, FULL by default) governs the cost: LOW halves the march and
+  stands the particles down. On Android the row offers LOW / OFF only --
+  no mobile driver grants the readable depth the march needs -- so the
+  forest keeps its haze there and loses the beams.
+
 ## 1.6.1
 
 ### Fixed
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 67720a0..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2026 DramaticShape
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/README.md b/README.md
index addf73c..2d20479 100644
--- a/README.md
+++ b/README.md
@@ -213,8 +213,6 @@ alongside.
 
 ## Licenses
 
-This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
-
 It redistributes one third-party binary:
 
 - **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader
diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua
new file mode 100644
index 0000000..5d6c321
--- /dev/null
+++ b/data/map_atmosphere.lua
@@ -0,0 +1,63 @@
+-- What hangs in the air of each map.
+--
+-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader
+-- folds every surface into, and volumetric god rays -- light let down
+-- through an INVISIBLE canopy hanging above the map's real geometry, as
+-- if the trees drawn are only the understorey of something taller. The
+-- rays are not placed: a per-pixel march (see ForestAtmos) reads the
+-- frame's own depth and the sun's own shadow map, so the beams stand
+-- exactly where light really breaks between the tree hulls, trees and
+-- characters carve dark columns through them, and a wind-blown leaf
+-- field at the canopy plane opens and closes them like foliage moving
+-- overhead. The light leans along the mod's fixed noon shear (the one
+-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
+-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
+-- rays after dark, pollen adrift in the day's beams and fireflies once
+-- they cool.
+--
+-- A map with no entry here has no atmosphere at all: no fog uniform is
+-- raised, no march runs, nothing is spent. That is the contract a new
+-- map opts into by adding a line, and what a stale entry degrades to if
+-- its map id ever stops existing.
+--
+-- The knobs, in world pixels unless said otherwise (a map cell is 16):
+--
+--   canopyY   where the invisible canopy hangs. MUST clear the tallest
+--             real geometry under it (Viridian's carved tree hulls top
+--             at y = 32) -- a beam is alpha ZERO at this height and only
+--             fades in below it, so a canopy at or under the tree tops
+--             would cut every ray off before it cleared the leaves.
+--   fadeTo    the height by which a descending ray reaches full strength.
+--   fog       density  how fast distance dissolves into the haze
+--                      (1 - exp(-density * distance-past-start))
+--             start    how many pixels out the dissolve begins
+--             heightK  how quickly the fog thins with ALTITUDE
+--                      (exp(-y * heightK): 0.02 halves it by y = 35)
+--   rays      strength  overall in-scatter gain on the march
+--             reach     how far out the march walks, in world px
+--   motes     count of pollen/dust flecks adrift in the daylight beams
+--   fireflies count of the night shift
+--   seed      the xorshift seed the particle deal runs on
+--
+-- Two caveats for maps opting in later: the water pass has no fog term,
+-- so a lake under heavy haze stays clear-day sharp in its reflections;
+-- and the march runs after the water's mirror copy, so beams will not
+-- appear IN those reflections either. Neither can bite in a map without
+-- water.
+
+return {
+  ["VIRIDIAN_FOREST"] = {
+    canopyY = 56,
+    fadeTo = 28,
+    fog = { density = 0.0045, start = 64, heightK = 0.02 },
+    -- strength is calibrated against the march's real integral: the
+    -- under-canopy stretch of an orbit ray is short and thinly dense, so
+    -- the raw accumulation for a fully lit beam core is a few percent --
+    -- this gain lands it near +0.3 on screen. Halve it for a whisper,
+    -- double it for cathedral light.
+    rays = { strength = 16, reach = 380 },
+    motes = { count = 96 },
+    fireflies = { count = 48 },
+    seed = 0x51D,
+  },
+}
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 52ad143..dbaf442 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -479,6 +479,17 @@ function BattleScene.render(state, arena, textures, token)
   -- no glint in the arena: the drift is the shot breathing, not the player
   -- moving, and a shimmer on background windows would fight the mons
   Voxel3D.glassGlint = 0
+  -- the host floor's atmosphere reaches the staged shot at HALF density --
+  -- a fight in Viridian Forest sits in the same haze the walk there did,
+  -- thinned so neither mon goes soft -- and its god rays stay out of it:
+  -- this camera is low and long, and a bright blade across a combatant
+  -- reads as a rendering fault, not weather. nil almost everywhere.
+  local ForestAtmos = V.require("ForestAtmos")
+  local atmos = ForestAtmos.frame(host)
+  Voxel3D.fog = atmos and { color = atmos.fog.color,
+                            density = atmos.fog.density * 0.5,
+                            start = atmos.fog.start,
+                            heightK = atmos.fog.heightK } or nil
 
   -- A B RUNG stands the fight on two carried discs against the sky, with no
   -- map in the shot at all (see StadiumStage). Everything below still runs --
diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua
new file mode 100644
index 0000000..d3f3a4e
--- /dev/null
+++ b/lib/ForestAtmos.lua
@@ -0,0 +1,822 @@
+-- The air under the canopy: fog, god rays, and what drifts through them.
+--
+-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest
+-- today, any map that adds a line tomorrow): a ground haze the scene shader
+-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down
+-- through an INVISIBLE canopy hanging above the map's real trees, as if the
+-- carved hulls on screen were only the understorey of something taller.
+--
+-- The rays are not placed geometry. A fullscreen pass marches every
+-- pixel's eye ray through the air, stops at the frame's own depth buffer
+-- (the same detach-and-read Water runs), and asks two questions of every
+-- step of air on the way:
+--
+--   * the SUN'S question -- the shadow map. Air behind a tree hull is
+--     dark air; air in a real gap glows. A trunk stands in a column of
+--     its own shade, a character walks through the beams and blocks
+--     them, and every shaft on screen agrees with the light already on
+--     the floor, because it is read from the same map.
+--
+--   * the CANOPY'S question -- where this thread of sun pierced the
+--     invisible leaf layer. Every step of air on one sun ray shares that
+--     point, which is what makes a shaft a SHAFT, and the point samples
+--     a wind-blown noise field: the dapple drifts and shivers like
+--     leaves moving overhead, opening and closing the beams as it goes.
+--
+-- The shafts lean along the fixed noon shear, deliberately: a canopy
+-- map's rig is pinned to noon (see DayNight.CANOPY), so light that
+-- followed the sun's arc would part company with every shadow on the
+-- floor. What follows the clock is colour and strength -- gold spears of
+-- sun by day, silver moon rays after dark, dying back through the
+-- twilights -- plus the crew each shift brings: pollen adrift in the
+-- day's beams, fireflies once they cool. A forward-scattering phase term
+-- brightens the beams for a camera looking up into the light, which is
+-- most of what makes them read as light in air rather than paint on it.
+--
+-- Everything is deterministic: placement and the leaf field are dealt by
+-- a seeded xorshift (StadiumFx's generator), motion is a pure function
+-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame
+-- exactly (see tests/forest_fog_shots). Every shader compiles lazily
+-- behind pcall -- nil untried, false unavailable -- and each refusal
+-- subtracts only itself: no march without readable depth, no beams
+-- without a shadow map, and the fog rides the scene shader whatever
+-- happens here.
+
+local V = ...
+
+local DayNight = V.require("DayNight")
+local ModSetting = V.require("ModSetting")
+
+local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max
+
+local ForestAtmos = {}
+
+-- FULL is the point; LOW halves the march and drops the particles, for
+-- hardware that minds a per-pixel loop under 4X supersampling.
+--
+-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the
+-- default. The march needs a depth texture it can READ, and no driver on
+-- the phones this runs on has granted one (see newDepth in Voxel3D) --
+-- so FULL would be a rung with nothing behind it, which reads as a
+-- broken mod rather than a missing feature. LOW there is the haze, the
+-- one part of the atmosphere that rides the scene shader and works
+-- everywhere. A desktop save opened on a phone stores FULL still;
+-- ModSetting's unknown-value fallback lands it on LOW, and putting the
+-- save back on the desktop restores the choice.
+local function onAndroid()
+  if not (love and love.system and love.system.getOS) then return false end
+  local ok, os = pcall(love.system.getOS)
+  return ok and os == "Android"
+end
+
+ForestAtmos.setting = onAndroid()
+  and ModSetting.new("atmos", "FOREST FX", { "low", "off" },
+                     { "LOW", "OFF" })
+  or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" },
+                    { "FULL", "LOW", "OFF" })
+
+-- the animation clock: ticked by main.lua's always-running update hook,
+-- pinnable (frozen = true) so a screenshot driver can hold a frame still
+ForestAtmos.time = 0
+ForestAtmos.frozen = false
+
+function ForestAtmos.update(dt)
+  if ForestAtmos.frozen then return end
+  ForestAtmos.time = ForestAtmos.time + (dt or 0)
+end
+
+-- ------- the authored table
+--
+-- Same shape as BattleArena's: the data file behind a pcall with a false
+-- sentinel, and an overrides table a tuning driver can stage a candidate
+-- through before anything is written down. `~= nil` on the override,
+-- because false is meaningful -- "this map has no atmosphere, whatever
+-- the file says".
+
+local authored = nil
+local overrides = {}
+
+local function configFor(mapId)
+  if not mapId then return nil end
+  local forced = overrides[mapId]
+  if forced ~= nil then return forced or nil end
+  if authored == nil then
+    local ok, list = pcall(V.data, "map_atmosphere")
+    authored = (ok and type(list) == "table") and list or false
+  end
+  if not authored then return nil end
+  return authored[mapId]
+end
+
+ForestAtmos.configFor = configFor
+
+-- ------- caches
+--
+-- Particle layouts and meshes go stale with the map (map.reloaded, and
+-- the pipeline's invalidate); called with no map id this also resets the
+-- shader and texture sentinels, which is what a lost GL context needs.
+
+local layoutCache = {}
+local meshCache = {}
+local shaders = {}            -- keyed by variant; nil untried, false refused
+local leafTex = nil           -- the tiling leaf-dapple field
+local rayMesh = nil           -- the fullscreen ray-fan quad, re-aimed per draw
+
+-- Every bail here is deliberate and silent on screen -- a missing piece
+-- subtracts itself, never the frame -- but "the beams are off" and "the
+-- beams are off BECAUSE ..." are different debugging days. Each reason
+-- is said once on the console, the way VR reports a missing runtime.
+local said = {}
+local function say(key, msg)
+  if said[key] then return end
+  said[key] = true
+  print("[DRAMATIC_SHAPE] atmos: " .. msg)
+end
+
+function ForestAtmos.invalidate(mapId)
+  if mapId then
+    layoutCache[mapId] = nil
+    meshCache[mapId] = nil
+  else
+    layoutCache, meshCache = {}, {}
+    shaders = {}
+    leafTex = nil
+    rayMesh = nil
+  end
+end
+
+function ForestAtmos.setOverride(mapId, entry)
+  overrides[mapId] = entry
+  ForestAtmos.invalidate(mapId)
+end
+
+-- ------- the hour's answer
+--
+-- One ramp, authored per phase and blended with DayNight's own weights,
+-- so the fog and the rays can never disagree about what hour it is. The
+-- two interact three ways: the fog colour leans toward the ray colour
+-- (noon warms the haze, midnight silvers it), the rays scale with the
+-- fog's density (a beam IS lit fog -- less medium, less beam), and the
+-- march accumulates through the same density the surfaces sink into.
+
+ForestAtmos.RAMP = {
+  day    = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 },
+             alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 },
+  golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 },
+             alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 },
+  dawn   = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 },
+             alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 },
+  dusk   = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 },
+             alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 },
+  violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 },
+             alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 },
+  night  = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 },
+             alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
+}
+
+-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
+-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
+-- and Voxel3D.fog should be left nil.
+function ForestAtmos.frame(map, t)
+  if ForestAtmos.setting:get() == "off" then return nil end
+  local cfg = configFor(map and map.id)
+  if not cfg then return nil end
+  local mix = DayNight.mix(t or DayNight.time())
+  local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
+  local alpha, dens, motes, flies = 0, 0, 0, 0
+  for name, w in pairs(mix) do
+    local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
+    fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
+    rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w
+    alpha = alpha + p.alpha * w
+    dens = dens + p.density * w
+    motes = motes + p.motes * w
+    flies = flies + p.flies * w
+  end
+  -- the haze takes on a little of the light standing in it
+  local LEAN = 0.15
+  fr = fr + (rr - fr) * LEAN
+  fg = fg + (rg - fg) * LEAN
+  fb = fb + (rb - fb) * LEAN
+  local base = cfg.fog or {}
+  local rays = cfg.rays or {}
+  return {
+    -- in exactly the shape Voxel3D.fog takes, so callers assign it whole
+    fog = { color = { fr, fg, fb },
+            density = (base.density or 0) * dens,
+            start = base.start or 0,
+            heightK = base.heightK or 0 },
+    rayColor = { rr, rg, rb },
+    -- a beam is scattered fog: less medium, less beam
+    rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)),
+    rayStrength = rays.strength or 12,
+    rayReach = rays.reach or 380,
+    moteLevel = motes,
+    fireflyLevel = flies,
+    cfg = cfg,
+  }
+end
+
+-- ------- deterministic noise
+--
+-- The same written-out xorshift StadiumFx runs (see the note there on why
+-- not LuaJIT's `bit`): the particle deal and the leaf field must come out
+-- identical on every machine and every visit.
+
+local function bxor32(a, b)
+  local r, p = 0, 1
+  for _ = 1, 32 do
+    local x, y = a % 2, b % 2
+    if x ~= y then r = r + p end
+    a, b, p = floor(a / 2), floor(b / 2), p * 2
+  end
+  return r
+end
+
+local Rng = {}
+Rng.__index = Rng
+
+local function newRng(seed)
+  local s = seed % 0x100000000
+  if s == 0 then s = 0x9E3779B9 end
+  return setmetatable({ s = s }, Rng)
+end
+
+function Rng:next()
+  local x = self.s
+  x = bxor32(x, (x % 0x80000) * 0x2000)               -- x ^= (x << 13)
+  x = bxor32(x, floor(x / 0x20000))                   -- x ^= x >> 17
+  x = bxor32(x, (x % 0x8000000) * 0x20)               -- x ^= (x << 5)
+  self.s = x % 0x100000000
+  return self.s
+end
+
+function Rng:unit()
+  return self:next() / 0x100000000
+end
+
+-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles
+local function lattice(rng, w, h)
+  local g = {}
+  for y = 1, h do
+    local row = {}
+    for x = 1, w do row[x] = rng:unit() end
+    g[y] = row
+  end
+  return g
+end
+
+local function smoothstep01(t)
+  return t * t * (3 - 2 * t)
+end
+
+local function torus(grid, w, h, x, y)
+  local x0, y0 = floor(x), floor(y)
+  local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0)
+  local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
+  x0, y0 = x0 % w, y0 % h
+  local a = grid[y0 + 1][x0 + 1]
+  local b = grid[y0 + 1][x1 + 1]
+  local c = grid[y1 + 1][x0 + 1]
+  local d = grid[y1 + 1][x1 + 1]
+  return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy
+end
+
+-- ------- the leaf field
+--
+-- One small tiling texture of three-octave value noise, generated once
+-- from a fixed seed: the pattern of the unseen foliage. The shader reads
+-- it at two drifting, differently-scaled offsets and thresholds the sum,
+-- so the pools of light between the leaves slide, open and close -- the
+-- movement is the WIND's, all in the sampling; the cloth itself never
+-- changes, which is what keeps a pinned frame reproducible.
+
+local function leafTexture()
+  if leafTex ~= nil then return leafTex or nil end
+  if not (love and love.image and love.image.newImageData
+          and love.graphics and love.graphics.newImage) then
+    leafTex = false
+    return nil
+  end
+  local ok, tex = pcall(function()
+    local N = 128
+    local rng = newRng(0x1EAF)
+    local g1 = lattice(rng, 8, 8)
+    local g2 = lattice(rng, 16, 16)
+    local g3 = lattice(rng, 32, 32)
+    local img = love.image.newImageData(N, N)
+    for y = 0, N - 1 do
+      local v = y / N
+      for x = 0, N - 1 do
+        local u = x / N
+        local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5
+                + torus(g2, 16, 16, u * 16, v * 16) * 0.3
+                + torus(g3, 32, 32, u * 32, v * 32) * 0.2
+        img:setPixel(x, y, n, n, n, 1)
+      end
+    end
+    local t = love.graphics.newImage(img)
+    t:setWrap("repeat", "repeat")
+    t:setFilter("linear", "linear")
+    return t
+  end)
+  leafTex = (ok and tex) or false
+  return leafTex or nil
+end
+
+-- ------- placement (the particles; the light places itself)
+
+local MARGIN = 24     -- keep off the map's edge, world px
+
+function ForestAtmos.layout(cfg, w, h)
+  local rng = newRng((cfg.seed or 0x51D))
+  local canopy = cfg.canopyY or 56
+  local motes = {}
+  for _ = 1, (cfg.motes and cfg.motes.count) or 0 do
+    motes[#motes + 1] = {
+      x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
+      y = 3 + rng:unit() * max(canopy - 11, 8),
+      z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
+      phase = rng:unit() * 6.2832,
+      rate = 0.5 + rng:unit(),
+    }
+  end
+  local flies = {}
+  for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do
+    flies[#flies + 1] = {
+      x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
+      y = 3 + rng:unit() * 12,
+      z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
+      phase = rng:unit() * 6.2832,
+      rate = 0.5 + rng:unit(),
+    }
+  end
+  return { motes = motes, flies = flies }
+end
+
+-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
+-- for world pixels (the same arithmetic Structures runs in tiles).
+local function layoutFor(map)
+  local hit = layoutCache[map.id]
+  if hit ~= nil then return hit or nil end
+  local cfg = configFor(map.id)
+  if not cfg then
+    layoutCache[map.id] = false
+    return nil
+  end
+  local def = map.def or {}
+  local L = ForestAtmos.layout(cfg, (def.width or 16) * 32,
+                               (def.height or 16) * 32)
+  layoutCache[map.id] = L
+  return L
+end
+
+ForestAtmos.layoutFor = layoutFor
+
+-- ------- the volumetric march
+--
+-- A fullscreen quad whose four corners carry the camera's own frustum
+-- rays; the varying interpolates them into a world ray per pixel. The
+-- pixel stage walks that ray to the depth buffer's surface, and every
+-- step of air on the way is lit or not by the shadow map and the leaf
+-- field, accumulated through the same haze the surfaces sink into.
+--
+-- Conventions copied from Water's march: the ray walks the FLAT world
+-- (the space it is straight in) and every depth compare bends the sample
+-- first, by the same displacement the vertex stage applies -- so the
+-- march reads the depth buffer it actually has. The shadow lookup stays
+-- flat, exactly like the scene shader's own vSun. STEPS is spliced into
+-- the source rather than sent (the LOW rung is a second compile), and
+-- there are no uniform arrays anywhere -- see the note in Sky about the
+-- Android driver that reads them as zero.
+
+local RAY_SHADER = [[
+  varying vec3 vRay;
+#ifdef VERTEX
+  attribute vec3 RayDir;
+  vec4 position(mat4 transform_projection, vec4 vertex_position) {
+    vRay = RayDir;
+    return transform_projection * vertex_position;
+  }
+#endif
+#ifdef PIXEL
+  uniform Image depthTex;    // the frame's own depth, detached to read
+  uniform Image sunMap;      // the sun's answer (see ShadowMap)
+  uniform Image leafTex;     // the unseen foliage, tiling
+  uniform mat4 vp;
+  uniform mat4 sunVP;
+  uniform float sunBias;
+  uniform vec3 eye;
+  uniform vec3 curve;        // xy = the focus in world XZ, z = k; 0 = off
+  uniform vec2 screen;       // canvas size, for the pixel's own uv
+  uniform vec4 fogW;         // density, heightK, canopyY, fadeTo
+  uniform vec3 shear;        // the noon shear kx, kz; z = reach
+  uniform vec3 rayColor;
+  uniform float strength;
+  uniform vec3 sunward;      // unit, toward the unseen sun
+  uniform vec2 wind;         // leaf-field drift, uv per second
+  uniform float time;
+
+  float sunDepth(vec2 uv) {
+    vec4 c = Texel(sunMap, uv);
+    return c.r + c.g * (1.0 / 255.0);
+  }
+
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    vec2 uv = sc / screen;
+    float sceneD = Texel(depthTex, uv).r;
+    vec3 dir = normalize(vRay);
+    // Spend every sample where a sample can glow. Above the canopy no
+    // beam exists, and below the floor there is no air at all -- so the
+    // march runs from where this ray first dips under the leaves to
+    // where it would pass the ground, however long or short that
+    // stretch is. From the orbit camera that is the last few dozen
+    // pixels of a mostly-vertical ray, and dividing the WHOLE reach by
+    // the step count there starved the beams to nothing.
+    float t0 = 0.0;
+    if (eye.y > fogW.z) {
+      if (dir.y >= -0.01) return vec4(0.0);
+      t0 = (eye.y - fogW.z) / -dir.y;
+    }
+    float tEnd = shear.z;
+    if (dir.y < -0.01) {
+      tEnd = min(tEnd, (eye.y + 8.0) / -dir.y);
+    }
+    if (tEnd <= t0) return vec4(0.0);
+    // interleaved gradient noise staggers neighbouring pixels' steps,
+    // which is what turns 20-odd samples into a smooth volume instead
+    // of an onion of banded slices
+    float jitter = fract(52.9829189
+                         * fract(dot(sc, vec2(0.06711056, 0.00583715))));
+    float dt = (tEnd - t0) / float(STEPS);
+    // HALF the fog's own extinction, on the way in and per step: the
+    // full rate is what the surfaces sink by, and beams that obeyed it
+    // too died before the orbit camera ever saw them. Half keeps the
+    // depth cue and leaves the light alive.
+    float trans = exp(-fogW.x * 0.5 * t0);
+    float acc = 0.0;
+    for (int i = 0; i < STEPS; i++) {
+      float t = t0 + (float(i) + jitter) * dt;
+      vec3 p = eye + dir * t;
+      // stop at the surface: bend the sample the way the geometry bent
+      vec2 cd = p.xz - curve.xy;
+      vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0);
+      if (c.w <= 1e-6) break;
+      if (c.z / c.w * 0.5 + 0.5 > sceneD) break;
+      if (p.y < fogW.z) {
+        // the sun's question: is this air behind a tree? Outside the
+        // frustum nothing was recorded and the air counts as lit, eased
+        // at the rim exactly like the scene shader's shadows
+        float lit = 1.0;
+        vec3 su = (sunVP * vec4(p, 1.0)).xyz;
+        if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0
+            && su.z < 1.0) {
+          vec2 e2 = min(su.xy, 1.0 - su.xy);
+          float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y));
+          lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge);
+        }
+        // the canopy's question: where did this thread of light pierce
+        // the leaves? Every step of air on one sun ray shares the
+        // answer -- that shared point is what makes a shaft a shaft --
+        // and the two drifting reads of the field are the wind moving
+        // the foliage overhead, opening and closing the beams
+        float up = fogW.z - p.y;
+        vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0);
+        float n = Texel(leafTex, gap + wind * time).r * 0.65
+                + Texel(leafTex, gap * 2.3 - wind * (time * 0.7)
+                        + vec2(0.37, 0.61)).r * 0.35;
+        float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n);
+        // the beam fades IN below the invisible canopy, thins with
+        // altitude like the haze it is made of, and kisses the floor
+        float y = max(p.y, 0.0);
+        float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0);
+        float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0);
+        float dens = fogW.x * exp(-y * fogW.y);
+        acc += trans * lit * dapple * fadeIn * foot * dens * dt;
+      }
+      trans *= exp(-fogW.x * 0.5 * dt);
+    }
+    // forward scattering: beams bloom for a camera looking up into the
+    // light, which is most of what makes them read as light IN air
+    float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0);
+    return vec4(rayColor * (acc * strength * phase), 1.0) * color;
+  }
+#endif
+]]
+
+local function rayShaderFor(steps)
+  local key = "ray" .. steps
+  local s = shaders[key]
+  if s ~= nil then return s or nil end
+  if not (love and love.graphics and love.graphics.newShader) then
+    shaders[key] = false
+    return nil
+  end
+  local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER
+  local ok, sh = pcall(love.graphics.newShader, src)
+  if not ok then
+    say(key, "ray shader refused -- beams off, fog stays: "
+        .. tostring(sh))
+  end
+  shaders[key] = (ok and sh) or false
+  return shaders[key] or nil
+end
+
+local RAY_FORMAT = {
+  { "VertexPosition", "float", 2 },
+  { "RayDir", "float", 3 },
+}
+
+-- The camera's frustum corners, from the same fields every pass sets:
+-- eye, focus, fovY, and the placed camera's up when there is one (VR
+-- eyes roll; the orbit never does). Interpolating a corner ray across
+-- the quad IS the standard reconstruction for a perspective camera, so
+-- this works identically for the orbit, first person and both eyes.
+local function rayQuad(Voxel3D, w, h)
+  local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY
+  if not (e and fo and fov) then return nil end
+  local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
+  local fl = sqrt(fx * fx + fy * fy + fz * fz)
+  if fl < 1e-6 then return nil end
+  fx, fy, fz = fx / fl, fy / fl, fz / fl
+  local cam = Voxel3D.camera
+  local up = (cam and cam.up) or { 0, 1, 0 }
+  -- right = forward x up, then a true up perpendicular to both
+  local rx = fy * up[3] - fz * up[2]
+  local ry = fz * up[1] - fx * up[3]
+  local rz = fx * up[2] - fy * up[1]
+  local rl = sqrt(rx * rx + ry * ry + rz * rz)
+  if rl < 1e-6 then return nil end
+  rx, ry, rz = rx / rl, ry / rl, rz / rl
+  local ux = ry * fz - rz * fy
+  local uy = rz * fx - rx * fz
+  local uz = rx * fy - ry * fx
+  local hh = math.tan(fov * 0.5)
+  local hw = hh * (w / h)
+  -- canvas row 0 is the TOP of the frame, which is the +up corner
+  local function corner(su, sv)
+    return fx + rx * hw * su + ux * hh * sv,
+           fy + ry * hw * su + uy * hh * sv,
+           fz + rz * hw * su + uz * hh * sv
+  end
+  local x0, y0, z0 = corner(-1, 1)
+  local x1, y1, z1 = corner(1, 1)
+  local x2, y2, z2 = corner(1, -1)
+  local x3, y3, z3 = corner(-1, -1)
+  local verts = {
+    { 0, 0, x0, y0, z0 },
+    { w, 0, x1, y1, z1 },
+    { w, h, x2, y2, z2 },
+    { 0, h, x3, y3, z3 },
+  }
+  if not rayMesh then
+    local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts,
+                           "fan", "stream")
+    rayMesh = ok and mesh or nil
+    return rayMesh
+  end
+  local ok = pcall(rayMesh.setVertices, rayMesh, verts)
+  return ok and rayMesh or nil
+end
+
+-- ------- the particles
+
+local PART_SHADER = [[
+  varying vec2 vCorner;
+  varying float vGlow;
+#ifdef VERTEX
+  uniform mat4 vp;
+  uniform vec3 curve;
+  uniform vec3 axisR;      // the camera's right, world space
+  uniform vec3 axisU;      // and its up: the billboard's own frame
+  uniform float time;
+  uniform float size;
+  uniform vec2 sway;       // wander amplitude: horizontal, vertical
+  uniform float blinky;    // 0 = steady motes, 1 = blinking fireflies
+  attribute vec4 AtmosData;    // corner x, corner y, phase, rate
+  vec4 position(mat4 transform_projection, vec4 vertex_position) {
+    float ph = AtmosData.z;
+    float rt = AtmosData.w;
+    float t = time * (0.5 + rt);
+    // bounded wander only -- three incommensurate sines, so nothing ever
+    // walks off the map or needs a CPU tick to bring it home
+    vec3 base = vertex_position.xyz + vec3(
+      sin(t * 0.23 + ph) * sway.x,
+      sin(t * 0.17 + ph * 2.7) * sway.y,
+      cos(t * 0.19 + ph * 1.3) * sway.x);
+    float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0);
+    vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky);
+    vCorner = AtmosData.xy;
+    vec4 w = vec4(base + axisR * (AtmosData.x * size)
+                       + axisU * (AtmosData.y * size), 1.0);
+    if (curve.z > 0.0) {
+      vec2 cd = w.xz - curve.xy;
+      w.y -= dot(cd, cd) * curve.z;
+    }
+    return vp * w;
+  }
+#endif
+#ifdef PIXEL
+  uniform vec3 dotColor;
+  uniform float level;
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    float d = dot(vCorner, vCorner);
+    float glow = max(0.0, 1.0 - d);
+    glow *= glow;
+    return vec4(dotColor, glow * level * vGlow) * color;
+  }
+#endif
+]]
+
+local function partShader()
+  local s = shaders.part
+  if s ~= nil then return s or nil end
+  if not (love and love.graphics and love.graphics.newShader) then
+    shaders.part = false
+    return nil
+  end
+  local ok, sh = pcall(love.graphics.newShader, PART_SHADER)
+  shaders.part = (ok and sh) or false
+  return shaders.part or nil
+end
+
+local PART_FORMAT = {
+  { "VertexPosition", "float", 3 },
+  { "AtmosData", "float", 4 },
+}
+
+local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } }
+
+local function pushQuad(map, n)
+  local b = n * 4
+  map[#map + 1] = b + 1
+  map[#map + 1] = b + 2
+  map[#map + 1] = b + 3
+  map[#map + 1] = b + 1
+  map[#map + 1] = b + 3
+  map[#map + 1] = b + 4
+end
+
+local function buildPartMesh(points)
+  if #points == 0 then return nil end
+  local verts, indices = {}, {}
+  for i = 1, #points do
+    local p = points[i]
+    for c = 1, 4 do
+      verts[#verts + 1] = { p.x, p.y, p.z,
+                            CORNERS[c][1], CORNERS[c][2], p.phase, p.rate }
+    end
+    pushQuad(indices, i - 1)
+  end
+  local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts,
+                         "triangles", "static")
+  if not ok then return nil end
+  pcall(mesh.setVertexMap, mesh, indices)
+  return mesh
+end
+
+local function meshesFor(map, L)
+  local hit = meshCache[map.id]
+  if hit then return hit end
+  local M = {
+    motes = buildPartMesh(L.motes),
+    flies = buildPartMesh(L.flies),
+  }
+  meshCache[map.id] = M
+  return M
+end
+
+local MOTE_COLOR = { 1.0, 0.96, 0.78 }
+local FLY_COLOR = { 0.72, 1.0, 0.45 }
+
+-- The billboard frame: the camera's own right and up, from the same
+-- fields every pass sets (per VR eye too -- drawScene runs per eye and
+-- reads the eye's camera). Degenerate looks answer nil and the
+-- particles sit this one out.
+local function billboardAxes(Voxel3D)
+  local e, fo = Voxel3D.eye, Voxel3D.focus
+  if not (e and fo) then return nil end
+  local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
+  local ll = sqrt(lx * lx + ly * ly + lz * lz)
+  if ll < 1e-6 then return nil end
+  lx, ly, lz = lx / ll, ly / ll, lz / ll
+  local rx, rz = -lz, lx
+  local rl = sqrt(rx * rx + rz * rz)
+  if rl < 1e-4 then return nil end
+  rx, rz = rx / rl, rz / rl
+  return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly }
+end
+
+-- ------- the draw
+--
+-- Inside the scene pass, in VoxelScene's prop slot. The march borrows
+-- the frame's depth through Voxel3D.beginWater -- the same detach Water
+-- runs -- and hand-tests every step against it, so the pass itself needs
+-- no depth attachment; the particles come after, depth-tested additive
+-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
+-- refused shader, no readable depth, no shadow map -- subtracts only
+-- itself.
+function ForestAtmos.draw(map)
+  local rung = ForestAtmos.setting:get()
+  if rung == "off" then return end
+  local f = ForestAtmos.frame(map)
+  if not f then return end
+  local Voxel3D = V.require("Voxel3D")
+  local ShadowMap = V.require("ShadowMap")
+
+  if f.rayAlpha > 0.01 then
+    if not Voxel3D.depthReadable() then
+      say("depth", "no readable depth this frame -- beams off, fog stays")
+      return
+    end
+    -- no beams without the sun's own pass: uvVP is only the world -> map
+    -- transform while a shadow map is actually standing
+    local sunTex = ShadowMap.active() and ShadowMap.texture()
+    if not sunTex then
+      say("sun", "no shadow map standing -- beams off, fog stays")
+    end
+    local leaf = leafTexture()
+    if not leaf then
+      say("leaf", "leaf field would not build -- beams off, fog stays")
+    end
+    local sh = rayShaderFor(rung == "low" and 12 or 24)
+    local w, h = Voxel3D.size()
+    local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h)
+    if sh and sunTex and leaf and w and not quad then
+      say("quad", "no camera frame for the ray fan -- beams off")
+    end
+    if quad then
+      local _, depth = Voxel3D.beginWater(nil)
+      if depth then
+        say("on", "volumetric beams running")
+        local kx, kz = DayNight.shearAt(DayNight.T.day)
+        local kl = sqrt(kx * kx + kz * kz + 1)
+        love.graphics.setBlendMode("add", "alphamultiply")
+        love.graphics.setShader(sh)
+        pcall(sh.send, sh, "depthTex", depth)
+        pcall(sh.send, sh, "sunMap", sunTex)
+        pcall(sh.send, sh, "leafTex", leaf)
+        pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
+        pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP)
+        pcall(sh.send, sh, "sunBias", ShadowMap.bias)
+        pcall(sh.send, sh, "eye", Voxel3D.eye)
+        pcall(sh.send, sh, "curve",
+              { Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
+                Voxel3D.curveK or 0 })
+        pcall(sh.send, sh, "screen", { w, h })
+        pcall(sh.send, sh, "fogW",
+              { f.fog.density, f.fog.heightK,
+                f.cfg.canopyY or 56, f.cfg.fadeTo or 28 })
+        pcall(sh.send, sh, "shear", { kx, kz, f.rayReach })
+        pcall(sh.send, sh, "rayColor", f.rayColor)
+        pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha)
+        pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl })
+        pcall(sh.send, sh, "wind", { 0.016, 0.009 })
+        pcall(sh.send, sh, "time", ForestAtmos.time)
+        pcall(love.graphics.draw, quad)
+        love.graphics.setShader()
+        love.graphics.setBlendMode("alpha")
+      end
+      Voxel3D.endWater()
+    end
+  end
+
+  if rung == "full" then
+    local L = layoutFor(map)
+    local M = L and meshesFor(map, L)
+    local psh = partShader()
+    local axisR, axisU = billboardAxes(Voxel3D)
+    if M and psh and axisR then
+      Voxel3D.blend("add")
+      if Voxel3D.beginEffect(psh) then
+        pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
+        pcall(psh.send, psh, "curve",
+              { Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
+                Voxel3D.curveK or 0 })
+        pcall(psh.send, psh, "axisR", axisR)
+        pcall(psh.send, psh, "axisU", axisU)
+        pcall(psh.send, psh, "time", ForestAtmos.time)
+        if M.motes and f.moteLevel > 0.02 then
+          pcall(psh.send, psh, "size", 1.4)
+          pcall(psh.send, psh, "sway", { 5, 2.5 })
+          pcall(psh.send, psh, "blinky", 0)
+          pcall(psh.send, psh, "dotColor", MOTE_COLOR)
+          pcall(psh.send, psh, "level", f.moteLevel * 0.5)
+          pcall(love.graphics.draw, M.motes)
+        end
+        if M.flies and f.fireflyLevel > 0.02 then
+          pcall(psh.send, psh, "size", 1.6)
+          pcall(psh.send, psh, "sway", { 10, 4 })
+          pcall(psh.send, psh, "blinky", 1)
+          pcall(psh.send, psh, "dotColor", FLY_COLOR)
+          pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
+          pcall(love.graphics.draw, M.flies)
+        end
+        Voxel3D.endEffect()
+      end
+      Voxel3D.blend(nil)
+    end
+  end
+end
+
+return ForestAtmos
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index a15f265..c7e5df5 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -72,6 +72,7 @@ Voxel3D.FACE_SHADE = {
 local SHADER = [[
   varying float vShade;
   varying vec3 vSun;          // this fragment's place in the sun's view
+  varying float vFog;         // how deep into the map's haze it stands
 #ifdef VOXEL_GRID
   // model space, one unit per voxel -- see VoxelGrid. Precision matters
   // here in a way it does not for a colour: the seam is the FRACTIONAL
@@ -87,6 +88,7 @@ local SHADER = [[
   uniform vec3 eye;
   uniform float pull;
   uniform vec3 curve;         // xy = the focus in world XZ, z = k; 0 = off
+  uniform vec4 fogInfo;       // density, start, heightK; density 0 = clear
   attribute float VertexShade;
   vec4 position(mat4 transform_projection, vec4 vertex_position) {
     vShade = VertexShade;
@@ -108,6 +110,19 @@ local SHADER = [[
     // answered. (The pull below is excluded for the same reason: it is a
     // depth trick aimed at the camera's own buffer.)
     vSun = (sunVP * (sunModel * vertex_position)).xyz;
+    // THE MAP'S HAZE (see ForestAtmos): how much fog stands between the
+    // eye and this vertex -- distance dissolves into it, altitude climbs
+    // out of it. Worked out on the FLAT world like the shadow lookup
+    // above (the curve is a trick played on the viewer, not weather),
+    // and per VERTEX: on meshes built a face per voxel the interpolated
+    // answer is indistinguishable from per-fragment fog at a fraction of
+    // the cost.
+    vFog = 0.0;
+    if (fogInfo.x > 0.0) {
+      float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y);
+      vFog = (1.0 - exp(-fogInfo.x * fogRun))
+             * exp(-max(w.y, 0.0) * fogInfo.z);
+    }
     // The curved world (see WorldCurve): drop every vertex by the square
     // of how far its column stands from the camera's focus. Applied AFTER
     // the shadow lookup above and clear of the wireframe's model space, so
@@ -205,6 +220,7 @@ local SHADER = [[
   uniform vec3 ghostColor;    // the flat silhouette colour
   uniform float ghost;        // 0 = shade normally, 1 = flatten to it
   uniform vec3 dayTint;       // the hour's light on the world; 1,1,1 = noon
+  uniform vec3 fogColor;      // what the haze is made of (see Voxel3D.fog)
   uniform Image glassMask;    // opaque where the atlas texel is window glass
   uniform vec2 glassSize;     // the mask's dimensions: tc -> atlas texels
   uniform float glassNight;   // 0 = daylight .. 1 = the lamps are on
@@ -256,6 +272,11 @@ local SHADER = [[
       vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine);
       rgb = mix(pane, lamp, glassNight * glass);
     }
+    // the haze stands between the eye and the SURFACE, so it lands after
+    // every surface term -- sun, seams, glass -- and before only the
+    // ghost, which must stay one solid readable shape whatever the
+    // weather (see below)
+    rgb = mix(rgb, fogColor, vFog);
     // The hidden player is a SHAPE, not a dimmed picture of itself. Tinting
     // through `color` could only multiply the sprite's own pixels, which
     // darkens each one by its own amount and keeps the character's internal
@@ -687,6 +708,14 @@ end
 -- answers it, so a caller that never does draws exactly what it always drew.
 Voxel3D.tint = { 1, 1, 1 }
 
+-- The map's haze, set the same way (VoxelScene and BattleScene ask
+-- ForestAtmos, who knows which maps have weather): a table of
+-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day.
+-- nil -- the default -- sends density 0, so a caller that never heard of
+-- fog draws exactly what it always drew, and no pass can inherit the
+-- last one's weather.
+Voxel3D.fog = nil
+
 -- The window-glass pass, set the same way and for the same reason: the
 -- MASK belongs to the map's tileset (GlassMask.texture) and how lit the
 -- panes are belongs to the hour and to being outdoors at all
@@ -922,6 +951,12 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
   pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
   -- the hour's light, as the caller last set it (see Voxel3D.tint)
   pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
+  -- and the map's haze (see Voxel3D.fog), density 0 when there is none
+  local fog = Voxel3D.fog
+  pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 })
+  pcall(sh.send, sh, "fogInfo", fog and
+        { fog.density or 0, fog.start or 0, fog.heightK or 0, 0 }
+        or { 0, 0, 0, 0 })
   -- the window glass: the tileset's mask (or the blank -- the sampler is
   -- declared either way, and unbound is a driver-dependent crash), how lit
   -- the panes are, and the movement-fed glint as the caller last set it
@@ -1127,6 +1162,25 @@ function Voxel3D.endWater()
   if activeShader then love.graphics.setShader(activeShader) end
 end
 
+-- A custom shader for the length of a draw, inside the pass. What makes
+-- this a pair rather than a bare setShader at the call site is the way
+-- BACK: the scene shader this pass bound is module-local (activeShader,
+-- above), so only this file can restore it -- the same restore endWater
+-- performs, without the canvas shuffle. Answers false when there is no
+-- pass to come back to, and the caller skips its draw entirely.
+function Voxel3D.beginEffect(shader)
+  if not (active and shader) then return false end
+  love.graphics.setShader(shader)
+  love.graphics.setColor(1, 1, 1, 1)
+  return true
+end
+
+function Voxel3D.endEffect()
+  if not active then return end
+  love.graphics.setColor(1, 1, 1, 1)
+  if activeShader then love.graphics.setShader(activeShader) end
+end
+
 -- Whether a reflective water pass can run in this frame at all -- there is
 -- a depth texture to read. Callers use it to choose between the water
 -- shader and an ordinary terrain draw before they start moving canvases.
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index dd4a146..907408b 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -914,6 +914,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
   Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
   local g = VoxelScene.glintStep(glint, cx, cy)
   Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
+  -- and the map's atmosphere, if it has one (see ForestAtmos): the haze
+  -- the scene shader folds every surface into, in the hour's colour.
+  -- nil for every map without an entry -- a clear day, exactly as before.
+  local ForestAtmos = V.require("ForestAtmos")
+  local atmos = ForestAtmos.frame(state.map)
+  Voxel3D.fog = atmos and atmos.fog or nil
 
   local function atlasFor(map)
     return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
@@ -1152,6 +1158,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
                  ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
   end
 
+  -- The map's atmosphere -- god rays down from the invisible canopy, and
+  -- whatever drifts through them (see ForestAtmos). Additive over the
+  -- finished depth buffer, so the trees occlude the light and the light
+  -- writes nothing; here in the prop slot, after everything the beams
+  -- should fall across and inside drawScene so VR gets them per eye. On
+  -- the one map that has any, today.
+  ForestAtmos.draw(state.map)
+
   -- The VR pokedex in the player's left hand, last of all: a prop over
   -- the world drawn with real depth, so leaning it into a wall still
   -- occludes honestly. Its frame only exists while a session is live and
diff --git a/main.lua b/main.lua
index 4714165..28f41b5 100644
--- a/main.lua
+++ b/main.lua
@@ -90,6 +90,7 @@ local BattleExit = V.require("BattleExit")
 local DayNight = V.require("DayNight")
 local DayTint = V.require("DayTint")
 local Water = V.require("Water")
+local ForestAtmos = V.require("ForestAtmos")
 local AntiAlias = V.require("AntiAlias")
 local FirstPerson = V.require("FirstPerson")
 local FreeMove = V.require("FreeMove")
@@ -191,6 +192,9 @@ mod.content.render_pipelines:register("voxel", {
     -- battles and menus, and a CYCLE evening falls mid-fight exactly as it
     -- would mid-walk
     DayNight.update(dt)
+    -- the atmosphere's own clock (shaft shimmer, drifting motes), on the
+    -- same tick so the beams keep breathing through a dialog box
+    ForestAtmos.update(dt)
     -- The overworld battle rides this hook rather than owning a pipeline of
     -- its own, because it owns no pass of the FRAME: it draws under a battle
     -- screen the engine composites, which is not a stage the registry has.
@@ -302,6 +306,7 @@ mod.content.render_pipelines:register("voxel", {
     OverworldBattle.invalidate()
     AntiAlias.invalidate()
     ChunkMesher.invalidate()   -- no map id = every cached mesh
+    ForestAtmos.invalidate()   -- shaft/particle meshes and shader sentinels
     VR.invalidate()            -- the mirror, and FBO ids of dead canvases
   end,
 })
@@ -427,6 +432,18 @@ local SETTINGS = {
     .. "shoreline, the trees and the buildings behind it; SKY is the sky, "
     .. "the sun and the moon alone, which is most of the look for a "
     .. "fraction of the cost." },
+  -- `full` for the AA reason: additive shafts are fill rate, and under 4X
+  -- supersampling that is a question about the hardware, not the look.
+  { ForestAtmos.setting,
+    "The air of the deep woods (Viridian Forest): a ground haze, and "
+    .. "volumetric light let down through the unseen canopy overhead -- "
+    .. "gold spears of sun by day, silver moon rays at night, pollen "
+    .. "drifting through the beams and fireflies once they cool. LOW "
+    .. "keeps the haze, halves the beam march and stands the particles "
+    .. "down. On a phone the row offers LOW alone: the beams need a "
+    .. "depth texture the pass can read back, and no mobile driver here "
+    .. "grants one.",
+    full = true },
   -- `full` marks a row FULL does not take away. FULL owns the diorama's own
   -- knobs; what a battle is drawn over, and how it is framed, are not that.
   -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
@@ -914,6 +931,9 @@ mod.events:on("map.reloaded", function(payload)
   if payload and payload.reason == "colors" then return end
   local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
   if mapId then ChunkMesher.invalidate(mapId) end
+  -- the atmosphere's layout stands on the same carved stamps the meshes
+  -- do, so it goes stale on exactly the same event
+  if mapId then ForestAtmos.invalidate(mapId) end
 end)
 
 -- ------- rows come and go, so the menu has to notice
diff --git a/manifest.json b/manifest.json
index 32e5c9e..8a32961 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.6.1",
+  "version": "1.6.2",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index ee96136..0a7487c 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -319,7 +319,7 @@ local order = {}
 for i, row in ipairs(grouped) do order[row.id] = i end
 T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
   "the mode's settings follow its pipeline rows")
-T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4,
+T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 5,
   "and sit in one unbroken block, not scattered to the end of the list")
 T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
   "with the engine's own later rows still after them")
@@ -404,18 +404,23 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
 -- (nothing to store, nothing for the mod manager to persist) and is offered
 -- on every platform, saying WHERE? rather than IMPORT where there is no file
 -- dialog to open
-T.eq(#hookedRows, 10, "the options hook added a row per setting, plus the "
+T.eq(#hookedRows, 11, "the options hook added a row per setting, plus the "
   .. "STADIUM ROM action row")
 local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
-local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
--- the AA row is hookedRows[8]; it is read in its own block below, because
--- this chunk is one main function and has 200 local slots to spend
+local battles, backRow, daytime = hookedRows[6], hookedRows[7], hookedRows[8]
+-- the FOREST FX row is hookedRows[5] and AA hookedRows[9]; both are read
+-- where they are used rather than named here, because this chunk is one
+-- main function and has 200 local slots to spend
 T.eq(water.label, "WATER", "the water row carries its label")
 T.eq(water.value(), "FULL",
   "and defaults to FULL -- reflections are the point of having the row")
 water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
 T.eq(water.value(), "SKY",
   "stepping down drops the screen-space march and keeps the sky, sun and moon")
+T.eq(hookedRows[5].label, "FOREST FX", "the atmosphere row carries its label")
+T.eq(hookedRows[5].value(), "FULL",
+  "and defaults to FULL -- it only spends anything on a map with an "
+  .. "atmosphere entry, which is one forest today")
 T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
 T.eq(daytime.value(), "SYNC",
   "and defaults to SYNC -- no value set follows the clock on the wall")
@@ -499,7 +504,7 @@ do
 local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
 local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
 local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
-local aa = hookedRows[8]
+local aa = hookedRows[9]
 T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
 T.eq(aa.value(), "OFF",
   "and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -4064,6 +4069,92 @@ T.check(not DayNight.isCanopy({ id = "PALLET_TOWN" }),
 T.check(not DayNight.isCanopy(nil), "no map, no canopy")
 end
 
+-- ------- the air under the canopy
+--
+-- ForestAtmos hangs an INVISIBLE canopy above the forest's real trees and
+-- lets light down through it: fog for the scene shader, a volumetric
+-- march for the beams (GPU-only, not checkable here), colour and strength
+-- following the clock. Everything checkable without a GPU is checked:
+-- the authored table, the hour's ramp, and the seeded particle deal --
+-- which must come out identical on every visit.
+do
+local ForestAtmos =
+  run.loader.exports.DRAMATIC_SHAPE.lib.require("ForestAtmos")
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
+
+-- the authored table
+local cfg = ForestAtmos.configFor("VIRIDIAN_FOREST")
+T.check(cfg ~= nil, "Viridian Forest has an atmosphere entry")
+T.check(cfg and (cfg.canopyY or 0) > 32,
+  "whose invisible canopy hangs ABOVE the carved tree hulls (y = 32) -- "
+  .. "a ray is alpha zero at the canopy and only fades in below it")
+T.check(ForestAtmos.configFor("PALLET_TOWN") == nil,
+  "a map without an entry has no atmosphere at all")
+T.check(ForestAtmos.configFor(nil) == nil, "and no map id does not crash")
+
+-- the hour's ramp: gold spears by day, silver rays by night, both dying
+-- back through the twilight handover
+local fmap = { id = "VIRIDIAN_FOREST" }
+local day = ForestAtmos.frame(fmap, DayNight.T.day)
+local night = ForestAtmos.frame(fmap, DayNight.T.night)
+local dusk = ForestAtmos.frame(fmap, DayNight.T.dusk)
+T.check(day and night and dusk, "the forest answers at every pin")
+T.check(day.rayColor[1] > day.rayColor[3],
+  "the day's rays are sun-gold: more red than blue")
+T.check(night.rayColor[3] > night.rayColor[1],
+  "the night's are moon-silver: more blue than red")
+T.check(dusk.rayAlpha < day.rayAlpha and dusk.rayAlpha < night.rayAlpha,
+  "and the twilight is the dim handover between the two")
+T.check(day.fog.density > 0 and night.fog.density > 0,
+  "the haze never lifts entirely, day or night")
+T.check(day.moteLevel > 0.5 and day.fireflyLevel < 0.05,
+  "pollen drifts through the day's beams, with no fireflies out")
+T.check(night.fireflyLevel > 0.5 and night.moteLevel < 0.05,
+  "and the night shift trades them")
+
+-- the seeded deal: the same particles on every visit (the beams place
+-- themselves -- they are marched from the shadow map, not dealt)
+local tcfg = { canopyY = 56, fadeTo = 28, seed = 77,
+               motes = { count = 12 }, fireflies = { count = 6 } }
+local a = ForestAtmos.layout(tcfg, 320, 320)
+local b = ForestAtmos.layout(tcfg, 320, 320)
+T.eq(#a.motes, 12, "the pollen musters at authored strength")
+T.eq(#a.flies, 6, "the fireflies too")
+local same = true
+for i = 1, #a.motes do
+  local m1, m2 = a.motes[i], b.motes[i]
+  same = same and m1.x == m2.x and m1.y == m2.y and m1.z == m2.z
+end
+T.check(same, "and the deal comes out identical on every visit")
+local under = true
+for _, m in ipairs(a.motes) do
+  if m.y >= tcfg.canopyY then under = false end
+end
+T.check(under, "everything drifts BELOW the canopy it lives under")
+
+-- the tuning override, the same handle arena_editor holds on battles
+ForestAtmos.setOverride("PALLET_TOWN", { canopyY = 40, fog = {} })
+T.check(ForestAtmos.configFor("PALLET_TOWN") ~= nil,
+  "an override stages an atmosphere a driver can tune live")
+ForestAtmos.setOverride("VIRIDIAN_FOREST", false)
+T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") == nil,
+  "false is meaningful: this map has none, whatever the file says")
+ForestAtmos.setOverride("PALLET_TOWN", nil)
+ForestAtmos.setOverride("VIRIDIAN_FOREST", nil)
+T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") ~= nil
+        and ForestAtmos.configFor("PALLET_TOWN") == nil,
+  "and nil hands both back to the authored table")
+
+-- the row: OFF is the ladder's last rung and the frame answers nothing
+T.eq(ForestAtmos.setting.values[1], "full",
+  "the atmosphere defaults to FULL -- it costs a handful of quads and "
+  .. "exists on one map")
+ForestAtmos.setting:sync("off")
+T.check(ForestAtmos.frame(fmap, DayNight.T.day) == nil,
+  "OFF answers no frame at all: no fog uniform, no draw, no spend")
+ForestAtmos.setting:sync("full")
+end
+
 -- ------- a shadow keeps hold of the feet that throw it
 --
 -- The depth compare forgives `slack` world pixels so lit ground does not
diff --git a/tests/forest_fog_shots.lua b/tests/forest_fog_shots.lua
new file mode 100644
index 0000000..d3a2e06
--- /dev/null
+++ b/tests/forest_fog_shots.lua
@@ -0,0 +1,137 @@
+-- Driver: screenshot the forest's atmosphere.
+--
+-- Viridian Forest through the pins: gold shafts and drifting pollen by
+-- day, silver moon rays and fireflies at night, the haze under both, one
+-- first-person frame standing inside a beam, and the control shots -- the
+-- row OFF (which must be pixel-identical to a run before the feature
+-- existed) and LOW (haze, half the shafts, no particles).
+--
+-- Deterministic on purpose: encounters killed, tile animation frozen, the
+-- atmosphere's own clock pinned, so an AB_TAG=before/after pair diffs
+-- clean (see tools/voxel-survey.md for the workflow).
+--
+--   SHOT_DIR=.scratchpad/forestfog AB_TAG=after \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/forest_fog_shots.lua lovec.exe .
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/forestfog")
+    .. "/" .. (os.getenv("AB_TAG") or "after")
+
+  local exports = game.mods and game.mods.exports
+  local handle = exports and exports.DRAMATIC_SHAPE
+  if not (handle and handle.lib) then
+    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+    return
+  end
+  local lib = handle.lib
+  local DayNight = lib.require("DayNight")
+  local ChunkMesher = lib.require("ChunkMesher")
+  local Voxel = lib.require("VoxelState")
+  local ForestAtmos = lib.require("ForestAtmos")
+
+  if not (game.data.maps and game.data.maps.VIRIDIAN_FOREST) then
+    U.log("no VIRIDIAN_FOREST in this dataset")
+    return
+  end
+
+  -- ------- the determinism recipe (see round_regress_shots)
+  require("src.world.OverworldController").rollEncounter =
+    function() return nil end
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+  local Zoom = require("src.render.Zoom")
+  pcall(function()
+    game.save.options.zoom = 1
+    Zoom.applyOptions(game.save.options)
+  end)
+  -- pin the atmosphere's own clock: every shimmer and every mote is a
+  -- pure function of this number
+  ForestAtmos.frozen = true
+  ForestAtmos.time = 5
+
+  local function setTime(value)
+    DayNight.setting:sync(value)
+    DayNight.update(0)
+  end
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
+        break
+      end
+      U.wait(1)
+    end
+    U.wait(40)
+  end
+
+  local function go(x, y, face, rung)
+    U.teleport(game, "VIRIDIAN_FOREST", x, y, face or "up")
+    Pipelines.setLevel("voxel", rung or 5)
+    Pipelines.setLevel("tiltshift", 0)   -- judge the atmosphere, not the blur
+    settle()
+  end
+
+  ForestAtmos.setting:sync("full")
+
+  -- ------- day: gold spears and pollen, three vantages
+  setTime("day")
+  go(17, 20)
+  U.shot(game, ROOT .. "/10_day_17_20.png")
+  go(16, 20)
+  U.shot(game, ROOT .. "/11_day_16_20.png")
+  go(16, 24)
+  U.shot(game, ROOT .. "/12_day_16_24.png")
+
+  -- ------- night: moon rays and fireflies, same vantages
+  setTime("night")
+  go(17, 20)
+  U.shot(game, ROOT .. "/20_night_17_20.png")
+  go(16, 20)
+  U.shot(game, ROOT .. "/21_night_16_20.png")
+  go(16, 24)
+  U.shot(game, ROOT .. "/22_night_16_24.png")
+
+  -- ------- first person, standing in a beam
+  --
+  -- The shafts are dealt by seed, so ask the layout where one landed and
+  -- walk into it rather than guessing a cell.
+  local shaft
+  pcall(function()
+    local map = game.overworld and game.overworld.map
+    local L = map and ForestAtmos.layoutFor(map)
+    shaft = L and L.shafts and L.shafts[1]
+  end)
+  if shaft then
+    local cx = math.floor(shaft.x / 16)
+    local cy = math.floor(shaft.z / 16)
+    setTime("day")
+    go(cx, cy + 1, "up", 6)              -- the 1ST rung, facing the beam
+    U.shot(game, ROOT .. "/30_fp_in_beam_day.png")
+    setTime("night")
+    U.wait(30)
+    U.shot(game, ROOT .. "/31_fp_in_beam_night.png")
+  else
+    U.log("no shaft landed -- check the layout seed")
+  end
+
+  -- ------- the controls
+  setTime("day")
+  ForestAtmos.setting:sync("low")
+  go(17, 20)
+  U.shot(game, ROOT .. "/40_low_day.png")
+  ForestAtmos.setting:sync("off")
+  go(17, 20)
+  U.shot(game, ROOT .. "/41_off_day.png")   -- must match a pre-feature run
+
+  ForestAtmos.setting:sync("full")
+  ForestAtmos.frozen = false
+  setTime("day")
+  U.log("done -- " .. ROOT)
+end