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

StadiumFx.lua

lib/StadiumFx.lua15.2 KB1006441f60de46cc
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 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
-- STADIUM battles: the generated fire and gas stand-ins.
--
-- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement
-- build.py sizes them against.
--
-- IMPORTANT: nothing here is extracted game data. The real tail flame, mane
-- fire and gas are drawn by procedural callbacks that live in a different
-- fragment -- geo command 0x08 records an attachment point and
-- func_80014A60 calls node->unk_10, and the model file supplies only two
-- empty display lists plus zeroed scratch buffers for it to fill. Those
-- callbacks have not been ported, so the models genuinely contain no flame
-- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth
-- and skin.
--
-- What follows is an ORIGINAL, procedurally generated replacement -- looping
-- flipbook noise on a pair of crossed quads, anchored to the exact bone the
-- callback hangs off so it sits where the real effect would and follows the
-- animation. Seeds derive from the species number, so a given Pokemon always
-- generates the same flame.
--
-- Which species get one is the game's own grouping: every species sharing a
-- callback shares an effect.
--
--   0x810000D8  Charmander, Charmeleon, Charizard, Magmar, Moltres   tail flame
--   0x81000108  Ponyta, Rapidash, Moltres's wings                    small flame
--   0x810000E0  Gastly (only)                                        gas cloud

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local StadiumFx = {}

local floor = math.floor
local sqrt = math.sqrt
local sin, cos = math.sin, math.cos
local char = string.char
local concat = table.concat
local pi = math.pi

local FIRE_TAIL  = 0x810000D8
local FIRE_SMALL = 0x81000108
local AURA       = 0x810000E0

-- Desired size as a fraction of the model's world-space height: length, width.
StadiumFx.SIZES = {
  fire_tail  = { 0.40, 0.22 },
  fire_small = { 0.075, 0.042 },
  gas        = { 1.05, 1.05 },
}

-- ------- 32-bit exclusive-or, in arithmetic
--
-- The generator below is an xorshift, so it needs a real 32-bit xor and a
-- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which
-- works in SIGNED 32-bit and would need converting back on every step -- see
-- the same note in StadiumFragment.

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

-- ------- deterministic noise

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

-- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and
-- therefore the texture -- is reproducible.
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 smooth(t)
  return t * t * (3 - 2 * t)
end

-- Bilinear value noise on a torus, so the field tiles in both axes.
local function sample(grid, x, y)
  local h = #grid
  local w = #grid[1]
  local fx0, fy0 = floor(x), floor(y)
  local x0, y0 = fx0 % w, fy0 % h
  local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
  local fx, fy = smooth(x - fx0), smooth(y - fy0)
  local r0, r1 = grid[y0 + 1], grid[y1 + 1]
  local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx
  local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx
  return a + (b - a) * fy
end

-- Sum octaves of tileable noise.
local function fbm(grids, x, y, scale)
  local total, amp, norm = 0.0, 1.0, 0.0
  for i = 1, #grids do
    local f = scale * 2 ^ (i - 1)
    total = total + sample(grids[i], x * f, y * f) * amp
    norm = norm + amp
    amp = amp * 0.5
  end
  return total / norm
end

-- Intensity -> RGBA, through a piecewise ramp.
local function ramp(stops, t)
  if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end
  for i = 1, #stops - 1 do
    local a, b = stops[i], stops[i + 1]
    if t <= b[1] then
      local k = 0.0
      if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end
      return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k),
             floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k)
    end
  end
  local last = stops[#stops]
  return last[2], last[3], last[4], last[5]
end

local FIRE_RAMP = {
  { 0.00, 0, 0, 0, 0 },
  { 0.30, 120, 24, 8, 90 },
  { 0.52, 226, 78, 16, 205 },
  { 0.74, 252, 176, 44, 245 },
  { 1.00, 255, 246, 214, 255 },
}

local GAS_RAMP = {
  { 0.00, 0, 0, 0, 0 },
  { 0.34, 52, 26, 78, 70 },
  { 0.60, 96, 52, 140, 140 },
  { 0.82, 148, 96, 196, 190 },
  { 1.00, 208, 176, 236, 215 },
}

local TRANSPARENT = char(0, 0, 0, 0)

-- An upward-advected noise plume. Scrolling by an exact multiple of the
-- lattice over the frame count is what makes the loop seamless.
local function fireFrames(seed, w, h, frames, wisp)
  wisp = wisp or 1.0
  local rng = newRng(seed)
  local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
                  lattice(rng, 32, 32) }
  local out = {}
  for f = 0, frames - 1 do
    local t = f / frames
    local buf = {}
    for i = 1, w * h do buf[i] = TRANSPARENT end
    for y = 0, h - 1 do
      local v = y / (h - 1)                 -- 0 at the base, 1 at the tip
      -- plume envelope: wide and hot at the base, pinched at the tip
      local taper = 1.0 - v
      if taper < 0.0 then taper = 0.0 end
      taper = taper ^ 0.42
      for x = 0, w - 1 do
        local u = (x / (w - 1)) * 2 - 1     -- -1 .. 1 across the flame
        local denom = taper * 0.95
        if denom < 0.10 then denom = 0.10 end
        local radial = 1.0 - (u < 0 and -u or u) / denom
        if radial > 0 then
          radial = radial ^ 0.7
          local n = fbm(grids, x / w, (y / h) - t, 3.0)
          local lick = 0.55 + 0.75 * (n - 0.5) * wisp
          local inten = radial * (0.55 + 0.8 * taper) * lick
          inten = inten - 0.16 * v           -- cool towards the tip
          if inten > 0.02 then
            local r, g, b, a = ramp(FIRE_RAMP, inten)
            -- +Y in texture space is up
            buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a)
          end
        end
      end
    end
    out[f + 1] = concat(buf)
  end
  return w, h, out
end

-- Slow swirling haze that fades out towards the rim.
local function gasFrames(seed, w, h, frames)
  local rng = newRng(seed)
  local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
                  lattice(rng, 32, 32) }
  local out = {}
  for f = 0, frames - 1 do
    local t = f / frames
    local buf = {}
    for i = 1, w * h do buf[i] = TRANSPARENT end
    local ang = t * 2 * pi
    local ca, sa = cos(ang), sin(ang)
    for y = 0, h - 1 do
      for x = 0, w - 1 do
        local dx = (x / (w - 1)) * 2 - 1
        local dy = (y / (h - 1)) * 2 - 1
        local d = sqrt(dx * dx + dy * dy)
        if d < 1.0 then
          local falloff = (1.0 - d) ^ 0.85
          -- rotate the sample point so the haze churns without popping
          local sx = dx * ca - dy * sa
          local sy = dx * sa + dy * ca
          local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
          local inten = falloff * (0.78 + 1.30 * (n - 0.44))
          if inten > 0.03 then
            local r, g, b, a = ramp(GAS_RAMP, inten)
            buf[y * w + x + 1] = char(r, g, b, a)
          end
        end
      end
    end
    out[f + 1] = concat(buf)
  end
  return w, h, out
end

-- Two quads at right angles, so the effect reads from any angle. `axis` picks
-- which bone-local direction the quad grows along: bone-local +X runs down the
-- limb, so a flame laid out along X comes out lying sideways, and 'y' is that
-- same quad turned a quarter left about Z, which stands it up. `centred`
-- straddles the origin instead of growing from it.
local function crossedQuads(bone, length, width, axis, centred)
  local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {}
  local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } }
  local nv, ni = 0, 0
  for q = 0, 1 do
    local base = nv
    for k = 1, 4 do
      local s, t = ST[k][1], ST[k][2]
      local a = (s - 0.5) * width
      local b = centred and (t - 0.5) * length or t * length
      local px, py, pz
      if axis == "x" then
        if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end
      else                                       -- (x, y) -> (-y, x)
        if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end
      end
      pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz
      uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t
      if q == 0 then
        nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0
      else
        nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0
      end
      skin[nv + 1] = bone
      nv = nv + 1
    end
    idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2
    idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3
    ni = ni + 6
  end
  return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv,
           idx = idx, nidx = ni }
end

-- ------- the bind pose these are sized against
--
-- build.py's bind_extent, kept in its own 4x4 column-major convention rather
-- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the
-- same skeleton -- but the effect sizes come out of THIS one's per-bone scale
-- measurement, and rewriting it into the other convention is exactly the kind
-- of change that moves a byte without anyone noticing.

local function trs(t, r, s)
  local function S(v) return sin(v / 32768 * pi) end
  local function C(v) return cos(v / 32768 * pi) end
  local sx, cx = S(r[1]), C(r[1])
  local sy, cy = S(r[2]), C(r[2])
  local sz, cz = S(r[3]), C(r[3])
  return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0,
           (sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2],
           sx * cy * s[2], 0,
           (cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3],
           cx * cy * s[3], 0,
           t[1], t[2], t[3], 1 }
end

local function mul(a, b)
  local r = {}
  for c = 0, 3 do
    for i = 1, 4 do
      r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2]
                     + a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4]
    end
  end
  return r
end

-- (height of the bind pose, per-bone local scale). Height rather than the
-- largest dimension: sizing off the max would scale Moltres's flames to its
-- wingspan.
function StadiumFx.bindExtent(data)
  local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale)
  local acc, uns, mats = {}, {}, {}
  for i = 1, #data.bones do
    local b = data.bones[i]
    local p = b.parent
    local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
    local pu = (p >= 0) and uns[p + 1] or root
    local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] },
                          b.r, { 1, 1, 1 }))
    local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] }
    local m = {}
    for k = 1, 16 do m[k] = u[k] end
    for k = 1, 4 do
      m[k] = m[k] * a[1]
      m[4 + k] = m[4 + k] * a[2]
      m[8 + k] = m[8 + k] * a[3]
    end
    acc[i], uns[i], mats[i] = a, u, m
  end

  local lo = { 1e9, 1e9, 1e9 }
  local hi = { -1e9, -1e9, -1e9 }
  for _, prim in ipairs(data.prims) do
    local pos, skin = prim.pos, prim.skin
    for i = 1, prim.nverts do
      local m = mats[skin[i] + 1]
      if m then
        local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
        local wx = m[1] * x + m[5] * y + m[9] * z + m[13]
        local wy = m[2] * x + m[6] * y + m[10] * z + m[14]
        local wz = m[3] * x + m[7] * y + m[11] * z + m[15]
        if wx < lo[1] then lo[1] = wx end
        if wy < lo[2] then lo[2] = wy end
        if wz < lo[3] then lo[3] = wz end
        if wx > hi[1] then hi[1] = wx end
        if wy > hi[2] then hi[2] = wy end
        if wz > hi[3] then hi[3] = wz end
      end
    end
  end
  local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0

  -- how much each bone scales its own local space, so an effect can divide it
  -- back out and come out the size it asked for wherever it hangs
  local scales = {}
  for i = 1, #mats do
    local m = mats[i]
    scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3])
  end
  return extent, scales
end

-- ------- what a species gets

-- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list.
function StadiumFx.buildFor(species, fx, extent, boneScale)
  local out = {}
  for _, node in ipairs(fx) do
    local cb, bone = node.callback, node.bone
    if bone >= 0 and bone < #boneScale then
      local k = boneScale[bone + 1]
      if k == 0 then k = 1.0 end
      if cb == FIRE_TAIL then
        local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2]
        local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8)
        out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
                          geo = crossedQuads(bone, extent * fl / k,
                                             extent * fw / k, "y", false) }
      elseif cb == FIRE_SMALL then
        local fl, fw = StadiumFx.SIZES.fire_small[1],
                       StadiumFx.SIZES.fire_small[2]
        local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25)
        out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
                          geo = crossedQuads(bone, extent * fl / k,
                                             extent * fw / k, "y", false) }
      elseif cb == AURA and species == 92 then        -- Gastly only
        local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2]
        local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10)
        out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr,
                          geo = crossedQuads(bone, extent * fl / k,
                                             extent * fw / k, "y", true) }
      end
    end
  end
  return out
end

-- Append the generated prims and their flipbook textures to a model, exactly
-- as build.py's attach_effects does. Returns how many were made.
function StadiumFx.attach(data, species)
  if not (data.fx and #data.fx > 0) then return 0 end
  local extent, boneScale = StadiumFx.bindExtent(data)
  local made = StadiumFx.buildFor(species, data.fx, extent, boneScale)
  for _, e in ipairs(made) do
    local first = #data.textures                      -- 0-based, as the file
    for i = 1, #e.frames do
      data.textures[first + i] = { index = -1, w = e.w, h = e.h,
                                   generated = true, rgba = e.frames[i] }
    end
    local g = e.geo
    local fxFrames = {}
    for i = 1, #e.frames do fxFrames[i] = first + i - 1 end
    data.prims[#data.prims + 1] = {
      tex = first, cull = 0, texAnim = -1, texMap = nil,
      generated = true, effect = e.kind,
      blend = (e.kind == "fire") and "add" or "alpha",
      fxFrames = fxFrames,
      pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts,
      idx = g.idx, nidx = g.nidx,
    }
  end
  return #made
end

return StadiumFx