mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:30:59 +02:00
170 lines
6.9 KiB
Lua
170 lines
6.9 KiB
Lua
-- The hour's light on the FLAT world.
|
|
--
|
|
-- The clock already reaches everything the 3D pass draws: VoxelScene and
|
|
-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
|
|
-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
|
|
-- mode off and none of that happens -- the tint is a uniform in a shader the
|
|
-- flat tile path never runs -- so the same evening that fell on the diorama
|
|
-- left the 2D world at permanent noon. One clock, two worlds, one of them
|
|
-- ignoring it.
|
|
--
|
|
-- So the flat composite gets the same multiply, painted as one rectangle.
|
|
--
|
|
-- ------- WHERE, which is the only difficult part
|
|
--
|
|
-- Not on the world canvas. In a colorized mode that canvas is grayscale art
|
|
-- and the blit that puts it on screen runs it through the palette shader,
|
|
-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
|
|
-- night blue over it first and every shade lands in the wrong bucket -- the
|
|
-- world would not darken, it would change colour into whatever the palette
|
|
-- said the wrong bucket was.
|
|
--
|
|
-- So it goes on AFTER that pass, on the composited world. And not after the
|
|
-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
|
|
-- the HUD are paper held up in front of the world rather than part of it --
|
|
-- the same reason the tilt-shift blur is a worldPresent and not a present.
|
|
--
|
|
-- Which leaves one instant: between the world blit and the UI blit, inside
|
|
-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
|
|
-- hook for exactly this, only runs when a PIPELINE produced the world, which
|
|
-- in flat mode is the one thing that did not happen. So endFrame is wrapped
|
|
-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
|
|
-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
|
|
-- by identity rather than by counting or guessing.
|
|
--
|
|
-- The shader and scissor that call arrives under belong to the UI blit already
|
|
-- in progress, so both are put aside for the rectangle and handed straight
|
|
-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
|
|
--
|
|
-- ------- WHEN
|
|
--
|
|
-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
|
|
-- load-bearing:
|
|
--
|
|
-- the flat path a pipeline that rendered the world already applied the
|
|
-- tint inside its own shader; painting it again would apply
|
|
-- the hour twice. worldOverride is exactly "a pipeline drew
|
|
-- this frame".
|
|
-- outdoors a room has no sky to take its light from, which is the
|
|
-- same answer DayNight.tint gives on its own and the same
|
|
-- one applyRig gives the sun.
|
|
-- not neutral midday is a multiply by white. Skipped rather than drawn,
|
|
-- so a game with the clock at DAY issues not one extra call.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local DayNight = V.require("DayNight")
|
|
|
|
local DayTint = {}
|
|
|
|
-- Below this the tint is close enough to white that the rectangle would not
|
|
-- change a pixel, and the frame is left exactly as it was.
|
|
DayTint.NEUTRAL = 0.999
|
|
|
|
local function outdoorNow()
|
|
local ok, Game = pcall(require, "src.core.Game")
|
|
if not ok then return false end
|
|
local ow = Game and Game.overworld
|
|
local map = ow and ow.map
|
|
if not map then return false end
|
|
local okMap, Map = pcall(require, "src.world.Map")
|
|
if not okMap then return false end
|
|
local outdoor = map.def and Map.isOutdoor(map.def) or false
|
|
-- a canopy floor takes the hour's colour and nothing else of it, exactly as
|
|
-- it does in the 3D pass (BattleScene, VoxelScene)
|
|
return outdoor or DayNight.isCanopy(map)
|
|
end
|
|
|
|
-- The colour this frame's world should be multiplied by, or nil to leave the
|
|
-- frame alone.
|
|
function DayTint.forFrame(renderer)
|
|
if not renderer then return nil end
|
|
if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
|
|
if not renderer.worldActive then return nil end -- no world on screen at all
|
|
if not outdoorNow() then return nil end
|
|
local tint = DayNight.tint(true)
|
|
if not tint then return nil end
|
|
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
|
if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
|
|
return nil
|
|
end
|
|
return r, g, b
|
|
end
|
|
|
|
-- One rectangle over the window, multiplied into whatever is under it.
|
|
--
|
|
-- The whole window rather than the world's own rect, which is what the
|
|
-- engine's warp fade does from the same place and for the same reason: the
|
|
-- border fill, the letterbox bars and the world are all "the world" as far as
|
|
-- the hour is concerned, and black multiplied by anything is still black.
|
|
-- Every read of the graphics state is optional, because a headless driver
|
|
-- ships some of these and not others -- the same reason TerrainAtlas reads the
|
|
-- engine's seams guarded. What cannot be read cannot be put back either, and a
|
|
-- missing accessor must cost the tint rather than the frame.
|
|
local function saved(name, ...)
|
|
local fn = love.graphics[name]
|
|
if not fn then return nil end
|
|
local ok, a, b, c, d = pcall(fn, ...)
|
|
if not ok then return nil end
|
|
return a, b, c, d
|
|
end
|
|
|
|
function DayTint.paint(r, g, b)
|
|
local gfx = love.graphics
|
|
local shader = saved("getShader")
|
|
local sx, sy, sw, sh = saved("getScissor")
|
|
local blend, alpha = saved("getBlendMode")
|
|
local pr, pg, pb, pa = saved("getColor")
|
|
local w, h = gfx.getDimensions()
|
|
|
|
if gfx.setShader then gfx.setShader() end
|
|
if gfx.setScissor then gfx.setScissor() end
|
|
gfx.setBlendMode("multiply", "premultiplied")
|
|
gfx.setColor(r, g, b, 1)
|
|
gfx.rectangle("fill", 0, 0, w, h)
|
|
|
|
gfx.setBlendMode(blend or "alpha", alpha)
|
|
gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
|
|
if gfx.setScissor then
|
|
if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
|
|
end
|
|
if shader and gfx.setShader then gfx.setShader(shader) end
|
|
end
|
|
|
|
function DayTint.install()
|
|
local Renderer = require("src.render.Renderer")
|
|
if Renderer.dramaticShapeTintHook then return end
|
|
local inner = Renderer.endFrame
|
|
|
|
function Renderer:endFrame(zones, worldZones)
|
|
local r, g, b = DayTint.forFrame(self)
|
|
if not r then return inner(self, zones, worldZones) end
|
|
|
|
local gfx = love.graphics
|
|
local draw = gfx.draw
|
|
local ui = self.canvas
|
|
local painted = false
|
|
gfx.draw = function(tex, ...)
|
|
-- the UI canvas reaching the screen: the world is finished, the paper
|
|
-- in front of it has not started. Restored FIRST so the rectangle's own
|
|
-- drawing cannot re-enter this, and so a UI blit that draws one quad per
|
|
-- SGB zone only triggers it once.
|
|
if not painted and tex == ui then
|
|
painted = true
|
|
gfx.draw = draw
|
|
DayTint.paint(r, g, b)
|
|
end
|
|
return draw(tex, ...)
|
|
end
|
|
|
|
local ok, err = pcall(inner, self, zones, worldZones)
|
|
gfx.draw = draw
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
Renderer.dramaticShapeTintHook = true
|
|
end
|
|
|
|
return DayTint
|