DramaticShapeVoxelMod archival mirror · read-only
lib/TiltShift.lua

TiltShift.lua

lib/TiltShift.lua6.8 KB100644a94081b1db
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
-- Voxel world mode: tilt-shift post-process (the miniature-diorama look).
--
-- A depth-of-field fake that reads perfectly on a tilted voxel scene: a
-- horizontal band through the view centre (where the camera focuses -- the
-- player) stays sharp, and the frame blurs progressively toward the top
-- and bottom edges, with a slight saturation lift to sell the model-photo
-- feel. It runs on the finished voxel canvas as two separable gaussian
-- passes, so it costs two fullscreen draws and touches nothing in the 3D
-- pass itself.
--
-- This is a render pipeline in its own right -- a worldPresent pass, which
-- is the stage that post-processes the finished world BEFORE the UI
-- composites over it. That placement is the whole point: a miniature-photo
-- blur belongs on the diorama, not on the dialog box in front of it.
--
-- The engine owns the level: the T-SHIFT options row, its hotkey, the
-- OFF -> 1 -> 2 -> 3 -> OFF ladder and persistence in
-- save.options.pipelines all come from the render_pipelines record in
-- main.lua, and the current level arrives through update(). Each step
-- narrows the sharp band, deepens the blur and pushes the saturation a
-- little further.
--
-- Because worldPresent only runs when some pipeline rendered the world,
-- this draws over the voxel scene and nothing else. With it off (or on any
-- failure -- headless, no shader support) apply() hands the canvas back
-- untouched, so every other path is byte-for-byte what it always was.

local V = ...
local TiltShift = {}

TiltShift.level = 0
TiltShift.LABELS = { "OFF", "1", "2", "3" }

-- The strength ladder. `spacing` is the gap between the gaussian's taps
-- at full blur, as a fraction of the canvas height (so the miniature
-- reads the same in a window and fullscreen; the blur's reach is 4x the
-- spacing per pass, and the two passes compound). `band` is the
-- half-height of the fully sharp zone and `range` the ramp to full blur,
-- both in canvas-uv units. The focus line sits at the vertical centre
-- because that is where the voxel camera parks the player.
TiltShift.FOCUS_Y = 0.5
TiltShift.PRESETS = {
  [1] = { spacing = 0.0016, band = 0.14, range = 0.42, saturation = 1.10 },
  [2] = { spacing = 0.0028, band = 0.10, range = 0.36, saturation = 1.18 },
  [3] = { spacing = 0.0042, band = 0.07, range = 0.30, saturation = 1.28 },
}

local SHADER = [[
  uniform vec2 dir;        // one texel step along the axis being blurred
  uniform float focusY;
  uniform float band;
  uniform float range;
  uniform float spacing;   // gap between taps at full blur, in texels
  uniform float boost;     // 0 = plain blur pass, 1 = final pass (color pop)
  uniform float saturation;
  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
    float d = abs(tc.y - focusY) - band;
    float s = clamp(d / range, 0.0, 1.0);
    s = s * s;             // ease in, so the band edge has no visible seam
    // 9 taps a small step apart: dense enough that even the strongest
    // preset blurs smoothly instead of ghosting into streaks
    vec2 o = dir * (s * spacing);
    vec4 sum = Texel(tex, tc) * 0.2270270270;
    sum += (Texel(tex, tc + o) + Texel(tex, tc - o)) * 0.1945945946;
    sum += (Texel(tex, tc + 2.0 * o) + Texel(tex, tc - 2.0 * o)) * 0.1216216216;
    sum += (Texel(tex, tc + 3.0 * o) + Texel(tex, tc - 3.0 * o)) * 0.0540540541;
    sum += (Texel(tex, tc + 4.0 * o) + Texel(tex, tc - 4.0 * o)) * 0.0162162162;
    if (boost > 0.5) {
      float luma = dot(sum.rgb, vec3(0.299, 0.587, 0.114));
      sum.rgb = mix(vec3(luma), sum.rgb, saturation);
    }
    return sum * color;
  }
]]

local shader = nil            -- nil = untried, false = unavailable
local ping, pong, cw, ch = nil, nil, 0, 0

local function getShader()
  if shader == nil then
    local ok, sh = pcall(function() return love.graphics.newShader(SHADER) end)
    shader = (ok and sh) or false
  end
  return shader or nil
end

local function getCanvases(w, h)
  if not ping or cw ~= w or ch ~= h then
    local PixelCanvas = V.require("PixelCanvas")
    local ok, a = PixelCanvas.new(w, h)
    if not ok then return nil end
    local okB, b = PixelCanvas.new(w, h)
    if not okB then return nil end
    -- the gaussian's fractional tap offsets need linear filtering
    a:setFilter("linear", "linear")
    b:setFilter("linear", "linear")
    ping, pong, cw, ch = a, b, w, h
  end
  return ping, pong
end

function TiltShift.setLevel(level)
  level = math.floor(tonumber(level) or 0)
  if level < 0 then level = 0 end
  if level > 3 then level = 3 end
  TiltShift.level = level
end

-- The pipeline's per-frame tick: the engine hands over the level the
-- player has the T-SHIFT row set to.  No tween -- unlike the camera angle,
-- a blur strength has nothing to ease through.
function TiltShift.update(_, level)
  TiltShift.setLevel(level)
end

function TiltShift.levelLabel(level)
  return TiltShift.LABELS[(level or TiltShift.level) + 1] or "OFF"
end

function TiltShift.active()
  return TiltShift.level > 0
end

function TiltShift.reset()
  TiltShift.level = 0
end

-- Run the effect over `canvas` and return the processed canvas. Returns
-- the input unchanged when the effect is off or cannot run (headless, no
-- shader support), so the caller composites exactly what it was handed.
function TiltShift.apply(canvas)
  local preset = TiltShift.PRESETS[TiltShift.level]
  if not (preset and canvas) then return canvas end
  local sh = getShader()
  if not sh then return canvas end
  local w, h = canvas:getDimensions()
  local a, b = getCanvases(w, h)
  if not a then return canvas end

  local spacing = math.max(0.75, math.min(3, h * preset.spacing))
  local prevBlend, prevAlpha = love.graphics.getBlendMode()

  -- the voxel canvas filters nearest for its 1:1 blit; the blur taps need
  -- linear, restored below so the composite path sees what it expects
  canvas:setFilter("linear", "linear")
  love.graphics.setShader(sh)
  love.graphics.setColor(1, 1, 1, 1)
  -- replace, not alpha-blend: these are image-processing copies
  love.graphics.setBlendMode("replace", "premultiplied")
  pcall(sh.send, sh, "focusY", TiltShift.FOCUS_Y)
  pcall(sh.send, sh, "band", preset.band)
  pcall(sh.send, sh, "range", preset.range)
  pcall(sh.send, sh, "spacing", spacing)
  pcall(sh.send, sh, "saturation", preset.saturation)

  local ok = pcall(function()
    love.graphics.setCanvas(a)
    pcall(sh.send, sh, "dir", { 1 / w, 0 })
    pcall(sh.send, sh, "boost", 0)
    love.graphics.draw(canvas)
    love.graphics.setCanvas(b)
    pcall(sh.send, sh, "dir", { 0, 1 / h })
    pcall(sh.send, sh, "boost", 1)
    love.graphics.draw(a)
  end)

  love.graphics.setCanvas()
  love.graphics.setShader()
  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
  canvas:setFilter("nearest", "nearest")
  return ok and b or canvas
end

-- Drop the GPU objects (window resize, hot reload).
function TiltShift.invalidate()
  ping, pong, cw, ch = nil, nil, 0, 0
end

return TiltShift