From 442e9d26d56e54f98723b7a946259fa58a32ee62 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Thu, 6 Aug 2026 01:07:38 -0400 Subject: [PATCH] add fog/god rays to viridian forest --- 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(-) delete mode 100644 LICENSE create mode 100644 data/map_atmosphere.lua create mode 100644 lib/ForestAtmos.lua create mode 100644 tests/forest_fog_shots.lua 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