-- 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 = {}

-- The diorama's viewport, as both shaders here take it (see Voxel3D.cull:
-- the field is set for a headset's diorama frame and nil for every other,
-- where kind 0 means "no cut"). Read through V.require rather than held as
-- an upvalue because this file loads before Voxel3D on some paths.
local function cullAt()
  local c = V.require("Voxel3D").cull
  return c and { c.x, c.y, c.z } or { 0, 0, 0 }
end

local function cullShape()
  local c = V.require("Voxel3D").cull
  return c and { c.r, c.invFade, c.kind } or { 0, 0, 0 }
end

-- 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;
  // the diorama's viewport, as the scene shader takes it (see Voxel3D):
  // air outside the model is not air, so a sample out there contributes
  // nothing and the beams end with the world they fall through
  uniform vec3 cullAt;
  uniform vec3 cullShape;

  float dioramaCull(vec3 p) {
    if (cullShape.z <= 0.5) return 1.0;
    vec3 cd = p - cullAt;
    float d;
    if (cullShape.z < 1.5) {
      d = max(abs(cd.x), abs(cd.z));      // the box
    } else if (cullShape.z < 2.5) {
      d = length(cd);                     // the ball
    } else {
      d = length(cd.xz);                  // the fight's pillar
    }
    return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0);
  }

  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
               * dioramaCull(p);
      }
      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 cullAt;     // the diorama's viewport (see Voxel3D.cull):
  uniform vec3 cullShape;  // a mote outside the model is not in the air
  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);
    // a whole mote at once: these are points, so the rim can dim them
    // rather than having to cut one in half
    if (cullShape.z > 0.5) {
      vec3 cd = base - cullAt;
      float cdd;
      if (cullShape.z < 1.5) {
        cdd = max(abs(cd.x), abs(cd.z));
      } else if (cullShape.z < 2.5) {
        cdd = length(cd);
      } else {
        cdd = length(cd.xz);
      }
      vGlow *= clamp((cullShape.x - cdd) * cullShape.y, 0.0, 1.0);
    }
    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, "cullAt", cullAt())
        pcall(sh.send, sh, "cullShape", cullShape())
        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, "cullAt", cullAt())
        pcall(psh.send, psh, "cullShape", cullShape())
        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
