mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 11:10:53 +02:00
226 lines
8.6 KiB
Python
226 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generated stand-in effects.
|
|
|
|
IMPORTANT: nothing in this file is extracted game data. The real tail flame,
|
|
mane fire and gas are drawn by procedural callbacks that live in another
|
|
fragment (geo command 0x08 -> func_80014A60 calls node->unk_10, and the model
|
|
file supplies only two empty display lists plus zeroed scratch buffers). Those
|
|
callbacks have not been ported, so the models genuinely contain no flame mesh
|
|
and no flame texture.
|
|
|
|
What follows is an original, procedurally generated replacement: looping
|
|
flipbook textures plus a pair of crossed quads anchored to the bone the
|
|
callback hangs off. It is meant to make the models look right in the viewer,
|
|
and it is tagged `generated: true` everywhere it appears so it is never
|
|
mistaken for ripped content.
|
|
"""
|
|
import math
|
|
|
|
# geo cmd 0x08 callback ids -> which effect to stand in for. The grouping is the
|
|
# game's own: every species sharing a callback shares an effect.
|
|
FIRE_TAIL = 0x810000D8 # Charmander, Charmeleon, Charizard, Magmar, Moltres
|
|
FIRE_SMALL = 0x81000108 # Ponyta, Rapidash, Moltres wings
|
|
AURA = 0x810000E0 # Gastly, Koffing, Weezing, Vaporeon, Articuno, Moltres
|
|
|
|
|
|
class Rng:
|
|
"""Deterministic PRNG so a given species always generates the same effect."""
|
|
|
|
def __init__(self, seed):
|
|
self.s = seed & 0xFFFFFFFF or 0x9E3779B9
|
|
|
|
def next(self):
|
|
x = self.s
|
|
x ^= (x << 13) & 0xFFFFFFFF
|
|
x ^= x >> 17
|
|
x ^= (x << 5) & 0xFFFFFFFF
|
|
self.s = x & 0xFFFFFFFF
|
|
return self.s
|
|
|
|
def unit(self):
|
|
return self.next() / 0x100000000
|
|
|
|
|
|
def _lattice(rng, w, h):
|
|
return [[rng.unit() for _ in range(w)] for _ in range(h)]
|
|
|
|
|
|
def _smooth(t):
|
|
return t * t * (3 - 2 * t)
|
|
|
|
|
|
def _sample(grid, x, y):
|
|
"""Bilinear value noise on a torus, so the field tiles in both axes."""
|
|
h, w = len(grid), len(grid[0])
|
|
x0, y0 = int(math.floor(x)) % w, int(math.floor(y)) % h
|
|
x1, y1 = (x0 + 1) % w, (y0 + 1) % h
|
|
fx, fy = _smooth(x - math.floor(x)), _smooth(y - math.floor(y))
|
|
a = grid[y0][x0] + (grid[y0][x1] - grid[y0][x0]) * fx
|
|
b = grid[y1][x0] + (grid[y1][x1] - grid[y1][x0]) * fx
|
|
return a + (b - a) * fy
|
|
|
|
|
|
def _fbm(grids, x, y, scale):
|
|
"""Sum octaves of tileable noise."""
|
|
total, amp, norm = 0.0, 1.0, 0.0
|
|
for i, g in enumerate(grids):
|
|
f = scale * (2 ** i)
|
|
total += _sample(g, x * f, y * f) * amp
|
|
norm += amp
|
|
amp *= 0.5
|
|
return total / norm
|
|
|
|
|
|
def _ramp(stops, t):
|
|
t = max(0.0, min(1.0, t))
|
|
for i in range(len(stops) - 1):
|
|
a, b = stops[i], stops[i + 1]
|
|
if t <= b[0]:
|
|
k = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0])
|
|
return tuple(int(a[1 + j] + (b[1 + j] - a[1 + j]) * k) for j in range(4))
|
|
return tuple(stops[-1][1:])
|
|
|
|
|
|
FIRE_RAMP = [ # intensity -> RGBA
|
|
(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),
|
|
]
|
|
|
|
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),
|
|
]
|
|
|
|
|
|
def fire_frames(seed, w=32, h=64, frames=8, wisp=1.0):
|
|
"""Upward-advected noise plume. Scrolling by an exact multiple of the noise
|
|
lattice over the frame count makes the loop seamless."""
|
|
rng = Rng(seed)
|
|
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
|
|
out = []
|
|
for f in range(frames):
|
|
t = f / frames
|
|
buf = bytearray(w * h * 4)
|
|
for y in range(h):
|
|
v = y / (h - 1) # 0 at the base, 1 at the tip
|
|
# plume envelope: wide and hot at the base, pinched at the tip
|
|
taper = max(0.0, 1.0 - v) ** 0.42
|
|
for x in range(w):
|
|
u = (x / (w - 1)) * 2 - 1 # -1 .. 1 across the flame
|
|
radial = (1.0 - min(1.0, abs(u) / max(0.10, taper * 0.95))) ** 0.7
|
|
if radial <= 0:
|
|
continue
|
|
n = _fbm(grids, x / w, (y / h) - t, 3.0)
|
|
lick = 0.55 + 0.75 * (n - 0.5) * wisp
|
|
inten = radial * (0.55 + 0.8 * taper) * lick
|
|
inten -= 0.16 * v # cool towards the tip
|
|
if inten <= 0.02:
|
|
continue
|
|
r, g, b, a = _ramp(FIRE_RAMP, inten)
|
|
i = ((h - 1 - y) * w + x) * 4 # +Y in texture space is up
|
|
buf[i:i+4] = bytes((r, g, b, a))
|
|
out.append(bytes(buf))
|
|
return w, h, out
|
|
|
|
|
|
def gas_frames(seed, w=48, h=48, frames=10):
|
|
"""Slow swirling haze that fades out towards the rim."""
|
|
rng = Rng(seed)
|
|
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
|
|
out = []
|
|
for f in range(frames):
|
|
t = f / frames
|
|
buf = bytearray(w * h * 4)
|
|
ang = t * 2 * math.pi
|
|
for y in range(h):
|
|
for x in range(w):
|
|
dx = (x / (w - 1)) * 2 - 1
|
|
dy = (y / (h - 1)) * 2 - 1
|
|
d = math.hypot(dx, dy)
|
|
if d >= 1.0:
|
|
continue
|
|
falloff = (1.0 - d) ** 0.85
|
|
# rotate the sample point so the haze churns without popping
|
|
sx = dx * math.cos(ang) - dy * math.sin(ang)
|
|
sy = dx * math.sin(ang) + dy * math.cos(ang)
|
|
n = _fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
|
|
inten = falloff * (0.78 + 1.30 * (n - 0.44))
|
|
if inten <= 0.03:
|
|
continue
|
|
r, g, b, a = _ramp(GAS_RAMP, inten)
|
|
i = (y * w + x) * 4
|
|
buf[i:i+4] = bytes((r, g, b, a))
|
|
out.append(bytes(buf))
|
|
return w, h, out
|
|
|
|
|
|
def crossed_quads(bone, length, width, axis='y', centred=False):
|
|
"""Two quads at right angles so the effect reads from any angle -- the
|
|
portable stand-in for a billboard, since glTF cannot billboard.
|
|
|
|
`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;
|
|
'y' is that same quad rotated 90 degrees left about Z, which stands it up.
|
|
`centred` straddles the origin instead of growing from it."""
|
|
pos, uv, nrm, skin, idx = [], [], [], [], []
|
|
for q in range(2):
|
|
base = len(pos) // 3
|
|
for (s, t) in ((0, 0), (1, 0), (1, 1), (0, 1)):
|
|
a = (s - 0.5) * width
|
|
b = (t - 0.5) * length if centred else t * length
|
|
if axis == 'x':
|
|
p = (b, a, 0.0) if q == 0 else (b, 0.0, a)
|
|
else: # (x, y) -> (-y, x)
|
|
p = (-a, b, 0.0) if q == 0 else (0.0, b, a)
|
|
pos += list(p)
|
|
uv += [s, 1.0 - t]
|
|
nrm += [0.0, 0.0, 1.0] if q == 0 else [1.0, 0.0, 0.0]
|
|
skin.append(bone)
|
|
idx += [base, base + 1, base + 2, base, base + 2, base + 3]
|
|
return dict(pos=pos, uv=uv, nrm=nrm, skin=skin, idx=idx)
|
|
|
|
|
|
# desired size as a fraction of the model's world-space extent
|
|
SIZES = {
|
|
'fire_tail': (0.40, 0.22), # length, width
|
|
'fire_small': (0.075, 0.042),
|
|
'gas': (1.05, 1.05),
|
|
}
|
|
|
|
|
|
def build_for(species, fx, extent, bone_scale):
|
|
"""Returns [{kind, bone, geo, w, h, frames}] for one model, or [].
|
|
|
|
`extent` is the model's world-space size and `bone_scale[i]` how much bone i
|
|
already scales its local space; dividing by it keeps every effect the size we
|
|
asked for regardless of where in the skeleton it hangs."""
|
|
out = []
|
|
for node in fx:
|
|
cb, bone = node['callback'], node['bone']
|
|
if bone < 0 or bone >= len(bone_scale):
|
|
continue
|
|
k = bone_scale[bone] or 1.0
|
|
if cb == FIRE_TAIL:
|
|
fl, fw = SIZES['fire_tail']
|
|
w, h, fr = fire_frames(species * 7919 + 1, 32, 64, 8)
|
|
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
|
|
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
|
elif cb == FIRE_SMALL:
|
|
fl, fw = SIZES['fire_small']
|
|
w, h, fr = fire_frames(species * 6271 + bone, 24, 40, 8, wisp=1.25)
|
|
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
|
|
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
|
elif cb == AURA and species == 92: # Gastly only
|
|
fl, fw = SIZES['gas']
|
|
w, h, fr = gas_frames(species * 5237 + 3, 48, 48, 10)
|
|
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y', centred=True)
|
|
out.append(dict(kind='gas', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
|
return out
|