StadiumFx.lua
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