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