455 lines
18 KiB
JavaScript
455 lines
18 KiB
JavaScript
"use strict";
|
||
|
||
// Effect scripts (resource/effect/*.txt) and their particle systems, following io3DEngine
|
||
// (ioEffectFactory::ParseScript, ioParticleSystem::Update, ioParticleEmitter, the emit point
|
||
// generators and affectors). Used by the map editor to play the map's effects.
|
||
//
|
||
// Units: times in ms, speeds / forces per second, angles in degrees (TexRotation speed: rad/s).
|
||
// Everything is simulated in game space (map effects have no rotation or scale).
|
||
|
||
const Effects = (() => {
|
||
// ---------- parser ----------
|
||
/** → { info, particles, models } with editable attributes and nested emitters/affectors. */
|
||
function parse(text) {
|
||
const lines = text.split(/\r\n|\r|\n/).map((l) => l.replace(/\s+$/, "").replace(/^\s+/, ""));
|
||
let i = 0;
|
||
const out = { info: {}, particles: [], models: [], light: false };
|
||
const skipToBrace = () => {
|
||
while (i < lines.length && !lines[i].startsWith("{")) i++;
|
||
i++;
|
||
};
|
||
const readBlock = (onNested) => {
|
||
const attrs = {};
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
if (!l || l.startsWith("//")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (l.startsWith("}")) {
|
||
i++;
|
||
break;
|
||
}
|
||
const sp = l.search(/\s/);
|
||
const key = (sp < 0 ? l : l.slice(0, sp)).toLowerCase();
|
||
const rest = sp < 0 ? "" : l.slice(sp + 1).trim();
|
||
if (onNested && (key === "emitter" || key === "affector")) {
|
||
i++;
|
||
skipToBrace();
|
||
onNested(key, rest, readBlock(null));
|
||
continue;
|
||
}
|
||
attrs[key] = rest;
|
||
i++;
|
||
}
|
||
return attrs;
|
||
};
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
if (!l || l.startsWith("//") || l.startsWith("{") || l.startsWith("}")) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const tag = l.split(/\s/)[0].toLowerCase();
|
||
i++;
|
||
skipToBrace();
|
||
if (tag === "particle" || tag === "model") {
|
||
const ps = { emitter: null, affectors: [] };
|
||
ps.attrs = readBlock((kind, type, attrs) => {
|
||
if (kind === "emitter") ps.emitter = { type: type.toLowerCase(), attrs };
|
||
else ps.affectors.push({ type: type.toLowerCase(), attrs });
|
||
});
|
||
out[tag === "model" ? "models" : "particles"].push(ps);
|
||
} else {
|
||
const attrs = readBlock(null);
|
||
if (tag === "info") out.info = attrs;
|
||
else if (tag === "light") out.light = true;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------- small helpers ----------
|
||
const nums = (s) => (s || "").trim().split(/\s+/).filter(Boolean).map(Number);
|
||
const pair = (s, d) => {
|
||
const n = nums(s);
|
||
return n.length >= 2 ? n : n.length === 1 ? [n[0], n[0]] : d;
|
||
};
|
||
const vec = (s, d) => {
|
||
const n = nums(s);
|
||
return n.length >= 3 ? n.slice(0, 3) : d;
|
||
};
|
||
const rnd = (a, b) => a + Math.random() * (b - a);
|
||
const norm = (v) => {
|
||
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
||
return [v[0] / l, v[1] / l, v[2] / l];
|
||
};
|
||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||
const quatMul = (a, b) => [
|
||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||
];
|
||
const quatRotate = (q, v) => {
|
||
const u = q.slice(0, 3), w = q[3];
|
||
const t = cross(u, v).map((n) => n * 2);
|
||
const c = cross(u, t);
|
||
return [v[0] + w * t[0] + c[0], v[1] + w * t[1] + c[1], v[2] + w * t[2] + c[2]];
|
||
};
|
||
/** Keyed curve "t v1 v2 …" (k values per key) → sampler(t01) → [v…] (linear, clamped) */
|
||
function curve(s, k, dflt) {
|
||
const n = nums(s);
|
||
const keys = [];
|
||
for (let j = 0; j + k < n.length + 1; j += k + 1) {
|
||
const vals = n.slice(j + 1, j + 1 + k);
|
||
while (vals.length < k) vals.push(0);
|
||
keys.push([n[j], vals]);
|
||
}
|
||
if (!keys.length) keys.push([0, dflt]);
|
||
return (t) => {
|
||
let i = 0;
|
||
while (i < keys.length && keys[i][0] <= t) i++;
|
||
if (i === 0) return keys[0][1];
|
||
if (i === keys.length) return keys[keys.length - 1][1];
|
||
const [t0, a] = keys[i - 1], [t1, b] = keys[i];
|
||
const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0;
|
||
return a.map((v, j) => v + (b[j] - v) * f);
|
||
};
|
||
}
|
||
|
||
// ---------- one particle system ----------
|
||
function makeSystem(ps, model = false) {
|
||
const a = ps.attrs;
|
||
const e = ps.emitter?.attrs || {};
|
||
const type = ps.emitter?.type || "point";
|
||
const sys = {
|
||
model,
|
||
mesh: model ? (a.mesh_file || "").trim() : "",
|
||
quota: Number(a.quota) || 0,
|
||
width: pair(a.default_width, [100, 100]),
|
||
height: pair(a.default_height, [100, 100]),
|
||
render: (a.render_type || "add").toLowerCase(),
|
||
depth: a.depth_check !== "false",
|
||
texture: (a.texture || "").split(/[\\/]/).pop(),
|
||
linkBone: (a.link_biped_name || "").trim(),
|
||
linkOffset: vec(a.pos_offset, [0, 0, 0]),
|
||
followParent: (a.follow_parent || "none").toLowerCase(),
|
||
linkRotation: nums(a.rot_offset).length === 4 ? nums(a.rot_offset) : [0, 0, 0, 1],
|
||
billboard: (a.billboard_type || "point").toLowerCase(),
|
||
endLimit: (a.end_condition || "").toLowerCase() === "time_limit",
|
||
limit: Number(a.limit_time) || 0,
|
||
commonDir: norm(vec(a.common_dir, [0, 1, 0])),
|
||
commonFixed: a.common_dir_none_billboard === "true",
|
||
interval: Number(a.iteration_interval) || 0,
|
||
burst: Number(a.start_create_particles) || 0,
|
||
// In the standalone editor the owner/bone offset is not applied to an
|
||
// unattached model. The emitter position is its visible local origin.
|
||
offset: model ? [0, 0, 0] : vec(a.pos_offset, [0, 0, 0]),
|
||
rotOffset: model ? nums(a.rot_offset) : [],
|
||
mod2x: a.modulate2x === "true",
|
||
// emitter
|
||
shape: type,
|
||
emitPos: vec(e.emit_pos, [0, 0, 0]),
|
||
emitDir: vec(e.emit_dir, [0, 1, 0]),
|
||
angle: ((Number(e.emit_angle) || 0) * Math.PI) / 180,
|
||
rate: e.emit_rate !== undefined ? Number(e.emit_rate) : 10,
|
||
speed: pair(e.emit_speed, [1, 1]),
|
||
life: pair(e.emit_live_time, [5000, 5000]),
|
||
wait: Number(e.emit_wait_time) || 0,
|
||
texRot: pair(e.emit_start_tex_rotation, [0, 0]).map((d) => (d * Math.PI) / 180),
|
||
startRotAxis: norm(vec(e.start_rot_axis, [0, 1, 0])),
|
||
startRotAngle: pair(e.start_rot_angle, [0, 0]),
|
||
area: vec(e.area_size, [100, 100, 100]),
|
||
inner: nums(e.inner_size),
|
||
color: curve(e.color_rate, 3, [255, 255, 255]),
|
||
alpha: curve(e.alpha_rate, 1, [1]),
|
||
affectors: ps.affectors.map((f) => ({ type: f.type, a: f.attrs, wait: Number(f.attrs.wait_time) || 0 })),
|
||
};
|
||
for (const f of sys.affectors) {
|
||
if (f.type === "linear") {
|
||
f.force = vec(f.a.force_vector, [0, -100, 0]);
|
||
f.rate = curve(f.a.force_rate, 1, [1]);
|
||
} else if (f.type === "scale") f.scale = curve(f.a.scale_rate, model ? 3 : 2, model ? [1, 1, 1] : [1, 1]);
|
||
else if (f.type === "texrotation") {
|
||
f.range = pair(f.a.rot_range, [0, 0]).map((d) => (d * Math.PI) / 180);
|
||
f.speed = pair(f.a.rot_speed, [0, 0]); // rad/s as the client uses it
|
||
} else if (f.type === "emit") {
|
||
f.power = Number(f.a.power) || 0;
|
||
f.rate = curve(f.a.force_rate, 1, [1]);
|
||
}
|
||
}
|
||
// emitter local axes (GenAreaAxis)
|
||
const d = norm(sys.emitDir);
|
||
let up = Math.abs(dot(d, [1, 0, 0])) > 0.99 ? [0, -1, 0] : [1, 0, 0];
|
||
const side = cross(up, d);
|
||
up = cross(d, side);
|
||
sys.axes = { d, up, side };
|
||
sys.parts = [];
|
||
sys.time = 0;
|
||
sys.acc = 0;
|
||
sys.started = false;
|
||
sys.stopped = false; // time_limit reached (no more emission)
|
||
sys.ended = false;
|
||
return sys;
|
||
}
|
||
|
||
function spawnPoint(s) {
|
||
const { d, up, side } = s.axes;
|
||
const [sx, sy, sz] = s.area;
|
||
const RX = up.map((v) => (v * sx) / 2), RY = d.map((v) => (v * sy) / 2), RZ = side.map((v) => (v * sz) / 2);
|
||
const c = s.emitPos;
|
||
const at = (a, b, cc) => [c[0] + a * RX[0] + b * RY[0] + cc * RZ[0], c[1] + a * RX[1] + b * RY[1] + cc * RZ[1], c[2] + a * RX[2] + b * RY[2] + cc * RZ[2]];
|
||
const r = () => Math.random() * 2 - 1;
|
||
switch (s.shape) {
|
||
case "box":
|
||
return at(r(), r(), r());
|
||
case "cylinder": {
|
||
let x = r(), z = r();
|
||
const l = Math.hypot(x, z);
|
||
if (l > 1) (x /= l), (z /= l);
|
||
return at(x, r(), z);
|
||
}
|
||
case "ellipse": {
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const ix = s.inner[0] ?? 0.5, iz = s.inner[1] ?? 0.5;
|
||
return at(ix * Math.sin(ang), Math.random(), iz * Math.cos(ang));
|
||
}
|
||
case "ellipsoid": {
|
||
let x = r(), y = r(), z = r();
|
||
const l = Math.hypot(x, y, z);
|
||
if (l > 1) (x /= l), (y /= l), (z /= l);
|
||
return at(x, y, z);
|
||
}
|
||
case "hollowellipsoid": {
|
||
const al = Math.random() * Math.PI * 2, be = Math.random() * Math.PI;
|
||
const inner = s.inner.map((v) => Math.max(0, Math.min(1, v)));
|
||
const rx = rnd(inner[0] ?? 0.5, 1), ry = rnd(inner[1] ?? 0.5, 1), rz = rnd(inner[2] ?? 0.5, 1);
|
||
return at(rx * Math.cos(al) * Math.sin(be), ry * Math.cos(be), rz * Math.sin(al) * Math.sin(be));
|
||
}
|
||
default:
|
||
return c.slice();
|
||
}
|
||
}
|
||
/** emit_dir turned by a random angle within the cone, around a random azimuth. */
|
||
function coneDir(s) {
|
||
const { d, up, side } = s.axes;
|
||
if (!s.angle) return d.slice();
|
||
const th = Math.random() * s.angle, ph = Math.random() * Math.PI * 2;
|
||
const u = norm(up), v = norm(side);
|
||
const k = Math.sin(th);
|
||
return norm([d[0] * Math.cos(th) + (u[0] * Math.cos(ph) + v[0] * Math.sin(ph)) * k, d[1] * Math.cos(th) + (u[1] * Math.cos(ph) + v[1] * Math.sin(ph)) * k, d[2] * Math.cos(th) + (u[2] * Math.cos(ph) + v[2] * Math.sin(ph)) * k]);
|
||
}
|
||
|
||
function spawn(s, origin, lead) {
|
||
const rotation = s.ownerRotation && s.followParent !== "pos_only" ? s.ownerRotation : null;
|
||
const p = rotation ? quatRotate(rotation, spawnPoint(s)) : spawnPoint(s);
|
||
const dir = rotation ? quatRotate(rotation, coneDir(s)) : coneDir(s);
|
||
const speed = rnd(s.speed[0], s.speed[1]);
|
||
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : s.offset;
|
||
const part = {
|
||
pos: [p[0] + origin[0] + offset[0] + dir[0] * speed * lead, p[1] + origin[1] + offset[1] + dir[1] * speed * lead, p[2] + origin[2] + offset[2] + dir[2] * speed * lead],
|
||
dir,
|
||
speed,
|
||
age: 0,
|
||
life: Math.max(1, rnd(s.life[0], s.life[1])),
|
||
w0: rnd(s.width[0], s.width[1]),
|
||
h0: rnd(s.height[0], s.height[1]),
|
||
rot: rnd(s.texRot[0], s.texRot[1]),
|
||
rotSpeed: 0,
|
||
};
|
||
if (s.model) {
|
||
// Effect scripts use Direct3D's left-handed axis-angle convention.
|
||
const angle = -rnd(s.startRotAngle[0], s.startRotAngle[1]) * Math.PI / 360;
|
||
const axis = s.startRotAxis;
|
||
const q = [axis[0] * Math.sin(angle), axis[1] * Math.sin(angle), axis[2] * Math.sin(angle), Math.cos(angle)];
|
||
part.modelRotation = quatMul(rotation || [0, 0, 0, 1], quatMul(s.rotOffset.length === 4 ? s.rotOffset : [0, 0, 0, 1], q));
|
||
part.modelScale = s.affectors.find((f) => f.type === "scale")?.scale(0) || [1, 1, 1];
|
||
}
|
||
for (const f of s.affectors) {
|
||
if (f.type === "texrotation") {
|
||
part.rot = rnd(f.range[0], f.range[1]);
|
||
part.rotSpeed = rnd(f.speed[0], f.speed[1]);
|
||
}
|
||
}
|
||
part.w = part.w0;
|
||
part.h = part.h0;
|
||
s.parts.push(part);
|
||
}
|
||
|
||
function step(s, dt, origin) {
|
||
const dts = dt / 1000;
|
||
if (!s.started) {
|
||
s.started = true;
|
||
for (let k = 0; k < Math.min(s.burst, s.quota || s.burst); k++) spawn(s, origin, 0);
|
||
}
|
||
s.time += dt;
|
||
// expire + age
|
||
s.parts = s.parts.filter((p) => p.age < p.life);
|
||
for (const p of s.parts) p.age += dt;
|
||
// affectors
|
||
for (const f of s.affectors) {
|
||
if (s.time < f.wait) continue;
|
||
for (const p of s.parts) {
|
||
const t = Math.min(1, p.age / p.life);
|
||
if (f.type === "linear") {
|
||
const r = f.rate(t)[0];
|
||
const v = [p.dir[0] * p.speed + f.force[0] * dts * r, p.dir[1] * p.speed + f.force[1] * dts * r, p.dir[2] * p.speed + f.force[2] * dts * r];
|
||
p.speed = Math.hypot(v[0], v[1], v[2]);
|
||
if (p.speed > 1e-6) p.dir = [v[0] / p.speed, v[1] / p.speed, v[2] / p.speed];
|
||
} else if (f.type === "scale") {
|
||
if (s.model) p.modelScale = f.scale(t);
|
||
else {
|
||
const [sx, sy] = f.scale(t);
|
||
p.w = p.w0 * sx;
|
||
p.h = p.h0 * sy;
|
||
}
|
||
} else if (f.type === "texrotation") p.rot += p.rotSpeed * dts;
|
||
else if (f.type === "emit") {
|
||
const emitPos = s.ownerRotation && s.followParent !== "pos_only" ? quatRotate(s.ownerRotation, s.emitPos) : s.emitPos;
|
||
const offset = s.heroAttached ? (s.ownerOffset || [0, 0, 0]) : [0, 0, 0];
|
||
const c = [origin[0] + offset[0] + emitPos[0], origin[1] + offset[1] + emitPos[1], origin[2] + offset[2] + emitPos[2]];
|
||
const d = norm([p.pos[0] - c[0], p.pos[1] - c[1], p.pos[2] - c[2]]);
|
||
const k = f.power * dts * f.rate(t)[0];
|
||
p.pos[0] += d[0] * k;
|
||
p.pos[1] += d[1] * k;
|
||
p.pos[2] += d[2] * k;
|
||
}
|
||
}
|
||
}
|
||
// integrate
|
||
for (const p of s.parts) {
|
||
p.pos[0] += p.dir[0] * p.speed * dts;
|
||
p.pos[1] += p.dir[1] * p.speed * dts;
|
||
p.pos[2] += p.dir[2] * p.speed * dts;
|
||
}
|
||
// emit
|
||
if (s.endLimit && s.time >= s.limit) s.stopped = true;
|
||
if (!s.stopped && s.time >= s.wait && s.quota > 0) {
|
||
s.acc += (s.rate * dt) / 1000;
|
||
let n = Math.floor(s.acc);
|
||
s.acc -= n;
|
||
n = Math.min(n, s.quota - s.parts.length);
|
||
for (let k = 0; k < n; k++) spawn(s, origin, (k * dts) / Math.max(1, n));
|
||
}
|
||
if ((s.stopped || (!s.endLimit && s.rate < 0.1)) && !s.parts.length && s.time > 0) s.ended = true;
|
||
}
|
||
|
||
// ---------- an effect instance ----------
|
||
function instance(def, origin) {
|
||
return { def, origin, systems: def.particles.map((p) => makeSystem(p)), modelSystems: def.models.map((m) => makeSystem(m, true)), restartAt: 0 };
|
||
}
|
||
function update(inst, dt) {
|
||
let live = false;
|
||
for (const s of [...inst.systems, ...inst.modelSystems]) {
|
||
if (s.ended) continue;
|
||
if (s.heroAttached && s.followParent !== "none") {
|
||
const base = s.ownerOrigin || inst.origin, offset = s.ownerOffset || [0, 0, 0];
|
||
const current = [base[0] + offset[0], base[1] + offset[1], base[2] + offset[2]];
|
||
const rotation = s.followParent === "all" ? (s.ownerRotation || [0, 0, 0, 1]) : [0, 0, 0, 1];
|
||
if (s.previousParent) {
|
||
const inverse = [-s.previousRotation[0], -s.previousRotation[1], -s.previousRotation[2], s.previousRotation[3]];
|
||
for (const p of s.parts) {
|
||
const local = quatRotate(inverse, [p.pos[0] - s.previousParent[0], p.pos[1] - s.previousParent[1], p.pos[2] - s.previousParent[2]]);
|
||
const next = quatRotate(rotation, local);
|
||
p.pos = [current[0] + next[0], current[1] + next[1], current[2] + next[2]];
|
||
p.dir = quatRotate(rotation, quatRotate(inverse, p.dir));
|
||
if (p.modelRotation) p.modelRotation = quatMul(rotation, quatMul(inverse, p.modelRotation));
|
||
}
|
||
}
|
||
s.previousParent = current;
|
||
s.previousRotation = rotation;
|
||
}
|
||
if (s.interval > 0) {
|
||
s.left = (s.left || 0) + dt;
|
||
while (s.left >= s.interval) {
|
||
step(s, s.interval, s.ownerOrigin || inst.origin);
|
||
s.left -= s.interval;
|
||
}
|
||
} else step(s, Math.min(dt, 100), s.ownerOrigin || inst.origin);
|
||
if (!s.ended) live = true;
|
||
}
|
||
// map effects play while their systems live; one-shot ones restart in the preview after a pause
|
||
if (!live && !inst.heroPreview) {
|
||
inst.restartAt += dt;
|
||
if (inst.restartAt > 1200) {
|
||
inst.systems = inst.def.particles.map(makeSystem);
|
||
inst.modelSystems = inst.def.models.map((m) => makeSystem(m, true));
|
||
inst.restartAt = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
const BLEND = {
|
||
add: { op: "add", src: "src_alpha", dst: "one" },
|
||
src_alpha: { op: "add", src: "src_alpha", dst: "inv_src_alpha" },
|
||
screen: { op: "add", src: "inv_dest_color", dst: "one" },
|
||
lighten: { op: "max", src: "one", dst: "one" },
|
||
darken: { op: "min", src: "one", dst: "one" },
|
||
rev_subtract: { op: "rev_subtract", src: "src_alpha", dst: "one" },
|
||
subtract: { op: "subtract", src: "src_alpha", dst: "one" },
|
||
};
|
||
|
||
/**
|
||
* Quads of every live particle (6 vertices × [x y z u v r g b a]) grouped per system.
|
||
* cam: { right, up, dir } in game space.
|
||
*/
|
||
function build(inst, cam) {
|
||
const out = [];
|
||
for (const s of inst.systems) {
|
||
if (!s.parts.length) continue;
|
||
const data = new Float32Array(s.parts.length * 6 * 9);
|
||
let o = 0;
|
||
for (const p of s.parts) {
|
||
const t = Math.min(1, p.age / p.life);
|
||
const c = s.color(t);
|
||
const al = Math.max(0, Math.min(1, s.alpha(t)[0]));
|
||
let X, Y;
|
||
if (s.billboard === "common") {
|
||
Y = s.commonDir;
|
||
X = s.commonFixed ? norm(cross(Math.abs(Y[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0], Y)) : norm(cross(Y, cam.dir));
|
||
} else if (s.billboard === "direction") {
|
||
X = p.dir;
|
||
Y = cross(X, cam.dir);
|
||
} else {
|
||
X = cam.right;
|
||
Y = cam.up;
|
||
}
|
||
const hw = p.w / 2, hh = p.h / 2;
|
||
const cs = Math.cos(p.rot), sn = Math.sin(p.rot);
|
||
const uv = (u, v) => {
|
||
// rotation about (0.5, 0.5)
|
||
const x = u - 0.5, y = v - 0.5;
|
||
return [x * cs - y * sn + 0.5, x * sn + y * cs + 0.5];
|
||
};
|
||
const corner = (sx, sy) => [p.pos[0] + X[0] * hw * sx + Y[0] * hh * sy, p.pos[1] + X[1] * hw * sx + Y[1] * hh * sy, p.pos[2] + X[2] * hw * sx + Y[2] * hh * sy];
|
||
const q = [
|
||
[corner(-1, 1), uv(0, 0)],
|
||
[corner(1, 1), uv(1, 0)],
|
||
[corner(1, -1), uv(1, 1)],
|
||
[corner(-1, -1), uv(0, 1)],
|
||
];
|
||
for (const k of [0, 1, 2, 0, 2, 3]) {
|
||
const [pp, tt] = q[k];
|
||
data[o++] = pp[0];
|
||
data[o++] = pp[1];
|
||
data[o++] = pp[2];
|
||
data[o++] = tt[0];
|
||
data[o++] = tt[1];
|
||
data[o++] = c[0] / 255;
|
||
data[o++] = c[1] / 255;
|
||
data[o++] = c[2] / 255;
|
||
data[o++] = al;
|
||
}
|
||
}
|
||
out.push({ texture: s.texture, blend: BLEND[s.render] || BLEND.add, depth: s.depth, mod2x: s.mod2x, data, count: s.parts.length * 6 });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const count = (inst) => inst.systems.reduce((a, s) => a + s.parts.length, 0);
|
||
return { parse, instance, update, build, count };
|
||
})();
|
||
window.Effects = Effects;
|