载入中...
搜索中...
未找到
ParticleEmitter.cpp
浏览该文件的文档.
3
10#include "common/Module.h"
11#include "common/ParticleSdf.h"
12#include "graphics/Canvas.h"
13#include "graphics/Graphics.h"
14#include "graphics/Texture.h"
15#include "ik/Skeleton2D.h"
16#include "ik/Skeleton3D.h"
17
18#include <cmath>
19#include <cstdio>
20#include <random>
21#include <string>
22
23namespace eve::particles {
24
25namespace {
26constexpr float kPi = 3.14159265358979323846f;
27constexpr float kEps = 1e-6f;
28
29float randRange(std::mt19937 &rng, float a, float b) {
30 if (a == b) return a;
31 std::uniform_real_distribution<float> dist(a, b);
32 return dist(rng);
33}
34
35int randIndex(std::mt19937 &rng, int n) {
36 if (n <= 1) return 0;
37 std::uniform_int_distribution<int> dist(0, n - 1);
38 return dist(rng);
39}
40
41float hashNoise2(int ix, int iy) {
42 unsigned h = unsigned(ix) * 374761393u + unsigned(iy) * 668265263u;
43 h = (h ^ (h >> 13)) * 1274126177u;
44 return (float((h ^ (h >> 16)) & 0xFFFFFFu) / float(0x1000000u)) * 2.f - 1.f;
45}
46
48float smoothNoise2(float x, float y) {
49 const int ix = int(std::floor(x));
50 const int iy = int(std::floor(y));
51 const float fx = x - float(ix);
52 const float fy = y - float(iy);
53 const float sx = fx * fx * (3.f - 2.f * fx);
54 const float sy = fy * fy * (3.f - 2.f * fy);
55 const float n00 = hashNoise2(ix, iy);
56 const float n10 = hashNoise2(ix + 1, iy);
57 const float n01 = hashNoise2(ix, iy + 1);
58 const float n11 = hashNoise2(ix + 1, iy + 1);
59 const float a = n00 + (n10 - n00) * sx;
60 const float b = n01 + (n11 - n01) * sx;
61 return a + (b - a) * sy;
62}
63
64std::string normalizePlane(const std::string &plane) {
65 if (plane == "xz" || plane == "yz") return plane;
66 return "xy";
67}
68
69const char* gpuFeatureFallbackReason(const ParticleEmitter::Config& cfg, const ParticleEmitter::Draw& draw) {
70 if (draw.canvas != nullptr) return "canvas";
71 if (draw.shader != nullptr) return "custom_shader";
72 if (cfg.sdfField != nullptr) return "sdf";
73 if (cfg.collisionMode != "none" || cfg.collisionBoundsEnabled || cfg.worldCollision)
74 return "collision";
75 if (!cfg.forceFields.empty()) return "force_fields";
76 if (!cfg.subEmitters.empty()) return "sub_emitters";
77 if (cfg.lights.enabled) return "particle_lights";
78 if (!cfg.velocityCurve.empty() || !cfg.sizeCurve.empty() || !cfg.rotationCurve.empty() ||
79 !cfg.colorGradient.empty())
80 return "curves";
81 if (cfg.materialMode == "distortion") return "distortion_material";
82 if (cfg.materialMode != "unlit") return "lit_material";
83 return "";
84}
85
86void projectToPlane(float x, float y, float z, const std::string &plane, float scale, float &ox,
87 float &oy) {
88 if (plane == "xz") {
89 ox = x * scale;
90 oy = z * scale;
91 } else if (plane == "yz") {
92 ox = y * scale;
93 oy = z * scale;
94 } else {
95 ox = x * scale;
96 oy = y * scale;
97 }
98}
99
100void quatRotateVec(float qx, float qy, float qz, float qw, float vx, float vy, float vz, float &ox,
101 float &oy, float &oz) {
102 const float ix = qw * vx + qy * vz - qz * vy;
103 const float iy = qw * vy + qz * vx - qx * vz;
104 const float iz = qw * vz + qx * vy - qy * vx;
105 const float iw = -qx * vx - qy * vy - qz * vz;
106 ox = ix * qw + iw * -qx + iy * -qz - iz * -qy;
107 oy = iy * qw + iw * -qy + iz * -qx - ix * -qz;
108 oz = iz * qw + iw * -qz + ix * -qy - iy * -qx;
109}
110
111void sampleEmissionOffset(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, float &ox,
112 float &oy) {
113 ox = 0.f;
114 oy = 0.f;
115 if (cfg.areaX <= 0.f && cfg.areaY <= 0.f) return;
116 if (cfg.areaType == "ellipse") {
117 const float a = randRange(sim.rng, 0.f, kPi * 2.f);
118 const float r = std::sqrt(randRange(sim.rng, 0.f, 1.f));
119 ox = std::cos(a) * cfg.areaX * r;
120 oy = std::sin(a) * cfg.areaY * r;
121 } else if (cfg.areaType == "rect") {
122 ox = randRange(sim.rng, -cfg.areaX, cfg.areaX);
123 oy = randRange(sim.rng, -cfg.areaY, cfg.areaY);
124 } else if (cfg.areaType == "line") {
125 ox = randRange(sim.rng, -cfg.areaX, cfg.areaX);
126 oy = 0.f;
127 } else if (cfg.areaType == "ring") {
128 const float a = randRange(sim.rng, 0.f, kPi * 2.f);
129 const float rx = cfg.areaX > 0.f ? cfg.areaX : 1.f;
130 const float ry = cfg.areaY > 0.f ? cfg.areaY : rx;
131 ox = std::cos(a) * rx;
132 oy = std::sin(a) * ry;
133 }
134}
135
136void rebuildSkinCandidates(ParticleEmitter::SkinSource &src) {
137 src.candidates.clear();
138 src.candidatesDirty = false;
139 if (!src.skin || src.skin->getVertexCount() <= 0) return;
140
141 const int n = src.skin->getVertexCount();
142 const int influences = src.skin->getInfluenceCount();
143 if (src.filterBone < 0) {
144 src.candidates.reserve(static_cast<size_t>(n));
145 for (int v = 0; v < n; ++v) src.candidates.push_back(v);
146 return;
147 }
148
149 src.candidates.reserve(static_cast<size_t>(n / 4 + 1));
150 for (int v = 0; v < n; ++v) {
151 float w = 0.f;
152 for (int i = 0; i < influences; ++i) {
153 if (src.skin->getVertexBone(v, i) == src.filterBone) {
154 w += src.skin->getVertexWeight(v, i);
155 }
156 }
157 if (w >= src.minWeight) src.candidates.push_back(v);
158 }
159}
160
161bool ensureSkinCache(ParticleEmitter::SkinSource &src) {
162 if (!src.enabled || !src.skin || !src.pose) return false;
163 if (src.candidatesDirty) rebuildSkinCandidates(src);
164 if (src.candidates.empty()) return false;
165 // Always refresh skinned positions from the live pose (caller must have
166 // computeWorld()'d). Cheap relative to VFX; keeps surface following animation.
167 return src.skin->updateSkinnedPositions(src.pose);
168}
169
170void fillParticleMotion(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, Particle &p) {
171 p.lifetime = randRange(sim.rng, cfg.lifeMin, cfg.lifeMax);
172 if (p.lifetime <= 0.f) p.lifetime = 1e-4f;
173 p.life = p.lifetime;
174 p.ax = randRange(sim.rng, cfg.accelXMin, cfg.accelXMax);
175 p.ay = randRange(sim.rng, cfg.accelYMin, cfg.accelYMax);
176 p.radial = randRange(sim.rng, cfg.radialMin, cfg.radialMax);
177 p.tangential = randRange(sim.rng, cfg.tangentialMin, cfg.tangentialMax);
178 p.spin = randRange(sim.rng, cfg.spinMin, cfg.spinMax);
179 p.rot = randRange(sim.rng, cfg.startRotMin, cfg.startRotMax) * (kPi / 180.f);
180
181 const int totalFrames = cfg.hframes > 0 && cfg.vframes > 0 ? cfg.hframes * cfg.vframes : 1;
182 if (cfg.frameRandomStart > 0.f && totalFrames > 1) {
183 const float frac = cfg.frameRandomStart > 1.f ? 1.f : cfg.frameRandomStart;
184 p.frame = randRange(sim.rng, 0.f, frac) * float(totalFrames);
185 } else {
186 p.frame = 0.f;
187 }
188 p.noisePhase = randRange(sim.rng, 0.f, 1000.f);
189
190 float sizeMul = 1.f;
191 if (cfg.sizeVariation > 0.f) {
192 const float v = cfg.sizeVariation > 1.f ? 1.f : cfg.sizeVariation;
193 sizeMul = 1.f + randRange(sim.rng, -v, v);
194 if (sizeMul < 0.01f) sizeMul = 0.01f;
195 }
196 p.size = sizeMul * cfg.resolvedParameterScale("size");
197
198 const float half = cfg.spread * 0.5f;
199 const float angle = cfg.direction + randRange(sim.rng, -half, half);
200 const float speed = randRange(sim.rng, cfg.speedMin, cfg.speedMax) * cfg.resolvedParameterScale("speed");
201 p.vx = std::cos(angle) * speed;
202 p.vy = std::sin(angle) * speed;
203}
204
205void clearAttachSources(ParticleEmitter::Attach &a) {
207 a.pose = nullptr;
208 a.skeleton = nullptr;
209 a.spine = nullptr;
210 a.ik2d = nullptr;
211 a.ik3d = nullptr;
212 a.boneIndex = -1;
213 a.enabled = false;
214}
215
216void syncAnimPoseAttach(ParticleEmitter::Config &cfg, ParticleEmitter::Attach &attach) {
217 if (!attach.pose || attach.boneIndex < 0 || attach.boneIndex >= attach.pose->getBoneCount())
218 return;
219 const auto &w = attach.pose->world(attach.boneIndex);
220 float ox = attach.offsetX, oy = attach.offsetY, oz = attach.offsetZ;
221 float wx, wy, wz;
223 projectToPlane(wx, wy, wz, attach.plane, attach.scale, cfg.x, cfg.y);
224
225 if (attach.followRotation) {
226 float fx, fy, fz;
227 quatRotateVec(w.qx, w.qy, w.qz, w.qw, 1.f, 0.f, 0.f, fx, fy, fz);
228 float ax, ay;
229 projectToPlane(fx, fy, fz, attach.plane, 1.f, ax, ay);
230 if (ax * ax + ay * ay > kEps) cfg.direction = std::atan2(ay, ax);
231 }
232}
233
234void syncSpineAttach(ParticleEmitter::Config &cfg, ParticleEmitter::Attach &attach) {
235 if (!attach.spine || attach.boneIndex < 0 || attach.boneIndex >= attach.spine->getBoneCount())
236 return;
237 float a, b, c, d;
238 attach.spine->getBoneWorldMatrix(attach.boneIndex, a, b, c, d);
239 const float wx =
240 attach.spine->getBoneWorldX(attach.boneIndex) + a * attach.offsetX + b * attach.offsetY;
241 const float wy =
242 attach.spine->getBoneWorldY(attach.boneIndex) + c * attach.offsetX + d * attach.offsetY;
243 // Spine is already 2D pixel space; scale still applies, plane ignored (xy).
244 cfg.x = wx * attach.scale;
245 cfg.y = wy * attach.scale;
246
247 if (attach.followRotation) {
248 // World rotation is degrees → particle direction radians.
249 cfg.direction = attach.spine->getBoneWorldRotation(attach.boneIndex) * (kPi / 180.f);
250 }
251}
252
253void syncIk2DAttach(ParticleEmitter::Config &cfg, ParticleEmitter::Attach &attach) {
254 if (!attach.ik2d || attach.boneIndex < 0 || attach.boneIndex >= attach.ik2d->getBoneCount())
255 return;
256 float ox = attach.offsetX;
257 float oy = attach.offsetY;
258 if (attach.followRotation || (ox != 0.f || oy != 0.f)) {
259 const float fx = attach.ik2d->getOrientationX(attach.boneIndex);
260 const float fy = attach.ik2d->getOrientationY(attach.boneIndex);
261 const float len2 = fx * fx + fy * fy;
262 if (len2 > kEps) {
263 const float inv = 1.f / std::sqrt(len2);
264 const float ux = fx * inv;
265 const float uy = fy * inv;
266 // Local +X along bone forward, +Y perpendicular.
267 const float rx = ox * ux - oy * uy;
268 const float ry = ox * uy + oy * ux;
269 ox = rx;
270 oy = ry;
271 if (attach.followRotation) cfg.direction = std::atan2(uy, ux);
272 }
273 }
274 cfg.x = (attach.ik2d->getX(attach.boneIndex) + ox) * attach.scale;
275 cfg.y = (attach.ik2d->getY(attach.boneIndex) + oy) * attach.scale;
276}
277
278void syncIk3DAttach(ParticleEmitter::Config &cfg, ParticleEmitter::Attach &attach) {
279 if (!attach.ik3d || attach.boneIndex < 0 || attach.boneIndex >= attach.ik3d->getBoneCount())
280 return;
281 float ox = attach.offsetX;
282 float oy = attach.offsetY;
283 float oz = attach.offsetZ;
284 const float fx = attach.ik3d->getOrientationX(attach.boneIndex);
285 const float fy = attach.ik3d->getOrientationY(attach.boneIndex);
286 const float fz = attach.ik3d->getOrientationZ(attach.boneIndex);
287 const float len2 = fx * fx + fy * fy + fz * fz;
288 if (len2 > kEps && (ox != 0.f || oy != 0.f || oz != 0.f || attach.followRotation)) {
289 const float inv = 1.f / std::sqrt(len2);
290 const float ux = fx * inv, uy = fy * inv, uz = fz * inv;
291 // Offset: along-bone (ox) + world remainder (oy/oz as translation extras).
292 const float wx = attach.ik3d->getX(attach.boneIndex) + ux * ox + oy;
293 const float wy = attach.ik3d->getY(attach.boneIndex) + uy * ox + oz;
294 const float wz = attach.ik3d->getZ(attach.boneIndex) + uz * ox;
295 projectToPlane(wx, wy, wz, attach.plane, attach.scale, cfg.x, cfg.y);
296 if (attach.followRotation) {
297 float ax, ay;
298 projectToPlane(ux, uy, uz, attach.plane, 1.f, ax, ay);
299 if (ax * ax + ay * ay > kEps) cfg.direction = std::atan2(ay, ax);
300 }
301 return;
302 }
303 projectToPlane(attach.ik3d->getX(attach.boneIndex) + ox, attach.ik3d->getY(attach.boneIndex) + oy,
304 attach.ik3d->getZ(attach.boneIndex) + oz, attach.plane, attach.scale, cfg.x,
305 cfg.y);
306}
307
308void fireSubEmitter(ParticleEmitter::Config &cfg, const std::string &trigger, float x, float y,
309 float vx, float vy) {
310 static int g_subDepth = 0;
311 if (g_subDepth >= 8) return; // cycle guard (A→B→A chains)
312 for (const auto &se : cfg.subEmitters) {
313 if (!se.target || se.trigger != trigger) continue;
314 // Self-referencing sub-emitters would corrupt the compaction loop.
315 if (se.target == cfg.entity) continue;
316 auto tc = se.target->config();
317 auto ts = se.target->sim();
318 if (ts->alive >= int(ts->particles.size())) continue;
319 ++g_subDepth;
320 const bool spawned = spawnParticleAt(*tc, *ts, x, y);
321 --g_subDepth;
322 if (spawned && se.inheritVelocity > 0.f) {
323 Particle &p = ts->particles[size_t(ts->alive - 1)];
324 p.vx += vx * se.inheritVelocity;
325 p.vy += vy * se.inheritVelocity;
326 }
327 }
328}
329
330} // namespace
331
332namespace {
333WorldCollisionFn g_worldCollision = nullptr;
334} // namespace
335
336void setWorldCollisionResolver(WorldCollisionFn fn) { g_worldCollision = fn; }
337
338WorldCollisionFn getWorldCollisionResolver() { return g_worldCollision; }
339
341 if (sim.spawnQuota == 0) {
343 return false;
344 }
345 if (sim.alive >= int(sim.particles.size())) return false;
346
347 Particle &p = sim.particles[size_t(sim.alive++)];
348 if (sim.spawnQuota > 0) --sim.spawnQuota;
349 ++sim.spawnedThisFrame;
350 float ox = 0.f, oy = 0.f;
351 sampleEmissionOffset(cfg, sim, ox, oy);
352 p.x = x + ox;
353 p.y = y + oy;
354 fillParticleMotion(cfg, sim, p);
355 fireSubEmitter(cfg, "birth", p.x, p.y, p.vx, p.vy);
356 return true;
357}
358
360 if (sim.spawnQuota == 0) {
362 return false;
363 }
364 // Prefer skin surface when configured on the owning entity.
365 if (cfg.entity) {
366 auto skinComp = cfg.entity->skinSource();
367 if (skinComp->enabled) {
368 float sx = cfg.x, sy = cfg.y;
369 if (sampleSkinSpawn(*skinComp, sim, sx, sy)) {
370 return spawnParticleAt(cfg, sim, sx, sy);
371 }
372 }
373 }
374 return spawnParticleAt(cfg, sim, cfg.x, cfg.y);
375}
376
378 float &outY) {
379 if (!ensureSkinCache(skinSrc)) return false;
380 const int vi = skinSrc.candidates[static_cast<size_t>(randIndex(sim.rng, int(skinSrc.candidates.size())))];
381 const float wx = skinSrc.skin->getSkinnedPositionX(vi);
382 const float wy = skinSrc.skin->getSkinnedPositionY(vi);
383 const float wz = skinSrc.skin->getSkinnedPositionZ(vi);
384 projectToPlane(wx, wy, wz, skinSrc.plane, skinSrc.scale, outX, outY);
385 return true;
386}
387
390 if (attach.enabled) {
391 switch (attach.kind) {
393 syncAnimPoseAttach(cfg, attach);
394 break;
396 syncSpineAttach(cfg, attach);
397 break;
399 syncIk2DAttach(cfg, attach);
400 break;
402 syncIk3DAttach(cfg, attach);
403 break;
405 default:
406 break;
407 }
408 }
409
410 if (skinSrc.enabled && skinSrc.skin && skinSrc.pose) {
411 ensureSkinCache(skinSrc);
412 }
413}
414
416 if (sim.paused || dt <= 0.f) return;
417 if (cfg.maxDeltaTime > 0.f && dt > cfg.maxDeltaTime) dt = cfg.maxDeltaTime;
418
419 // Emitter velocity for inheritVelocity (before lastX/lastY refresh).
420 float emitVx = 0.f, emitVy = 0.f;
421 if (cfg.inheritVelocity > 0.f && sim.hasLastPos) {
422 emitVx = (cfg.x - sim.lastX) / dt;
423 emitVy = (cfg.y - sim.lastY) / dt;
424 }
425 const bool localSpace = cfg.simSpace == "local";
426 const float localDx = localSpace && sim.hasLastPos ? cfg.x - sim.lastX : 0.f;
427 const float localDy = localSpace && sim.hasLastPos ? cfg.y - sim.lastY : 0.f;
428
429 const float damp = cfg.damping > 0.f ? (cfg.damping > 1.f ? 1.f : cfg.damping) : 0.f;
430 const float dampFactor = damp > 0.f ? std::max(0.f, 1.f - damp * dt) : 1.f;
431 const float noiseFreq = cfg.noiseFrequency > 0.f ? cfg.noiseFrequency : 1.f;
432 const bool hasCollision =
433 cfg.collisionMode != "none" &&
434 (cfg.worldCollision || cfg.collisionBoundsEnabled || cfg.sdfField != nullptr);
435 const bool bounce = cfg.collisionMode == "bounce";
436 const bool killMode = cfg.collisionMode == "kill";
437 const bool stopMode = cfg.collisionMode == "stop";
438 const float cRadius = cfg.collisionRadius;
439 const float lifeLoss = cfg.collisionLifetimeLoss;
440
441 int write = 0;
442 for (int i = 0; i < sim.alive; ++i) {
443 Particle &p = sim.particles[size_t(i)];
444 p.life -= dt;
445 if (p.life <= 0.f) {
446 fireSubEmitter(cfg, "death", p.x, p.y, p.vx, p.vy);
447 continue;
448 }
449
450 if (localSpace && (localDx != 0.f || localDy != 0.f)) {
451 p.x += localDx;
452 p.y += localDy;
453 }
454
455 float ax = p.ax + cfg.gravityX;
456 float ay = p.ay + cfg.gravityY;
457 const float dx = p.x - cfg.x;
458 const float dy = p.y - cfg.y;
459 const float len2 = dx * dx + dy * dy;
460 if (len2 > kEps) {
461 const float inv = 1.f / std::sqrt(len2);
462 const float rdx = dx * inv;
463 const float rdy = dy * inv;
464 ax += rdx * p.radial - rdy * p.tangential;
465 ay += rdy * p.radial + rdx * p.tangential;
466 }
467
468 if (cfg.noiseStrength != 0.f) {
469 const float t = sim.emitterAge * cfg.noiseSpeed;
470 ax += smoothNoise2(p.x * noiseFreq + t, p.y * noiseFreq + p.noisePhase) *
471 cfg.noiseStrength;
472 ay += smoothNoise2(p.y * noiseFreq + t, p.x * noiseFreq - p.noisePhase) *
473 cfg.noiseStrength;
474 }
475
476 // Radial force fields (strength > 0 attract, < 0 repel).
477 for (const auto &f : cfg.forceFields) {
478 if (f.radius <= 0.f || f.strength == 0.f) continue;
479 const float fdx = f.x - p.x;
480 const float fdy = f.y - p.y;
481 const float dist = std::sqrt(fdx * fdx + fdy * fdy);
482 if (dist >= f.radius) continue;
483 const float fall = std::pow(1.f - dist / f.radius, f.falloff > 0.f ? f.falloff : 1.f);
484 const float inv = dist > kEps ? 1.f / dist : 0.f;
485 ax += fdx * inv * f.strength * fall;
486 ay += fdy * inv * f.strength * fall;
487 }
488
489 p.vx += ax * dt;
490 p.vy += ay * dt;
491
492 // Velocity-over-lifetime multiplier (applied as a smooth ratio).
493 if (!cfg.velocityCurve.empty() && p.lifetime > 0.f) {
494 const float tNew = 1.f - (p.life / p.lifetime);
495 const float tOld = tNew - dt / p.lifetime;
496 const float vOld = cfg.velocityCurve.sample(tOld, 1.f);
497 const float vNew = cfg.velocityCurve.sample(tNew, 1.f);
498 if (vOld > 1e-4f) {
499 const float ratio = vNew / vOld;
500 p.vx *= ratio;
501 p.vy *= ratio;
502 }
503 }
504
505 if (dampFactor != 1.f) {
506 p.vx *= dampFactor;
507 p.vy *= dampFactor;
508 }
509 if (cfg.limitVelocity > 0.f) {
510 const float sp2 = p.vx * p.vx + p.vy * p.vy;
511 const float max2 = cfg.limitVelocity * cfg.limitVelocity;
512 if (sp2 > max2) {
513 const float s = std::sqrt(max2 / sp2);
514 p.vx *= s;
515 p.vy *= s;
516 }
517 }
518
519 p.x += p.vx * dt;
520 p.y += p.vy * dt;
521 p.rot += p.spin * dt;
522 p.frame += cfg.frameRate * dt;
523
524 if (hasCollision) {
525 const float scale = p.size > 0.f ? p.size : 1.f;
526 const float rad =
527 cRadius > 0.f ? cRadius : std::max(cfg.particleW, cfg.particleH) * 0.5f * scale;
528 float nx = 0.f, ny = 0.f;
529 bool hit = false;
530 if (cfg.worldCollision) {
532 if (fn(p.x, p.y, rad, nx, ny)) hit = true;
533 }
534 if (!hit && cfg.sdfField != nullptr) {
535 const float distance = cfg.sdfField->sample(p.x, p.y);
536 if (std::isfinite(distance) && distance < rad) {
537 float gx = 0.f, gy = 0.f;
538 if (cfg.sdfField->gradient(p.x, p.y, gx, gy) ==
540 nx = gx;
541 ny = gy;
542 const float push = rad - distance;
543 p.x += nx * push;
544 p.y += ny * push;
545 hit = true;
546 } else {
547 hit = true;
548 }
549 }
550 }
551 if (!hit && cfg.collisionBoundsEnabled) {
552 if (p.x - rad < cfg.boundsMinX) {
553 nx += 1.f;
554 p.x = cfg.boundsMinX + rad;
555 hit = true;
556 } else if (p.x + rad > cfg.boundsMaxX) {
557 nx += -1.f;
558 p.x = cfg.boundsMaxX - rad;
559 hit = true;
560 }
561 if (p.y - rad < cfg.boundsMinY) {
562 ny += 1.f;
563 p.y = cfg.boundsMinY + rad;
564 hit = true;
565 } else if (p.y + rad > cfg.boundsMaxY) {
566 ny += -1.f;
567 p.y = cfg.boundsMaxY - rad;
568 hit = true;
569 }
570 const float nlen = std::sqrt(nx * nx + ny * ny);
571 if (nlen > kEps) {
572 nx /= nlen;
573 ny /= nlen;
574 }
575 }
576 if (hit) {
577 fireSubEmitter(cfg, "collision", p.x, p.y, p.vx, p.vy);
578 if (killMode) continue;
579 if (stopMode) {
580 p.vx = 0.f;
581 p.vy = 0.f;
582 } else if (bounce && (nx != 0.f || ny != 0.f)) {
583 const float dot = p.vx * nx + p.vy * ny;
584 p.vx = (p.vx - 2.f * dot * nx) * cfg.collisionRestitution;
585 p.vy = (p.vy - 2.f * dot * ny) * cfg.collisionRestitution;
586 }
587 if (lifeLoss > 0.f) {
588 p.life -= p.lifetime * lifeLoss;
589 if (p.life <= 0.f) continue;
590 }
591 }
592 }
593
594 if (write != i) sim.particles[size_t(write)] = p;
595 ++write;
596 }
597 sim.alive = write;
598
599 if (!sim.active) {
600 sim.lastX = cfg.x;
601 sim.lastY = cfg.y;
602 sim.hasLastPos = true;
603 return;
604 }
605
606 sim.emitterAge += dt;
607
608 // Timed bursts fire once while the emitter is active.
609 auto spawnWithInherit = [&]() {
610 const bool spawned = spawnParticle(cfg, sim);
611 if (spawned && cfg.inheritVelocity > 0.f && sim.alive > 0) {
612 Particle &np = sim.particles[size_t(sim.alive - 1)];
613 np.vx += emitVx * cfg.inheritVelocity;
614 np.vy += emitVy * cfg.inheritVelocity;
615 }
616 return spawned;
617 };
618 for (auto &b : cfg.bursts) {
619 if (!b.emitted && b.count > 0 && sim.emitterAge >= b.time) {
620 b.emitted = true;
621 for (int k = 0; k < b.count; ++k) {
622 if (sim.alive >= int(sim.particles.size())) break;
623 if (!spawnWithInherit()) break;
624 }
625 }
626 }
627
628 const float emissionScale = cfg.resolvedParameterScale("emission");
629 if (cfg.emissionRate > 0.f && emissionScale > 0.f) {
630 sim.emitAccum += cfg.emissionRate * emissionScale * dt;
631 while (sim.emitAccum >= 1.f) {
632 if (sim.alive >= int(sim.particles.size())) {
633 if (cfg.overflowMode == "pause") break; // keep accum; retry next frame
634 if (cfg.overflowMode == "warn" && !sim.overflowWarned) {
635 sim.overflowWarned = true;
636 std::fprintf(stderr,
637 "[particles] buffer overflow (emissionRate=%.1f, buffer=%d)\n",
638 cfg.emissionRate, int(sim.particles.size()));
639 }
640 sim.emitAccum = 0.f;
641 break;
642 }
643 if (!spawnWithInherit()) {
644 sim.emitAccum = 0.f;
645 break;
646 }
647 sim.emitAccum -= 1.f;
648 }
649 }
650
651 // Rate over distance is accumulated in particle units and spawned at
652 // evenly spaced points along the emitter's travelled segment. This avoids
653 // the frame-rate-dependent clumps produced by spawning every item at the
654 // current endpoint (important for weapon trails and wheel dust).
655 const float distanceRate = cfg.emissionRateOverDistance * emissionScale;
656 if (distanceRate > 0.f && sim.hasLastPos) {
657 const float moveX = cfg.x - sim.lastX;
658 const float moveY = cfg.y - sim.lastY;
659 const float distance = std::sqrt(moveX * moveX + moveY * moveY);
660 const float prior = sim.distanceEmitAccum;
661 const float total = prior + distance * distanceRate;
662 const int wanted = int(std::floor(total));
663 sim.distanceEmitAccum = total - float(wanted);
664 const float firstDistance = prior > 0.f ? (1.f - prior) / distanceRate : 1.f / distanceRate;
665 for (int i = 0; i < wanted; ++i) {
666 if (sim.alive >= int(sim.particles.size())) {
667 if (cfg.overflowMode == "pause")
668 sim.distanceEmitAccum += float(wanted - i);
669 else if (cfg.overflowMode == "warn" && !sim.overflowWarned) {
670 sim.overflowWarned = true;
671 std::fprintf(stderr, "[particles] distance emission overflow (rate=%.2f, buffer=%d)\n",
672 cfg.emissionRateOverDistance, int(sim.particles.size()));
673 }
674 break;
675 }
676 float spawnX = cfg.x;
677 float spawnY = cfg.y;
678 if (distance > kEps && !localSpace) {
679 const float along = firstDistance + float(i) / distanceRate;
680 const float alpha = std::min(1.f, along / distance);
681 spawnX = sim.lastX + moveX * alpha;
682 spawnY = sim.lastY + moveY * alpha;
683 }
684 const bool spawned = spawnParticleAt(cfg, sim, spawnX, spawnY);
685 if (!spawned) break;
686 if (cfg.inheritVelocity > 0.f && sim.alive > 0) {
687 Particle& np = sim.particles[size_t(sim.alive - 1)];
688 np.vx += emitVx * cfg.inheritVelocity;
689 np.vy += emitVy * cfg.inheritVelocity;
690 }
691 }
692 }
693
694 // Expire AFTER this frame's emission so a short-lived emitter still
695 // releases the particles due during its final frame.
696 if (cfg.emitterLife >= 0.f && sim.emitterAge >= cfg.emitterLife) {
697 if (cfg.looping && cfg.emitterLife > 0.f) {
698 sim.emitterAge = std::fmod(sim.emitterAge, cfg.emitterLife);
699 for (auto& b : cfg.bursts) b.emitted = false;
700 } else {
701 sim.active = false;
702 sim.emitAccum = 0.f;
703 sim.distanceEmitAccum = 0.f;
704 }
705 }
706
707 sim.lastX = cfg.x;
708 sim.lastY = cfg.y;
709 sim.hasLastPos = true;
710}
711
713 ParticleEmitter *e = ParticleEmitter::create();
714 e->config()->entity = e;
715 const int n = bufferSize > 0 ? bufferSize : 1;
716 e->sim()->particles.resize(size_t(n));
717 std::random_device rd;
718 const int seed = static_cast<int>(rd());
719 e->sim()->activeSeed = seed;
720 e->sim()->rng.seed(static_cast<std::mt19937::result_type>(seed));
721 // Touch Draw / Attach / SkinSource so system views see fully initialized emitters.
722 (void)e->draw();
723 (void)e->attach();
724 (void)e->skinSource();
725 (void)e->lights();
726 (void)e->gpuSim();
727 return e;
728}
729
731 auto gpu = gpuSim();
732 if (gpu->residentHandle != 0) {
733 auto* gfx = eve::ModuleManager::getInstance<eve::graphics::Graphics>("Graphics");
734 if (gfx) gfx->releaseGpuParticleEmitter(gpu->residentHandle);
735 gpu->residentHandle = 0;
736 gpu->residentActive = false;
737 gpu->estimatedAlive = 0;
738 gpu->deathTimes.clear();
739 }
740 ecs::DestroyEntity(this);
741}
742
743void ParticleEmitter::setPosition(float x, float y) {
744 config()->x = x;
745 config()->y = y;
746}
747
748void ParticleEmitter::moveTo(float x, float y) { setPosition(x, y); }
749
750float ParticleEmitter::getX() { return config()->x; }
751float ParticleEmitter::getY() { return config()->y; }
752
754 config()->emissionRate = rate < 0.f ? 0.f : rate;
755}
756
757float ParticleEmitter::getEmissionRate() { return config()->emissionRate; }
758
759void ParticleEmitter::setParticleLifetime(float minLife, float maxLife) {
760 auto c = config();
761 c->lifeMin = minLife < 0.f ? 0.f : minLife;
762 c->lifeMax = maxLife < c->lifeMin ? c->lifeMin : maxLife;
763}
764
765float ParticleEmitter::getParticleLifetimeMin() { return config()->lifeMin; }
766float ParticleEmitter::getParticleLifetimeMax() { return config()->lifeMax; }
767
768void ParticleEmitter::setEmitterLifetime(float seconds) { config()->emitterLife = seconds; }
769float ParticleEmitter::getEmitterLifetime() { return config()->emitterLife; }
770
771void ParticleEmitter::setDirection(float radians) { config()->direction = radians; }
772float ParticleEmitter::getDirection() { return config()->direction; }
773
774void ParticleEmitter::setSpread(float radians) {
775 config()->spread = radians < 0.f ? 0.f : radians;
776}
777float ParticleEmitter::getSpread() { return config()->spread; }
778
779void ParticleEmitter::setSpeed(float minSpeed, float maxSpeed) {
780 auto c = config();
781 c->speedMin = minSpeed;
782 c->speedMax = maxSpeed < minSpeed ? minSpeed : maxSpeed;
783}
784
785void ParticleEmitter::setLinearAcceleration(float xmin, float ymin, float xmax, float ymax) {
786 auto c = config();
787 c->accelXMin = xmin;
788 c->accelYMin = ymin;
789 c->accelXMax = xmax < xmin ? xmin : xmax;
790 c->accelYMax = ymax < ymin ? ymin : ymax;
791}
792
793void ParticleEmitter::setRadialAcceleration(float minA, float maxA) {
794 auto c = config();
795 c->radialMin = minA;
796 c->radialMax = maxA < minA ? minA : maxA;
797}
798
799void ParticleEmitter::setTangentialAcceleration(float minA, float maxA) {
800 auto c = config();
801 c->tangentialMin = minA;
802 c->tangentialMax = maxA < minA ? minA : maxA;
803}
804
805void ParticleEmitter::setEmissionArea(const std::string &type, float x, float y) {
806 auto c = config();
807 if (type == "ellipse" || type == "rect" || type == "line" || type == "ring")
808 c->areaType = type;
809 else
810 c->areaType = "none";
811 c->areaX = x < 0.f ? 0.f : x;
812 c->areaY = y < 0.f ? 0.f : y;
813}
814
815std::string ParticleEmitter::getEmissionAreaType() { return config()->areaType; }
816float ParticleEmitter::getEmissionAreaX() { return config()->areaX; }
817float ParticleEmitter::getEmissionAreaY() { return config()->areaY; }
818
820 auto c = config();
821 c->particleW = width > 0.f ? width : 1.f;
822 c->particleH = height > 0.f ? height : 1.f;
823}
824
825float ParticleEmitter::getParticleWidth() { return config()->particleW; }
826float ParticleEmitter::getParticleHeight() { return config()->particleH; }
827
828void ParticleEmitter::setSizes(float startScale, float endScale) {
829 config()->sizeStart = startScale;
830 config()->sizeEnd = endScale;
831}
832
834 config()->sizeVariation = variation < 0.f ? 0.f : (variation > 1.f ? 1.f : variation);
835}
836float ParticleEmitter::getSizeVariation() { return config()->sizeVariation; }
837
838void ParticleEmitter::setSpin(float minSpin, float maxSpin) {
839 auto c = config();
840 c->spinMin = minSpin;
841 c->spinMax = maxSpin < minSpin ? minSpin : maxSpin;
842}
843
844void ParticleEmitter::setStartRotation(float minDeg, float maxDeg) {
845 auto c = config();
846 c->startRotMin = minDeg;
847 c->startRotMax = maxDeg < minDeg ? minDeg : maxDeg;
848}
849
851 if (count <= 0) return;
852 config()->bursts.push_back(Config::Burst{time < 0.f ? 0.f : time, count, false});
853}
854
855void ParticleEmitter::clearBursts() { config()->bursts.clear(); }
856
857void ParticleEmitter::setPrewarm(float seconds) {
858 config()->prewarmSeconds = seconds < 0.f ? 0.f : seconds;
859}
860
861float ParticleEmitter::getPrewarmSeconds() { return config()->prewarmSeconds; }
862
863void ParticleEmitter::setGravity(float x, float y) {
864 auto c = config();
865 c->gravityX = x;
866 c->gravityY = y;
867}
868
869void ParticleEmitter::setDamping(float perSecond) {
870 config()->damping = perSecond < 0.f ? 0.f : perSecond;
871}
872
874 config()->limitVelocity = maxSpeed < 0.f ? 0.f : maxSpeed;
875}
876
877void ParticleEmitter::clearVelocityCurve() { config()->velocityCurve.clear(); }
878
880 config()->velocityCurve.add(t, v);
881}
882
884 config()->inheritVelocity =
885 fraction < 0.f ? 0.f : (fraction > 1.f ? 1.f : fraction);
886}
887
888void ParticleEmitter::setSimulationSpace(const std::string &space) {
889 config()->simSpace = space == "local" ? "local" : "world";
890}
891
892void ParticleEmitter::setNoise(float strength, float frequency, float speed) {
893 auto c = config();
894 c->noiseStrength = strength;
895 c->noiseFrequency = frequency > 0.f ? frequency : 1.f;
896 c->noiseSpeed = speed;
897}
898
900 config()->gpuSimulation = enable;
901 auto gpu = gpuSim();
902 gpu->enabled = enable;
903 if (!enable && gpu->residentHandle != 0) {
904 auto* gfx = eve::ModuleManager::getInstance<eve::graphics::Graphics>("Graphics");
905 if (gfx) gfx->releaseGpuParticleEmitter(gpu->residentHandle);
906 gpu->residentHandle = 0;
907 gpu->residentActive = false;
908 gpu->estimatedAlive = 0;
909 gpu->timeline = 0.0;
910 gpu->deathTimes.clear();
911 }
912}
913
914bool ParticleEmitter::getGpuSimulation() { return config()->gpuSimulation; }
915
916bool ParticleEmitter::isGpuSimulationActive() { return gpuSim()->residentActive; }
917
918bool ParticleEmitter::isGpuFeatureSetSupported() { return gpuFeatureFallbackReason(*config(), *draw())[0] == '\0'; }
919
920std::string ParticleEmitter::getSimulationBackend() { return gpuSim()->residentActive ? "gpu" : "cpu"; }
921
923 if (gpuSim()->residentActive) return {};
924 if (!config()->gpuSimulation) return "disabled";
925 if (const char* reason = gpuFeatureFallbackReason(*config(), *draw()); reason[0] != '\0') return reason;
926 auto* gfx = eve::ModuleManager::getInstance<eve::graphics::Graphics>("Graphics");
927 if (!gfx || !gfx->supportsGpuParticles()) return "backend_unavailable";
928 return "pending_activation";
929}
930
931void ParticleEmitter::setFloatParameter(const std::string& name, float value) {
932 if (!name.empty()) config()->floatParameters[name] = value;
933}
934
935float ParticleEmitter::getFloatParameter(const std::string& name, float fallback) {
936 auto found = config()->floatParameters.find(name);
937 return found == config()->floatParameters.end() ? fallback : found->second;
938}
939
940bool ParticleEmitter::hasFloatParameter(const std::string& name) {
941 return config()->floatParameters.find(name) != config()->floatParameters.end();
942}
943
944void ParticleEmitter::clearFloatParameters() { config()->floatParameters.clear(); }
945
946void ParticleEmitter::bindFloatParameter(const std::string& name, const std::string& target, float scale,
947 float offset) {
948 if (name.empty()) return;
949 if (target != "emission" && target != "speed" && target != "size" && target != "playback") return;
950 config()->parameterBindings[target] = Config::ParameterBinding{name, scale, offset};
951}
952
953void ParticleEmitter::clearFloatParameterBindings() { config()->parameterBindings.clear(); }
954
956 return config()->resolvedParameterScale(target);
957}
958
959void ParticleEmitter::setCollision(const std::string &mode, float radius, float restitution,
960 float lifetimeLoss) {
961 auto c = config();
962 c->collisionMode =
963 (mode == "kill" || mode == "bounce" || mode == "stop") ? mode : "none";
964 c->collisionRadius = radius < 0.f ? 0.f : radius;
965 c->collisionRestitution = restitution < 0.f ? 0.f : restitution;
966 c->collisionLifetimeLoss =
967 lifetimeLoss < 0.f ? 0.f : (lifetimeLoss > 1.f ? 1.f : lifetimeLoss);
968}
969
970void ParticleEmitter::setCollisionBounds(bool enabled, float minX, float minY, float maxX,
971 float maxY) {
972 auto c = config();
973 c->collisionBoundsEnabled = enabled;
974 c->boundsMinX = minX;
975 c->boundsMinY = minY;
976 c->boundsMaxX = maxX;
977 c->boundsMaxY = maxY;
978}
979
980void ParticleEmitter::setWorldCollision(bool enabled) { config()->worldCollision = enabled; }
981
982void ParticleEmitter::setSdfField(eve::IParticleSdfField* field) { config()->sdfField = field; }
983
985
986void ParticleEmitter::setMotionVectorPolicy(const std::string& policy) {
987 config()->motionVectorPolicy =
988 (policy == "velocity" || policy == "spawn_delta") ? policy : "none";
989}
990
992 const auto& policy = config()->motionVectorPolicy;
993 return (policy == "velocity" || policy == "spawn_delta") ? policy : "none";
994}
995
997 // Policy is authored and queryable; the graphics velocity-buffer path is not
998 // wired yet, so activation stays false until a backend writes motion vectors.
999 return false;
1000}
1001
1002void ParticleEmitter::setRenderMode(const std::string &mode, float stretchFactor) {
1003 auto c = config();
1004 c->renderMode = (mode == "stretched" || mode == "velocity") ? "stretched"
1005 : mode == "axis" ? "axis"
1006 : mode == "ribbon" ? "ribbon"
1007 : "billboard";
1008 c->stretchFactor = stretchFactor < 0.f ? 0.f : stretchFactor;
1009}
1010
1011void ParticleEmitter::setRibbon(float width, float minSegmentLength) {
1012 auto c = config();
1013 c->renderMode = "ribbon";
1014 c->ribbonWidth = width < 0.f ? 0.f : width;
1015 c->ribbonMinSegmentLength = minSegmentLength < 0.f ? 0.f : minSegmentLength;
1016}
1017
1018void ParticleEmitter::setRenderAxis(float degrees) { config()->renderAxisDegrees = degrees; }
1019
1020void ParticleEmitter::setSortMode(const std::string& mode) {
1021 config()->sortMode = (mode == "oldest" || mode == "youngest" || mode == "distance") ? mode : "none";
1022}
1023
1024std::string ParticleEmitter::getSortMode() { return config()->sortMode; }
1025
1026void ParticleEmitter::setMaterialMode(const std::string& mode) {
1027 config()->materialMode = (mode == "lit" || mode == "distortion") ? mode : "unlit";
1028}
1029
1030std::string ParticleEmitter::getMaterialMode() { return config()->materialMode; }
1031
1032void ParticleEmitter::setDistortionStrength(float strengthPixels) {
1033 config()->distortionStrength = std::max(0.f, strengthPixels);
1034}
1035
1036float ParticleEmitter::getDistortionStrength() { return config()->distortionStrength; }
1037
1038void ParticleEmitter::setOverflowMode(const std::string &mode) {
1039 config()->overflowMode =
1040 (mode == "pause" || mode == "warn") ? mode : "drop";
1041}
1042
1044 config()->maxDeltaTime = seconds < 0.f ? 0.f : seconds;
1045}
1046
1048 float inheritVelocity) {
1049 if (!target || target == this) return;
1051 se.target = target;
1052 se.trigger = (trigger == "death" || trigger == "collision") ? trigger : "birth";
1053 se.inheritVelocity = inheritVelocity < 0.f ? 0.f : (inheritVelocity > 1.f ? 1.f : inheritVelocity);
1054 config()->subEmitters.push_back(se);
1055}
1056
1057void ParticleEmitter::clearSubEmitters() { config()->subEmitters.clear(); }
1058
1059void ParticleEmitter::addForceField(float x, float y, float radius, float strength,
1060 float falloff) {
1061 if (radius <= 0.f || strength == 0.f) return;
1063 f.x = x;
1064 f.y = y;
1065 f.radius = radius;
1066 f.strength = strength;
1067 f.falloff = falloff > 0.f ? falloff : 1.f;
1068 config()->forceFields.push_back(f);
1069}
1070
1071void ParticleEmitter::clearForceFields() { config()->forceFields.clear(); }
1072
1075
1076void ParticleEmitter::setLights(bool enabled, float radius, float intensity, float r, float g,
1077 float b, int maxLights) {
1078 auto c = config();
1079 c->lights.enabled = enabled;
1080 c->lights.radius = radius > 0.f ? radius : 0.f;
1081 c->lights.intensity = intensity;
1082 c->lights.r = r;
1083 c->lights.g = g;
1084 c->lights.b = b;
1085 c->lights.max = maxLights > 0 ? maxLights : 0;
1086}
1087
1088bool ParticleEmitter::getLightsEnabled() { return config()->lights.enabled; }
1089
1090void ParticleEmitter::setBlendMode(const std::string &mode) {
1091 if (mode == "additive")
1092 draw()->blend = BlendMode::Additive;
1093 else if (mode == "opaque")
1094 draw()->blend = BlendMode::Opaque;
1095 else if (mode == "premultiplied" || mode == "premultiplied_alpha")
1096 draw()->blend = BlendMode::Premultiplied;
1097 else if (mode == "multiply")
1098 draw()->blend = BlendMode::Multiply;
1099 else
1100 draw()->blend = BlendMode::Alpha;
1101}
1102
1104 switch (draw()->blend) {
1106 return "additive";
1107 case BlendMode::Opaque:
1108 return "opaque";
1110 return "premultiplied";
1112 return "multiply";
1113 case BlendMode::Alpha:
1114 default:
1115 return "alpha";
1116 }
1117}
1118
1119void ParticleEmitter::setFlipbook(int h, int v, float framesPerSecond, float randomStart) {
1120 auto c = config();
1121 c->hframes = h > 0 ? h : 1;
1122 c->vframes = v > 0 ? v : 1;
1123 c->frameRate = framesPerSecond;
1124 c->frameRandomStart = randomStart < 0.f ? 0.f : (randomStart > 1.f ? 1.f : randomStart);
1125}
1126
1127void ParticleEmitter::clearColorGradient() { config()->colorGradient.clear(); }
1128
1129void ParticleEmitter::addColorStop(float t, float r, float g, float b, float a) {
1130 config()->colorGradient.add(t, r, g, b, a);
1131}
1132
1133void ParticleEmitter::clearSizeCurve() { config()->sizeCurve.clear(); }
1134
1135void ParticleEmitter::addSizeCurvePoint(float t, float v) { config()->sizeCurve.add(t, v); }
1136
1137void ParticleEmitter::clearRotationCurve() { config()->rotationCurve.clear(); }
1138
1140 config()->rotationCurve.add(t, v);
1141}
1142
1143void ParticleEmitter::setColorStart(float r, float g, float b, float a) {
1144 config()->colorStart = Color(r, g, b, a);
1145}
1146
1147void ParticleEmitter::setColorEnd(float r, float g, float b, float a) {
1148 config()->colorEnd = Color(r, g, b, a);
1149}
1150
1151void ParticleEmitter::setTexture(graphics::Texture *texture) { draw()->texture = texture; }
1153
1154void ParticleEmitter::setNormalTexture(graphics::Texture* texture) { draw()->normalTexture = texture; }
1156
1157void ParticleEmitter::setCanvas(graphics::Canvas *canvas) { draw()->canvas = canvas; }
1159
1161int ParticleEmitter::getLayer() { return draw()->layer; }
1162
1164bool ParticleEmitter::isVisible() { return draw()->visible; }
1165
1166void ParticleEmitter::applyPreset(const std::string &name) {
1167 if (name == "spark") {
1168 setEmissionRate(80.f);
1169 setParticleLifetime(0.2f, 0.6f);
1170 setEmitterLifetime(-1.f);
1171 setDirection(-kPi * 0.5f);
1172 setSpread(kPi * 0.6f);
1173 setSpeed(60.f, 180.f);
1174 setLinearAcceleration(-20.f, 40.f, 20.f, 120.f);
1175 setRadialAcceleration(0.f, 0.f);
1176 setTangentialAcceleration(0.f, 0.f);
1177 setEmissionArea("none", 0.f, 0.f);
1178 setParticleSize(4.f, 4.f);
1179 setSizes(1.f, 0.2f);
1180 setSizeVariation(0.3f);
1181 setSpin(-8.f, 8.f);
1182 setColorStart(1.f, 0.9f, 0.3f, 1.f);
1183 setColorEnd(1.f, 0.2f, 0.f, 0.f);
1184 } else if (name == "smoke") {
1185 setEmissionRate(25.f);
1186 setParticleLifetime(1.5f, 3.f);
1187 setEmitterLifetime(-1.f);
1188 setDirection(-kPi * 0.5f);
1189 setSpread(0.4f);
1190 setSpeed(10.f, 40.f);
1191 setLinearAcceleration(-5.f, -30.f, 5.f, -10.f);
1192 setRadialAcceleration(-5.f, 5.f);
1193 setTangentialAcceleration(-10.f, 10.f);
1194 setEmissionArea("ellipse", 12.f, 4.f);
1195 setParticleSize(16.f, 16.f);
1196 setSizes(0.5f, 2.f);
1197 setSizeVariation(0.4f);
1198 setSpin(-1.f, 1.f);
1199 setColorStart(0.5f, 0.5f, 0.5f, 0.5f);
1200 setColorEnd(0.3f, 0.3f, 0.3f, 0.f);
1201 } else if (name == "fire") {
1202 setEmissionRate(60.f);
1203 setParticleLifetime(0.4f, 1.0f);
1204 setEmitterLifetime(-1.f);
1205 setDirection(-kPi * 0.5f);
1206 setSpread(0.5f);
1207 setSpeed(20.f, 80.f);
1208 setLinearAcceleration(-15.f, -80.f, 15.f, -20.f);
1209 setRadialAcceleration(-20.f, 10.f);
1210 setTangentialAcceleration(-30.f, 30.f);
1211 setEmissionArea("ellipse", 20.f, 8.f);
1212 setParticleSize(10.f, 10.f);
1213 setSizes(1.2f, 0.3f);
1214 setSizeVariation(0.25f);
1215 setSpin(-2.f, 2.f);
1216 setColorStart(1.f, 0.7f, 0.1f, 1.f);
1217 setColorEnd(1.f, 0.1f, 0.f, 0.f);
1218 }
1219}
1220
1221bool ParticleEmitter::applyConfig(const std::string &json) {
1222 return applyConfigText(this, json, nullptr);
1223}
1224
1225void ParticleEmitter::setSoftParticles(bool enabled, float particleDepth, float fadeDistance) {
1226 auto c = config();
1227 c->softParticles = enabled;
1228 c->softParticleDepth = std::clamp(particleDepth, 0.f, 1.f);
1229 c->softFadeDistance = std::max(fadeDistance, 1e-5f);
1230}
1231
1233 if (!config()->softParticles || !gpuSim()->residentActive) return false;
1234 auto* gfx = eve::ModuleManager::getInstance<eve::graphics::Graphics>("Graphics");
1235 return gfx && gfx->getSceneLinearDepthTexture() != nullptr;
1236}
1237
1238bool ParticleEmitter::loadConfig(const std::string &path) {
1239 return loadConfigFile(this, path, nullptr);
1240}
1241
1242bool ParticleEmitter::reloadConfig() { return reloadConfigFile(this, nullptr); }
1243
1244void ParticleEmitter::setAutoReload(bool enable) { resource()->autoReload = enable; }
1245bool ParticleEmitter::getAutoReload() { return resource()->autoReload; }
1246std::string ParticleEmitter::getConfigPath() { return resource()->path; }
1247
1249 auto *const buffer = storage ? storage->getComponentBuffer<Resource>() : nullptr;
1250 if (!buffer || id >= buffer->size()) return "unbound";
1251 switch (buffer->get(id).lastReloadObservation) {
1252 case Resource::ReloadObservation::Unbound: return "unbound";
1253 case Resource::ReloadObservation::AutoReloadDisabled: return "auto_reload_disabled";
1254 case Resource::ReloadObservation::MtimePollingUnchanged: return "mtime_polling_unchanged";
1255 case Resource::ReloadObservation::MtimePollingReloaded: return "mtime_polling_reloaded";
1256 case Resource::ReloadObservation::MtimeUnavailable: return "mtime_unavailable";
1257 case Resource::ReloadObservation::MtimePollingReloadFailed: return "mtime_polling_reload_failed";
1258 }
1259 return "unbound";
1260}
1261
1263 auto a = attach();
1264 clearAttachSources(*a);
1265 a->kind = Attach::Kind::AnimPose;
1266 a->pose = pose;
1267 a->boneIndex = boneIndex;
1268 a->enabled = pose != nullptr && boneIndex >= 0;
1269 if (!a->enabled) a->kind = Attach::Kind::None;
1270 if (a->enabled) syncAttach();
1271}
1272
1274 animation::AnimSkeleton *skeleton,
1275 const std::string &boneName) {
1276 auto a = attach();
1277 a->skeleton = skeleton;
1278 int idx = -1;
1279 if (skeleton) idx = skeleton->findBone(boneName);
1281 // Preserve skeleton pointer for name lookups after attachToBone cleared sources.
1282 attach()->skeleton = skeleton;
1283}
1284
1286 auto a = attach();
1287 clearAttachSources(*a);
1288 a->kind = Attach::Kind::Spine;
1289 a->spine = spine;
1290 a->boneIndex = boneIndex;
1291 a->enabled = spine != nullptr && boneIndex >= 0 && boneIndex < spine->getBoneCount();
1292 if (!a->enabled) a->kind = Attach::Kind::None;
1293 if (a->enabled) syncAttach();
1294}
1295
1297 const std::string &boneName) {
1298 int idx = -1;
1299 if (spine && spine->getData()) idx = spine->getData()->findBone(boneName);
1300 attachToSpineBone(spine, idx);
1301}
1302
1304 auto a = attach();
1305 clearAttachSources(*a);
1306 a->kind = Attach::Kind::Ik2D;
1307 a->ik2d = skeleton;
1308 a->boneIndex = boneId;
1309 a->enabled = skeleton != nullptr && boneId >= 0 && boneId < skeleton->getBoneCount();
1310 if (!a->enabled) a->kind = Attach::Kind::None;
1311 if (a->enabled) syncAttach();
1312}
1313
1315 auto a = attach();
1316 clearAttachSources(*a);
1317 a->kind = Attach::Kind::Ik3D;
1318 a->ik3d = skeleton;
1319 a->boneIndex = boneId;
1320 a->enabled = skeleton != nullptr && boneId >= 0 && boneId < skeleton->getBoneCount();
1321 if (!a->enabled) a->kind = Attach::Kind::None;
1322 if (a->enabled) syncAttach();
1323}
1324
1325void ParticleEmitter::setAttachOffset(float x, float y, float z) {
1326 auto a = attach();
1327 a->offsetX = x;
1328 a->offsetY = y;
1329 a->offsetZ = z;
1330 if (a->enabled) syncAttach();
1331}
1332
1333void ParticleEmitter::setAttachPlane(const std::string &plane) {
1334 attach()->plane = normalizePlane(plane);
1335 if (attach()->enabled) syncAttach();
1336}
1337
1339 attach()->scale = scale;
1340 if (attach()->enabled) syncAttach();
1341}
1342
1344 attach()->followRotation = enable;
1345 if (attach()->enabled) syncAttach();
1346}
1347
1349 clearAttachSources(*attach());
1350}
1351
1352bool ParticleEmitter::isAttached() { return attach()->enabled; }
1353int ParticleEmitter::getAttachBone() { return attach()->boneIndex; }
1354
1356 switch (attach()->kind) {
1358 return "anim";
1360 return "spine";
1361 case Attach::Kind::Ik2D:
1362 return "ik2d";
1363 case Attach::Kind::Ik3D:
1364 return "ik3d";
1365 case Attach::Kind::None:
1366 default:
1367 return "none";
1368 }
1369}
1370
1372 syncEmitterSources(*config(), *sim(), *attach(), *skinSource());
1373}
1374
1376 auto s = skinSource();
1377 s->skin = skin;
1378 s->pose = pose;
1379 s->enabled = skin != nullptr && pose != nullptr;
1380 s->candidatesDirty = true;
1381 s->lastSkinnedFrame = -1;
1382}
1383
1384void ParticleEmitter::setSkinBoneFilter(int skeletonBoneIndex, float minWeight) {
1385 auto s = skinSource();
1386 s->filterBone = skeletonBoneIndex;
1387 s->minWeight = minWeight < 0.f ? 0.f : minWeight;
1388 s->candidatesDirty = true;
1389}
1390
1392 const std::string &boneName, float minWeight) {
1393 auto s = skinSource();
1394 s->skeleton = skeleton;
1395 int idx = -1;
1396 if (skeleton) idx = skeleton->findBone(boneName);
1397 setSkinBoneFilter(idx, minWeight);
1398}
1399
1400void ParticleEmitter::setSkinPlane(const std::string &plane) {
1401 skinSource()->plane = normalizePlane(plane);
1402}
1403
1404void ParticleEmitter::setSkinScale(float scale) { skinSource()->scale = scale; }
1405
1407 auto s = skinSource();
1408 s->enabled = false;
1409 s->skin = nullptr;
1410 s->pose = nullptr;
1411 s->filterBone = -1;
1412 s->candidates.clear();
1413 s->candidatesDirty = true;
1414}
1415
1416bool ParticleEmitter::hasSkinSource() { return skinSource()->enabled; }
1417
1419 if (count <= 0) return;
1420 auto s = skinSource();
1421 if (!s->enabled) return;
1422 auto c = config();
1423 auto simc = sim();
1424 for (int i = 0; i < count; ++i) {
1425 float sx = c->x, sy = c->y;
1426 if (!sampleSkinSpawn(*s, *simc, sx, sy)) break;
1427 spawnParticleAt(*c, *simc, sx, sy);
1428 }
1429}
1430
1431} // namespace eve::particles
LogicalId target
double value
float w
Definition AnimClip.cpp:738
float y
Definition AnimClip.cpp:738
float x
Definition AnimClip.cpp:738
float z
Definition AnimClip.cpp:738
eve::EntitySpatialPose pose
const std::string & s
float degrees
Definition CardTypes.cpp:35
int ax
Definition CaveMesh.cpp:113
int ay
Definition CaveMesh.cpp:113
float nx
float ny
glm::vec4 p[6]
tensor::Graph g
Definition GpuGraph.cpp:7
float camera[2]
std::uint32_t spawned
glm::vec3 n
Definition Grass.cpp:63
double r
float v
std::int32_t c
float blend
int h
std::uint32_t height
std::uint32_t width
TokenKind kind
size_t offset
std::array< float, 3 > scale
std::string name
MeleePoint3 b
Definition MeleeHit.cpp:41
MeleePoint3 a
Definition MeleeHit.cpp:40
float distance
MeshVfxBatchedDraw draw
std::unique_ptr< gpgpu::GpuBuffer > buffer
Definition OnnxGpgpu.cpp:26
TileLayer * layer
int idx
float f
float radius
std::string path
Definition PlayHost.cpp:110
std::uint32_t seed
Definition PointSet.cpp:807
bool hit
float d
float t
Shader * shader
bool found
std::string resource
float dy
float dx
std::uint32_t count
double restitution
bool visible
double oy
double ox
float vz
float wz
float wx
float vy
float qy
float vx
float qx
float qw
float angle
float qz
float wy
Borrowed 2D signed-distance field consumed by particle collision.
Definition ParticleSdf.h:25
virtual ParticleSdfGradientStatus gradient(float x, float y, float &outNx, float &outNy) const =0
Approximate outward unit gradient at a world-space position.
virtual float sample(float x, float y) const =0
Sample signed distance at a world-space particle position.
Evaluated local (and optional world) pose for an AnimSkeleton. Script type: AnimPose.
Definition AnimPose.h:17
3D bone hierarchy + bind-pose local TRS for skeletal animation. Independent of ik::Skeleton3D (FABRIK...
int findBone(const std::string &name) const
Finds bone.
CPU linear-blend skinning binding for one mesh against an AnimSkeleton.
Definition AnimSkin.h:47
float getSkinnedPositionX(int vertexIndex) const
Cached skinned position component (requires updateSkinnedPositions).
Definition AnimSkin.cpp:258
float getSkinnedPositionZ(int vertexIndex) const
Returns the skinned position z.
Definition AnimSkin.cpp:268
float getSkinnedPositionY(int vertexIndex) const
Returns the skinned position y.
Definition AnimSkin.cpp:263
int findBone(const std::string &name) const
Finds bone.
Runtime Spine skeleton pose (local + world bone transforms, slot attachments). Script type: SpineSkel...
int getBoneCount() const
Returns the bone count.
SpineSkeletonData * getData() const
Returns the data.
Declarative 2D camera (viewport center + zoom).
Canvas public API.
Definition Canvas.h:17
Custom GPU program.
Definition Shader.h:39
GPU texture created via Graphics::newTexture. Owns GPU resources through an opaque backend handle.
Definition Texture.h:18
Script-facing 2D skeleton + pose state (ik::skeleton2d + ik::ecs2d). Bone indices are stable after ea...
Definition Skeleton2D.h:14
int getBoneCount() const
Number of bones currently in the skeleton.
Script-facing 3D skeleton + pose state (ik::skeleton3d + ik::ecs3d). Local angles are yaw/pitch in th...
Definition Skeleton3D.h:13
int getBoneCount() const
Number of bones currently in the skeleton.
float sample(float t, float fallback) const
Sample at normalized t; fallback is returned when the curve is empty.
ECS emitter entity. Script configures components; ParticleSimSystem / ParticleRenderSystem drive per-...
void applyPreset(const std::string &name)
Named preset: "spark" / "smoke" / "fire". Unknown → no-op.
std::string getSortMode()
Return the configured transparent ordering policy.
void addSizeCurvePoint(float t, float v)
void setDistortionStrength(float strengthPixels)
Set the maximum scene-color refraction offset in screen pixels.
void setFlipbook(int hframes, int vframes, float framesPerSecond=0.f, float randomStart=0.f)
void addColorStop(float t, float r, float g, float b, float a)
void setSkinBoneFilter(int skeletonBoneIndex, float minWeight=0.f)
void setLimitVelocity(float maxSpeed)
graphics::Texture * getNormalTexture()
Return the configured particle normal map.
void addVelocityCurvePoint(float t, float v)
void setFloatParameter(const std::string &name, float value)
Set a named gameplay/VFX float parameter without rebuilding the emitter.
void attachToSkeleton2D(eve::ik::Skeleton2D *skeleton, int boneId)
void attachToSkeleton3D(eve::ik::Skeleton3D *skeleton, int boneId)
std::string getMotionVectorPolicy()
Return the normalized motion-vector policy.
float getFloatParameter(const std::string &name, float fallback=0.f)
Return a named float parameter, or fallback when absent.
void setSpin(float minSpin, float maxSpin)
eve::IParticleSdfField * getSdfField()
Return the borrowed SDF field, or null. @ownership Borrowed. The emitter does not own the field; do n...
void setTexture(graphics::Texture *texture)
bool isGpuFeatureSetSupported()
Return whether the configured feature set can use resident GPU simulation.
std::string getSimulationBackend()
Return "gpu" for active resident simulation, otherwise "cpu".
void bindFloatParameter(const std::string &name, const std::string &target, float scale=1.f, float offset=0.f)
Bind a parameter to emission, speed, size, or playback scaling.
float getDistortionStrength()
Return the configured scene-color refraction offset in screen pixels.
void clearFloatParameters()
Remove all exposed float values.
void setPosition(float x, float y)
void setRenderMode(const std::string &mode, float stretchFactor=1.f)
void attachToSpineBone(animation::SpineSkeleton *spine, int boneIndex)
void setNormalTexture(graphics::Texture *texture)
Set an optional tangent-space normal map for lit particles.
static ParticleEmitter * createEmitter(int bufferSize=1000)
void setMaterialMode(const std::string &mode)
Select particle shading: "unlit", "lit", or scene-color "distortion".
void setSkinBoneFilterByName(animation::AnimSkeleton *skeleton, const std::string &boneName, float minWeight=0.f)
bool loadConfig(const std::string &path)
void setCollisionBounds(bool enabled, float minX, float minY, float maxX, float maxY)
std::string getAttachKind()
"none" | "anim" | "spine" | "ik2d" | "ik3d"
bool isSoftParticlesActive()
True when soft particles have both a resident renderer and scene depth.
void setSkinPlane(const std::string &plane)
void setSizes(float startScale, float endScale)
void setRenderAxis(float degrees)
Set the fixed screen-space orientation used by render mode "axis".
void setRadialAcceleration(float minA, float maxA)
bool isGpuSimulationActive()
Return true after this emitter has migrated to a resident GPU backend.
void setSpeed(float minSpeed, float maxSpeed)
void setBlendMode(const std::string &mode)
void addForceField(float x, float y, float radius, float strength, float falloff=1.f)
void setCanvas(graphics::Canvas *canvas)
void attachToBone(animation::AnimPose *pose, int boneIndex)
void setOverflowMode(const std::string &mode)
void setStartRotation(float minDeg, float maxDeg)
void setCamera(graphics::Camera2D *camera)
float getResolvedParameterScale(const std::string &target)
Return the resolved non-negative multiplier for a supported target.
void setLinearAcceleration(float xmin, float ymin, float xmax, float ymax)
void emitFromSkin(int count)
Burst-emit count particles from the current skinned surface.
std::string getGpuFallbackReason()
Explain CPU fallback; empty means GPU active, "pending_activation" means eligible.
void syncAttach()
Sync Config.x/y (and direction) from the attached bone. Also called by ParticleSimSystem.
void setTangentialAcceleration(float minA, float maxA)
bool hasFloatParameter(const std::string &name)
Return whether a named float parameter exists.
bool applyConfig(const std::string &json)
void setSkinSource(animation::AnimSkin *skin, animation::AnimPose *pose)
void addRotationCurvePoint(float t, float v)
void setSoftParticles(bool enabled, float particleDepth=0.5f, float fadeDistance=0.05f)
Fade resident particles where they intersect sampled scene depth.
std::string getConfigReloadObservation() const
Return the explicit config reload observation state.
void setCollision(const std::string &mode, float radius=0.f, float restitution=0.6f, float lifetimeLoss=0.f)
void addSubEmitter(ParticleEmitter *target, const std::string &trigger, float inheritVelocity=0.f)
void setColorEnd(float r, float g, float b, float a=1.f)
std::string getMaterialMode()
Return the normalized particle shading mode.
void setEmissionArea(const std::string &type, float x, float y)
void setColorStart(float r, float g, float b, float a=1.f)
void setNoise(float strength, float frequency=1.f, float speed=1.f)
void setSortMode(const std::string &mode)
Select stable per-emitter transparent ordering.
void attachToSpineBoneByName(animation::SpineSkeleton *spine, const std::string &boneName)
void setShader(graphics::Shader *shader)
void setMotionVectorPolicy(const std::string &policy)
Set motion-vector policy: none, velocity, or spawn_delta.
void clearFloatParameterBindings()
Remove all parameter-to-runtime bindings.
void setSizeVariation(float variation)
void attachToBoneByName(animation::AnimPose *pose, animation::AnimSkeleton *skeleton, const std::string &boneName)
void setSimulationSpace(const std::string &space)
void setAttachOffset(float x, float y, float z)
void setLights(bool enabled, float radius=120.f, float intensity=1.f, float r=1.f, float g=1.f, float b=1.f, int maxLights=4)
bool isMotionVectorActive()
True when a graphics path is actually writing motion vectors.
void setRibbon(float width=1.f, float minSegmentLength=1.f)
Configure connected ribbon rendering and switch to ribbon mode.
void setParticleLifetime(float minLife, float maxLife)
void addBurst(float time, int count)
void setAttachPlane(const std::string &plane)
void setSdfField(eve::IParticleSdfField *field)
Bind a borrowed SDF field for particle collision sampling.
void setInheritVelocity(float fraction)
void setGravity(float x, float y)
void setParticleSize(float width, float height)
bool spawnParticle(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim)
Spawn particle.
void setWorldCollisionResolver(WorldCollisionFn fn)
Sets the world collision resolver.
bool loadConfigFile(ParticleEmitter *emitter, const std::string &path, std::string *error)
Read path via Filesystem, apply, and bind Resource.path + modtime for hot reload. Returns false if fi...
bool(*)(float x, float y, float radius, float &nx, float &ny) WorldCollisionFn
World collision query used by emitters with worldCollision enabled.
bool reloadConfigFile(ParticleEmitter *emitter, std::string *error)
Re-read Resource.path if set; updates modtime.
void stepEmitterSim(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, float dt)
Step emitter sim.
bool applyConfigText(ParticleEmitter *emitter, const std::string &json, std::string *error)
Parse JSON text and apply.
WorldCollisionFn getWorldCollisionResolver()
Returns the world collision resolver.
bool sampleSkinSpawn(ParticleEmitter::SkinSource &skinSrc, ParticleEmitter::Sim &sim, float &outX, float &outY)
Sample skin spawn.
void syncEmitterSources(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &, ParticleEmitter::Attach &attach, ParticleEmitter::SkinSource &skinSrc)
Sync bone attach + refresh skin cache; call before stepEmitterSim when using Attach/SkinSource.
bool spawnParticleAt(ParticleEmitter::Config &cfg, ParticleEmitter::Sim &sim, float x, float y)
Spawn particle at.
glm::vec4 Color
Render-neutral RGBA color shared by graphics-facing modules.
Definition RenderTypes.h:8
@ Defined
Finite outward unit gradient written to the output components.
SettlementPipeline::Stage fn
bool enabled
static Mat4 fromTRS(const TransformTRS &t)
From trs.
Definition AnimMath.h:163
void transformPoint(float x, float y, float z, float &ox, float &oy, float &oz) const
Transform point.
Definition AnimMath.h:205
Optional bone attachment. When enabled, syncAttach() writes Config.x/y (and optionally direction) fro...
Timed burst emission (fired once while the emitter is active).
Radial attract/repel force fields (strength > 0 attract, < 0 repel).
Script-linked sub-emitters (birth / death / collision triggers).
float limitVelocity
Max speed; 0 = unlimited. Applied after forces each step.
float maxDeltaTime
Cap per-step delta time (0 = unlimited).
float noiseStrength
Turbulence: random per-particle acceleration scaled by strength.
float resolvedParameterScale(const std::string &target) const
Resolved parameter scale.
eve::IParticleSdfField * sdfField
Borrowed 2D SDF used as an additional collision solid. @ownership Borrowed. Cleared by the caller bef...
std::string simSpace
"world" (default) or "local" (particles track the emitter).
std::string overflowMode
Buffer-full strategy: "drop" (default) | "pause" | "warn".
ParticleCurve velocityCurve
Optional speed multiplier curve over lifetime.
float emissionRateOverDistance
Distance-based emission in particles per world unit (0 = disabled).
float inheritVelocity
Fraction [0,1] of the emitter's current velocity added to new particles.
float damping
Per-second velocity damping fraction in [0,1].
bool looping
Restart the emitter timeline after emitterLife instead of stopping.
float gravityX
Gravity applied every step (world units/s²).
bool worldCollision
Query the engine-level world collision resolver each step.
std::string collisionMode
"none" | "kill" | "bounce" | "stop" on collision.
Bound config file for hot reload (empty path = unbound).
int spawnQuota
Temporary automatic-spawn quota set by ParticleSimSystem (-1 = unlimited).
Optional skinned-mesh surface source. When enabled, newly spawned particles sample random (optionally...
std::vector< int > candidates
Vertex indices eligible for sampling (rebuilt when filter changes).
Single live particle (CPU simulation).