-- 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 viewport, as both shaders here take it (see Voxel3D.cull: the field -- is set for a headset's diorama frame and for an orbit rung's window box, -- 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 local function cullRect() local c = V.require("Voxel3D").cull return c and { c.rx or c.r, c.rz or c.r } or { 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 grassCache = {} -- the grass deal, keyed by map; false = no grass 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 grassCache[mapId] = nil meshCache[mapId] = nil else layoutCache, grassCache, 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 }, } -- How far out the night shift is, 0..1, at clock `t`. Pulled out of the -- frame's own loop because the GRASS fireflies (below) need it on maps -- that have no atmosphere at all -- and pulled out rather than copied so -- the two can never disagree about when a firefly comes on. function ForestAtmos.fireflyLevel(t) local flies = 0 for name, w in pairs(DayNight.mix(t or DayNight.time())) do flies = flies + (ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day).flies * w end return flies end -- 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 = 0, 0, 0 local flies = ForestAtmos.fireflyLevel(t) 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 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 -- ------- the grass fireflies -- -- The same particle -- same mesh format, same blinking shader, same hour's -- ramp -- dealt over a map's TALL GRASS instead of over its whole volume, -- and on any outdoor map rather than only the ones with an atmosphere -- entry. Grass IS the entry: a route with tall grass on it gets fireflies -- after dark without anybody authoring a line, and a map with no grass -- cell on it deals nothing and costs one scan. -- -- Placement asks the engine's own question, `map:isGrassCell` -- the -- cell's collision tile, the rule that decides where a wild battle can -- start -- which is the same test Structures runs before it sprouts a -- tuft. The grass GRAPHIC also turns up as decorative filler inside plain -- ground blocks, and going by the tile would hang fireflies over town -- plazas (the trap that note in Structures.buildGrass records). -- -- They are dealt into the cell rather than at its middle, and the shader's -- own wander (sway 10, 4) carries each one about a cell's width from where -- it was dealt -- so a patch reads as a patch with fireflies loose over it, -- not as a grid of lights. The height band is the tufts' own: 3..15, which -- is blade height and a little air (Structures stands its blades to y = 16). -- -- This is the FULL rung, like every other particle here. The per-tuft -- firefly cards in Structures.buildGrass are the layer underneath -- they -- are static geometry the scene shader already carries, so grass still has -- something alight after dark on LOW and on Android, where this pass is -- not drawn at all. local GRASS_PER_CELL = 0.8 -- fireflies per grass cell... local GRASS_CAP = 200 -- ...up to this many on one map local CELL = 16 -- world px, the grid isGrassCell speaks -- A map id deals its own swarm: stable across visits and saves (nothing -- here rides the clock), different between maps, so two routes with the -- same amount of grass do not get the same arrangement of lights. local function seedOf(id) local s = 0x51D for i = 1, #id do s = (s * 31 + id:byte(i)) % 0x100000000 end return s end -- `cells` is a flat list of { cx, cy } pairs; count is worked out from how -- many there are. Deterministic, and separated from the map so a test can -- hand it a meadow. function ForestAtmos.grassLayout(cells, seed, per, cap) local n = #cells if n == 0 then return {} end local want = min(floor(n * (per or GRASS_PER_CELL) + 0.5), cap or GRASS_CAP) local rng = newRng(seed or 0x51D) local flies = {} for _ = 1, want do local c = cells[1 + min(floor(rng:unit() * n), n - 1)] flies[#flies + 1] = { x = c[1] * CELL + rng:unit() * CELL, y = 3 + rng:unit() * 12, z = c[2] * CELL + rng:unit() * CELL, phase = rng:unit() * 6.2832, rate = 0.5 + rng:unit(), } end return flies end -- Only where the clock reaches: an outdoor map, or a CANOPY one (Viridian -- Forest is not outdoor -- no sky, no sun -- but night still falls in it, -- and it is the map these fireflies were drawn for). A cave stays a cave. local function litByTheHour(map) if DayNight.isCanopy(map) then return true end local ok, Map = pcall(require, "src.world.Map") if not (ok and Map and Map.isOutdoor) then return false end local got, outdoor = pcall(Map.isOutdoor, map.def or {}) return got and outdoor or false end -- One scan of the map's cells, cached with everything else that goes stale -- with it. `false` records "scanned, no grass" so a grassless map is never -- walked twice. local function grassFliesFor(map) local hit = grassCache[map.id] if hit ~= nil then return hit or nil end if not (map.isGrassCell and litByTheHour(map)) then grassCache[map.id] = false return nil end local cells = {} local w = map.widthCells or ((map.def and map.def.width or 0) * 2) local h = map.heightCells or ((map.def and map.def.height or 0) * 2) -- one pcall around the whole scan, not one per cell: a route is a couple -- of thousand cells and this runs on the frame that first draws the map pcall(function() for cy = 0, h - 1 do for cx = 0, w - 1 do if map:isGrassCell(cx, cy) then cells[#cells + 1] = { cx, cy } end end end end) local cfg = configFor(map.id) or {} local knob = cfg.grassFlies or {} local flies = ForestAtmos.grassLayout(cells, seedOf(map.id), knob.per, knob.cap) grassCache[map.id] = (#flies > 0) and flies or false return grassCache[map.id] or nil end ForestAtmos.grassFliesFor = grassFliesFor -- 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 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; uniform vec2 cullRect; // the box's half-extents in x and z float dioramaCull(vec3 p) { if (cullShape.z <= 0.5) return 1.0; vec3 cd = p - cullAt; float inside; if (cullShape.z < 1.5) { // the box inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { inside = cullShape.x - length(cd); // the ball } else { inside = cullShape.x - length(cd.xz); // the fight's pillar } return clamp(inside * 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 viewport (see Voxel3D.cull): a mote uniform vec3 cullShape; // outside the model is not in the air uniform vec2 cullRect; // the box's half-extents in x and z 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 uniform vec3 origin; // the map's corner: zero for the one being // stood on, the connection offset for a // neighbour's swarm (one mesh, drawn per map) 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 + origin + 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 inside; if (cullShape.z < 1.5) { inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { inside = cullShape.x - length(cd); } else { inside = cullShape.x - length(cd.xz); } vGlow *= clamp(inside * 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 -- The map's three swarms in one cache entry: the atmosphere's pollen and -- fireflies, which exist only for a map with an entry, and the grass -- fireflies, which exist for any outdoor map with tall grass on it. Each -- is nil on its own -- a route builds one mesh, Viridian Forest builds -- three, and a cave builds none. local function meshesFor(map) local hit = meshCache[map.id] if hit then return hit end local L = layoutFor(map) local M = { motes = L and buildPartMesh(L.motes) or nil, flies = L and buildPartMesh(L.flies) or nil, grass = buildPartMesh(grassFliesFor(map) or {}), } 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 } local HOME = { 0, 0, 0 } -- the origin of the map being stood on -- 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. -- -- `f` is nil for every map without an atmosphere entry, and the pass does -- NOT stop there any more: the grass fireflies belong to the grass, not to -- an authored line, so the fog and the beams sit that map out and the -- particle block below still runs. Every read of `f` past here is guarded. function ForestAtmos.draw(map, neighbors) local rung = ForestAtmos.setting:get() if rung == "off" then return end if not (map and map.id) then return end local f = ForestAtmos.frame(map) local Voxel3D = V.require("Voxel3D") local ShadowMap = V.require("ShadowMap") -- A refusal here takes the BEAMS, not the frame: this one used to -- `return` out of the function, which took the particles with it. They -- fall through now, so a machine that cannot hand back a readable depth -- buffer still gets fireflies over its grass. local beams = f and f.rayAlpha > 0.01 or false if beams and not Voxel3D.depthReadable() then say("depth", "no readable depth this frame -- beams off, fog stays") beams = false end if beams then -- 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, "cullRect", cullRect()) 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 M = meshesFor(map) -- the night shift, for the grass swarm: the atmosphere's own answer -- where there is one, and the bare ramp where there is not local flyLevel = f and f.fireflyLevel or ForestAtmos.fireflyLevel() local psh = partShader() local axisR, axisU = billboardAxes(Voxel3D) if (M.motes or M.flies or M.grass) 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, "cullRect", cullRect()) pcall(psh.send, psh, "axisR", axisR) pcall(psh.send, psh, "axisU", axisU) pcall(psh.send, psh, "time", ForestAtmos.time) pcall(psh.send, psh, "origin", HOME) if M.motes and f 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 -- every swarm of fireflies shares every uniform but the mesh and -- the map corner it stands on: the grass ones ARE the forest's, -- moved onto the grass. -- -- The NEIGHBOURS matter here in a way they never did for the fog -- or the beams. A connected route is drawn in full -- its ground, -- its trees, its grass and the firefly cards standing in it -- so -- a swarm that stopped at the seam would draw a line across the -- map where the lights ran out. Each neighbour's own deal is a -- mesh already cached against its id; it costs a uniform and a -- draw call to put it where the terrain under it is. if flyLevel > 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", flyLevel * 0.85) if M.flies then pcall(love.graphics.draw, M.flies) end if M.grass then pcall(love.graphics.draw, M.grass) end for _, nb in ipairs(neighbors or {}) do local NM = nb.map and nb.map.id and meshesFor(nb.map) if NM and NM.grass then pcall(psh.send, psh, "origin", { nb.ox or 0, 0, nb.oy or 0 }) pcall(love.graphics.draw, NM.grass) end end end Voxel3D.endEffect() end Voxel3D.blend(nil) end end end return ForestAtmos