feat(particle): 添加完整粒子系统和粒子编辑器 (#284)
* feat(editor-core): 添加用户系统自动注册功能 - IUserCodeService 新增 registerSystems/unregisterSystems/getRegisteredSystems 方法 - UserCodeService 实现系统检测、实例化和场景注册逻辑 - ServiceRegistry 在预览开始时注册用户系统,停止时移除 - 热更新时自动重新加载用户系统 - 更新 System 脚本模板添加 @ECSSystem 装饰器 * feat(editor-core): 添加编辑器脚本支持(Inspector/Gizmo) - registerEditorExtensions 实际注册用户 Inspector 和 Gizmo - 添加 unregisterEditorExtensions 方法 - ServiceRegistry 在项目加载时编译并加载编辑器脚本 - 项目关闭时自动清理编辑器扩展 - 添加 Inspector 和 Gizmo 脚本创建模板 * feat(particle): 添加粒子系统和粒子编辑器 新增两个包: - @esengine/particle: 粒子系统核心库 - @esengine/particle-editor: 粒子编辑器 UI 粒子系统功能: - ECS 组件架构,支持播放/暂停/重置控制 - 7种发射形状:点、圆、环、矩形、边缘、线、锥形 - 5个动画模块:颜色渐变、缩放曲线、速度控制、旋转、噪声 - 纹理动画模块支持精灵表动画 - 3种混合模式:Normal、Additive、Multiply - 11个内置预设:火焰、烟雾、爆炸、雨、雪等 - 对象池优化,支持粒子复用 编辑器功能: - 实时 Canvas 预览,支持全屏和鼠标跟随 - 点击触发爆发效果(用于测试爆炸类特效) - 渐变编辑器:可视化颜色关键帧编辑 - 曲线编辑器:支持缩放曲线和缓动函数 - 预设浏览器:快速应用内置预设 - 模块开关:独立启用/禁用各个模块 - Vector2 样式输入(重力 X/Y) * feat(particle): 完善粒子系统核心功能 1. Burst 定时爆发系统 - BurstConfig 接口支持时间、数量、循环次数、间隔 - 运行时自动处理定时爆发 - 支持无限循环爆发 2. 速度曲线模块 (VelocityOverLifetimeModule) - 6种曲线类型:Constant、Linear、EaseIn、EaseOut、EaseInOut、Custom - 自定义关键帧曲线支持 - 附加速度 X/Y - 轨道速度和径向速度 3. 碰撞边界模块 (CollisionModule) - 矩形和圆形边界类型 - 3种碰撞行为:Kill、Bounce、Wrap - 反弹系数和最小速度阈值 - 反弹时生命损失 * feat(particle): 添加力场模块、碰撞模块和世界/本地空间支持 - 新增 ForceFieldModule 支持风力、吸引点、漩涡、湍流四种力场类型 - 新增 SimulationSpace 枚举支持世界空间和本地空间切换 - ParticleSystemComponent 集成力场模块和空间模式 - 粒子编辑器添加 Collision 和 ForceField 模块的 UI 编辑支持 - 新增 Vortex、Leaves、Bouncing 三个预设展示新功能 - 编辑器预览实现完整的碰撞和力场效果 * fix(particle): 移除未使用的 transform 循环变量
This commit is contained in:
222
packages/particle/src/modules/CollisionModule.ts
Normal file
222
packages/particle/src/modules/CollisionModule.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 边界类型
|
||||
* Boundary type
|
||||
*/
|
||||
export enum BoundaryType {
|
||||
/** 无边界 | No boundary */
|
||||
None = 'none',
|
||||
/** 矩形边界 | Rectangle boundary */
|
||||
Rectangle = 'rectangle',
|
||||
/** 圆形边界 | Circle boundary */
|
||||
Circle = 'circle'
|
||||
}
|
||||
|
||||
/**
|
||||
* 碰撞行为
|
||||
* Collision behavior
|
||||
*/
|
||||
export enum CollisionBehavior {
|
||||
/** 销毁粒子 | Kill particle */
|
||||
Kill = 'kill',
|
||||
/** 反弹 | Bounce */
|
||||
Bounce = 'bounce',
|
||||
/** 环绕(从另一边出现)| Wrap (appear on opposite side) */
|
||||
Wrap = 'wrap'
|
||||
}
|
||||
|
||||
/**
|
||||
* 碰撞模块
|
||||
* Collision module for particle boundaries
|
||||
*/
|
||||
export class CollisionModule implements IParticleModule {
|
||||
readonly name = 'Collision';
|
||||
enabled = true;
|
||||
|
||||
// ============= 边界设置 | Boundary Settings =============
|
||||
|
||||
/** 边界类型 | Boundary type */
|
||||
boundaryType: BoundaryType = BoundaryType.Rectangle;
|
||||
|
||||
/** 碰撞行为 | Collision behavior */
|
||||
behavior: CollisionBehavior = CollisionBehavior.Kill;
|
||||
|
||||
// ============= 矩形边界 | Rectangle Boundary =============
|
||||
|
||||
/** 左边界(相对于发射器)| Left boundary (relative to emitter) */
|
||||
left: number = -200;
|
||||
|
||||
/** 右边界(相对于发射器)| Right boundary (relative to emitter) */
|
||||
right: number = 200;
|
||||
|
||||
/** 上边界(相对于发射器)| Top boundary (relative to emitter) */
|
||||
top: number = -200;
|
||||
|
||||
/** 下边界(相对于发射器)| Bottom boundary (relative to emitter) */
|
||||
bottom: number = 200;
|
||||
|
||||
// ============= 圆形边界 | Circle Boundary =============
|
||||
|
||||
/** 圆形边界半径 | Circle boundary radius */
|
||||
radius: number = 200;
|
||||
|
||||
// ============= 反弹设置 | Bounce Settings =============
|
||||
|
||||
/** 反弹系数 (0-1),1 = 完全弹性 | Bounce factor (0-1), 1 = fully elastic */
|
||||
bounceFactor: number = 0.8;
|
||||
|
||||
/** 最小速度阈值(低于此速度时销毁)| Min velocity threshold (kill if below) */
|
||||
minVelocityThreshold: number = 5;
|
||||
|
||||
/** 反弹时的生命损失 (0-1) | Life loss on bounce (0-1) */
|
||||
lifeLossOnBounce: number = 0;
|
||||
|
||||
// ============= 发射器位置(运行时设置)| Emitter Position (set at runtime) =============
|
||||
|
||||
/** 发射器 X 坐标 | Emitter X position */
|
||||
emitterX: number = 0;
|
||||
|
||||
/** 发射器 Y 坐标 | Emitter Y position */
|
||||
emitterY: number = 0;
|
||||
|
||||
/** 粒子死亡标记数组 | Particle death flag array */
|
||||
private _particlesToKill: Set<Particle> = new Set();
|
||||
|
||||
/**
|
||||
* 获取需要销毁的粒子
|
||||
* Get particles to kill
|
||||
*/
|
||||
getParticlesToKill(): Set<Particle> {
|
||||
return this._particlesToKill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除死亡标记
|
||||
* Clear death flags
|
||||
*/
|
||||
clearDeathFlags(): void {
|
||||
this._particlesToKill.clear();
|
||||
}
|
||||
|
||||
update(p: Particle, _dt: number, _normalizedAge: number): void {
|
||||
if (this.boundaryType === BoundaryType.None) return;
|
||||
|
||||
// 计算相对于发射器的位置 | Calculate position relative to emitter
|
||||
const relX = p.x - this.emitterX;
|
||||
const relY = p.y - this.emitterY;
|
||||
|
||||
let collision = false;
|
||||
let normalX = 0;
|
||||
let normalY = 0;
|
||||
|
||||
if (this.boundaryType === BoundaryType.Rectangle) {
|
||||
// 矩形边界检测 | Rectangle boundary detection
|
||||
if (relX < this.left) {
|
||||
collision = true;
|
||||
normalX = 1;
|
||||
if (this.behavior === CollisionBehavior.Wrap) {
|
||||
p.x = this.emitterX + this.right;
|
||||
} else if (this.behavior === CollisionBehavior.Bounce) {
|
||||
p.x = this.emitterX + this.left;
|
||||
}
|
||||
} else if (relX > this.right) {
|
||||
collision = true;
|
||||
normalX = -1;
|
||||
if (this.behavior === CollisionBehavior.Wrap) {
|
||||
p.x = this.emitterX + this.left;
|
||||
} else if (this.behavior === CollisionBehavior.Bounce) {
|
||||
p.x = this.emitterX + this.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (relY < this.top) {
|
||||
collision = true;
|
||||
normalY = 1;
|
||||
if (this.behavior === CollisionBehavior.Wrap) {
|
||||
p.y = this.emitterY + this.bottom;
|
||||
} else if (this.behavior === CollisionBehavior.Bounce) {
|
||||
p.y = this.emitterY + this.top;
|
||||
}
|
||||
} else if (relY > this.bottom) {
|
||||
collision = true;
|
||||
normalY = -1;
|
||||
if (this.behavior === CollisionBehavior.Wrap) {
|
||||
p.y = this.emitterY + this.top;
|
||||
} else if (this.behavior === CollisionBehavior.Bounce) {
|
||||
p.y = this.emitterY + this.bottom;
|
||||
}
|
||||
}
|
||||
} else if (this.boundaryType === BoundaryType.Circle) {
|
||||
// 圆形边界检测 | Circle boundary detection
|
||||
const dist = Math.sqrt(relX * relX + relY * relY);
|
||||
if (dist > this.radius) {
|
||||
collision = true;
|
||||
if (dist > 0.001) {
|
||||
normalX = -relX / dist;
|
||||
normalY = -relY / dist;
|
||||
}
|
||||
|
||||
if (this.behavior === CollisionBehavior.Wrap) {
|
||||
// 移动到对面 | Move to opposite side
|
||||
p.x = this.emitterX - relX * (this.radius / dist) * 0.9;
|
||||
p.y = this.emitterY - relY * (this.radius / dist) * 0.9;
|
||||
} else if (this.behavior === CollisionBehavior.Bounce) {
|
||||
// 移回边界内 | Move back inside boundary
|
||||
p.x = this.emitterX + relX * (this.radius / dist) * 0.99;
|
||||
p.y = this.emitterY + relY * (this.radius / dist) * 0.99;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (collision) {
|
||||
switch (this.behavior) {
|
||||
case CollisionBehavior.Kill:
|
||||
this._particlesToKill.add(p);
|
||||
break;
|
||||
|
||||
case CollisionBehavior.Bounce:
|
||||
this._applyBounce(p, normalX, normalY);
|
||||
break;
|
||||
|
||||
case CollisionBehavior.Wrap:
|
||||
// 位置已经在上面处理 | Position already handled above
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用反弹
|
||||
* Apply bounce effect
|
||||
*/
|
||||
private _applyBounce(p: Particle, normalX: number, normalY: number): void {
|
||||
// 反弹速度计算 | Calculate bounce velocity
|
||||
if (normalX !== 0) {
|
||||
p.vx = -p.vx * this.bounceFactor;
|
||||
}
|
||||
if (normalY !== 0) {
|
||||
p.vy = -p.vy * this.bounceFactor;
|
||||
}
|
||||
|
||||
// 更新存储的初始速度(如果有速度曲线模块)| Update stored initial velocity
|
||||
if ('startVx' in p && normalX !== 0) {
|
||||
(p as any).startVx = -((p as any).startVx) * this.bounceFactor;
|
||||
}
|
||||
if ('startVy' in p && normalY !== 0) {
|
||||
(p as any).startVy = -((p as any).startVy) * this.bounceFactor;
|
||||
}
|
||||
|
||||
// 应用生命损失 | Apply life loss
|
||||
if (this.lifeLossOnBounce > 0) {
|
||||
p.lifetime *= (1 - this.lifeLossOnBounce);
|
||||
}
|
||||
|
||||
// 检查最小速度 | Check minimum velocity
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
if (speed < this.minVelocityThreshold) {
|
||||
this._particlesToKill.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
packages/particle/src/modules/ColorOverLifetimeModule.ts
Normal file
63
packages/particle/src/modules/ColorOverLifetimeModule.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 颜色关键帧
|
||||
* Color keyframe
|
||||
*/
|
||||
export interface ColorKey {
|
||||
/** 时间点 (0-1) | Time (0-1) */
|
||||
time: number;
|
||||
/** 颜色R (0-1) | Color R (0-1) */
|
||||
r: number;
|
||||
/** 颜色G (0-1) | Color G (0-1) */
|
||||
g: number;
|
||||
/** 颜色B (0-1) | Color B (0-1) */
|
||||
b: number;
|
||||
/** 透明度 (0-1) | Alpha (0-1) */
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色随生命周期变化模块
|
||||
* Color over lifetime module
|
||||
*/
|
||||
export class ColorOverLifetimeModule implements IParticleModule {
|
||||
readonly name = 'ColorOverLifetime';
|
||||
enabled = true;
|
||||
|
||||
/** 颜色渐变关键帧 | Color gradient keyframes */
|
||||
gradient: ColorKey[] = [
|
||||
{ time: 0, r: 1, g: 1, b: 1, a: 1 },
|
||||
{ time: 1, r: 1, g: 1, b: 1, a: 0 }
|
||||
];
|
||||
|
||||
update(p: Particle, _dt: number, normalizedAge: number): void {
|
||||
if (this.gradient.length === 0) return;
|
||||
|
||||
// 找到当前时间点的两个关键帧 | Find the two keyframes around current time
|
||||
let startKey = this.gradient[0];
|
||||
let endKey = this.gradient[this.gradient.length - 1];
|
||||
|
||||
for (let i = 0; i < this.gradient.length - 1; i++) {
|
||||
if (normalizedAge >= this.gradient[i].time && normalizedAge <= this.gradient[i + 1].time) {
|
||||
startKey = this.gradient[i];
|
||||
endKey = this.gradient[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 在两个关键帧之间插值 | Interpolate between keyframes
|
||||
const range = endKey.time - startKey.time;
|
||||
const t = range > 0 ? (normalizedAge - startKey.time) / range : 0;
|
||||
|
||||
p.r = p.startR * lerp(startKey.r, endKey.r, t);
|
||||
p.g = p.startG * lerp(startKey.g, endKey.g, t);
|
||||
p.b = p.startB * lerp(startKey.b, endKey.b, t);
|
||||
p.alpha = p.startAlpha * lerp(startKey.a, endKey.a, t);
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
305
packages/particle/src/modules/ForceFieldModule.ts
Normal file
305
packages/particle/src/modules/ForceFieldModule.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 力场类型
|
||||
* Force field type
|
||||
*/
|
||||
export enum ForceFieldType {
|
||||
/** 风力(方向性力)| Wind (directional force) */
|
||||
Wind = 'wind',
|
||||
/** 吸引/排斥点 | Attraction/repulsion point */
|
||||
Point = 'point',
|
||||
/** 漩涡 | Vortex */
|
||||
Vortex = 'vortex',
|
||||
/** 湍流 | Turbulence */
|
||||
Turbulence = 'turbulence'
|
||||
}
|
||||
|
||||
/**
|
||||
* 力场配置
|
||||
* Force field configuration
|
||||
*/
|
||||
export interface ForceField {
|
||||
/** 力场类型 | Force field type */
|
||||
type: ForceFieldType;
|
||||
/** 启用 | Enabled */
|
||||
enabled: boolean;
|
||||
/** 强度 | Strength */
|
||||
strength: number;
|
||||
|
||||
// 风力参数 | Wind parameters
|
||||
/** 风向 X | Wind direction X */
|
||||
directionX?: number;
|
||||
/** 风向 Y | Wind direction Y */
|
||||
directionY?: number;
|
||||
|
||||
// 点力场参数 | Point force parameters
|
||||
/** 力场位置 X(相对于发射器)| Position X (relative to emitter) */
|
||||
positionX?: number;
|
||||
/** 力场位置 Y(相对于发射器)| Position Y (relative to emitter) */
|
||||
positionY?: number;
|
||||
/** 影响半径 | Influence radius */
|
||||
radius?: number;
|
||||
/** 衰减类型 | Falloff type */
|
||||
falloff?: 'none' | 'linear' | 'quadratic';
|
||||
|
||||
// 漩涡参数 | Vortex parameters
|
||||
/** 漩涡轴心 X | Vortex center X */
|
||||
centerX?: number;
|
||||
/** 漩涡轴心 Y | Vortex center Y */
|
||||
centerY?: number;
|
||||
/** 向内拉力 | Inward pull strength */
|
||||
inwardStrength?: number;
|
||||
|
||||
// 湍流参数 | Turbulence parameters
|
||||
/** 湍流频率 | Turbulence frequency */
|
||||
frequency?: number;
|
||||
/** 湍流振幅 | Turbulence amplitude */
|
||||
amplitude?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认力场配置
|
||||
* Create default force field
|
||||
*/
|
||||
export function createDefaultForceField(type: ForceFieldType): ForceField {
|
||||
const base = {
|
||||
type,
|
||||
enabled: true,
|
||||
strength: 100,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case ForceFieldType.Wind:
|
||||
return { ...base, directionX: 1, directionY: 0 };
|
||||
case ForceFieldType.Point:
|
||||
return { ...base, positionX: 0, positionY: 0, radius: 100, falloff: 'linear' as const };
|
||||
case ForceFieldType.Vortex:
|
||||
return { ...base, centerX: 0, centerY: 0, inwardStrength: 0 };
|
||||
case ForceFieldType.Turbulence:
|
||||
return { ...base, frequency: 1, amplitude: 50 };
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 力场模块
|
||||
* Force field module for applying external forces to particles
|
||||
*/
|
||||
export class ForceFieldModule implements IParticleModule {
|
||||
readonly name = 'ForceField';
|
||||
enabled = true;
|
||||
|
||||
/** 力场列表 | Force field list */
|
||||
forceFields: ForceField[] = [];
|
||||
|
||||
/** 发射器位置(运行时设置)| Emitter position (set at runtime) */
|
||||
emitterX: number = 0;
|
||||
emitterY: number = 0;
|
||||
|
||||
/** 时间累计(用于湍流)| Time accumulator (for turbulence) */
|
||||
private _time: number = 0;
|
||||
|
||||
/**
|
||||
* 添加力场
|
||||
* Add force field
|
||||
*/
|
||||
addForceField(field: ForceField): void {
|
||||
this.forceFields.push(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加风力
|
||||
* Add wind force
|
||||
*/
|
||||
addWind(directionX: number, directionY: number, strength: number = 100): ForceField {
|
||||
const field: ForceField = {
|
||||
type: ForceFieldType.Wind,
|
||||
enabled: true,
|
||||
strength,
|
||||
directionX,
|
||||
directionY,
|
||||
};
|
||||
this.forceFields.push(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加吸引/排斥点
|
||||
* Add attraction/repulsion point
|
||||
*/
|
||||
addAttractor(x: number, y: number, strength: number = 100, radius: number = 100): ForceField {
|
||||
const field: ForceField = {
|
||||
type: ForceFieldType.Point,
|
||||
enabled: true,
|
||||
strength, // 正数=吸引,负数=排斥 | positive=attract, negative=repel
|
||||
positionX: x,
|
||||
positionY: y,
|
||||
radius,
|
||||
falloff: 'linear',
|
||||
};
|
||||
this.forceFields.push(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加漩涡
|
||||
* Add vortex
|
||||
*/
|
||||
addVortex(x: number, y: number, strength: number = 100, inwardStrength: number = 0): ForceField {
|
||||
const field: ForceField = {
|
||||
type: ForceFieldType.Vortex,
|
||||
enabled: true,
|
||||
strength,
|
||||
centerX: x,
|
||||
centerY: y,
|
||||
inwardStrength,
|
||||
};
|
||||
this.forceFields.push(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加湍流
|
||||
* Add turbulence
|
||||
*/
|
||||
addTurbulence(strength: number = 50, frequency: number = 1): ForceField {
|
||||
const field: ForceField = {
|
||||
type: ForceFieldType.Turbulence,
|
||||
enabled: true,
|
||||
strength,
|
||||
frequency,
|
||||
amplitude: strength,
|
||||
};
|
||||
this.forceFields.push(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有力场
|
||||
* Clear all force fields
|
||||
*/
|
||||
clearForceFields(): void {
|
||||
this.forceFields = [];
|
||||
}
|
||||
|
||||
update(p: Particle, dt: number, _normalizedAge: number): void {
|
||||
this._time += dt;
|
||||
|
||||
for (const field of this.forceFields) {
|
||||
if (!field.enabled) continue;
|
||||
|
||||
switch (field.type) {
|
||||
case ForceFieldType.Wind:
|
||||
this._applyWind(p, field, dt);
|
||||
break;
|
||||
case ForceFieldType.Point:
|
||||
this._applyPointForce(p, field, dt);
|
||||
break;
|
||||
case ForceFieldType.Vortex:
|
||||
this._applyVortex(p, field, dt);
|
||||
break;
|
||||
case ForceFieldType.Turbulence:
|
||||
this._applyTurbulence(p, field, dt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用风力
|
||||
* Apply wind force
|
||||
*/
|
||||
private _applyWind(p: Particle, field: ForceField, dt: number): void {
|
||||
const dx = field.directionX ?? 1;
|
||||
const dy = field.directionY ?? 0;
|
||||
// 归一化方向 | Normalize direction
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len > 0.001) {
|
||||
p.vx += (dx / len) * field.strength * dt;
|
||||
p.vy += (dy / len) * field.strength * dt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用点力场(吸引/排斥)
|
||||
* Apply point force (attraction/repulsion)
|
||||
*/
|
||||
private _applyPointForce(p: Particle, field: ForceField, dt: number): void {
|
||||
const fieldX = this.emitterX + (field.positionX ?? 0);
|
||||
const fieldY = this.emitterY + (field.positionY ?? 0);
|
||||
const radius = field.radius ?? 100;
|
||||
|
||||
const dx = fieldX - p.x;
|
||||
const dy = fieldY - p.y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const dist = Math.sqrt(distSq);
|
||||
|
||||
if (dist < 0.001 || dist > radius) return;
|
||||
|
||||
// 计算衰减 | Calculate falloff
|
||||
let falloffFactor = 1;
|
||||
switch (field.falloff) {
|
||||
case 'linear':
|
||||
falloffFactor = 1 - dist / radius;
|
||||
break;
|
||||
case 'quadratic':
|
||||
falloffFactor = 1 - (distSq / (radius * radius));
|
||||
break;
|
||||
// 'none' - no falloff
|
||||
}
|
||||
|
||||
// 归一化方向并应用力 | Normalize direction and apply force
|
||||
const force = field.strength * falloffFactor * dt;
|
||||
p.vx += (dx / dist) * force;
|
||||
p.vy += (dy / dist) * force;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用漩涡力
|
||||
* Apply vortex force
|
||||
*/
|
||||
private _applyVortex(p: Particle, field: ForceField, dt: number): void {
|
||||
const centerX = this.emitterX + (field.centerX ?? 0);
|
||||
const centerY = this.emitterY + (field.centerY ?? 0);
|
||||
|
||||
const dx = p.x - centerX;
|
||||
const dy = p.y - centerY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 0.001) return;
|
||||
|
||||
// 切向力(旋转)| Tangential force (rotation)
|
||||
const tangentX = -dy / dist;
|
||||
const tangentY = dx / dist;
|
||||
p.vx += tangentX * field.strength * dt;
|
||||
p.vy += tangentY * field.strength * dt;
|
||||
|
||||
// 向心力(可选)| Centripetal force (optional)
|
||||
const inward = field.inwardStrength ?? 0;
|
||||
if (inward !== 0) {
|
||||
p.vx -= (dx / dist) * inward * dt;
|
||||
p.vy -= (dy / dist) * inward * dt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用湍流
|
||||
* Apply turbulence
|
||||
*/
|
||||
private _applyTurbulence(p: Particle, field: ForceField, dt: number): void {
|
||||
const freq = field.frequency ?? 1;
|
||||
const amp = field.amplitude ?? field.strength;
|
||||
|
||||
// 使用简单的正弦波噪声 | Use simple sine wave noise
|
||||
const noiseX = Math.sin(p.x * freq * 0.01 + this._time * freq) *
|
||||
Math.cos(p.y * freq * 0.013 + this._time * freq * 0.7);
|
||||
const noiseY = Math.cos(p.x * freq * 0.011 + this._time * freq * 0.8) *
|
||||
Math.sin(p.y * freq * 0.01 + this._time * freq);
|
||||
|
||||
p.vx += noiseX * amp * dt;
|
||||
p.vy += noiseY * amp * dt;
|
||||
}
|
||||
}
|
||||
26
packages/particle/src/modules/IParticleModule.ts
Normal file
26
packages/particle/src/modules/IParticleModule.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Particle } from '../Particle';
|
||||
|
||||
/**
|
||||
* 粒子模块接口
|
||||
* Particle module interface
|
||||
*
|
||||
* Modules modify particle properties over their lifetime.
|
||||
* 模块在粒子生命周期内修改粒子属性。
|
||||
*/
|
||||
export interface IParticleModule {
|
||||
/** 模块名称 | Module name */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否启用 | Whether enabled */
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* 更新粒子
|
||||
* Update particle
|
||||
*
|
||||
* @param p - Particle to update
|
||||
* @param dt - Delta time in seconds
|
||||
* @param normalizedAge - Age / Lifetime (0-1)
|
||||
*/
|
||||
update(p: Particle, dt: number, normalizedAge: number): void;
|
||||
}
|
||||
100
packages/particle/src/modules/NoiseModule.ts
Normal file
100
packages/particle/src/modules/NoiseModule.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 值噪声哈希函数
|
||||
* Value noise hash function
|
||||
*
|
||||
* 使用经典的整数哈希算法生成伪随机值
|
||||
* Uses classic integer hash algorithm to generate pseudo-random values
|
||||
*/
|
||||
export function noiseHash(x: number, y: number): number {
|
||||
const n = x + y * 57;
|
||||
const shifted = (n << 13) ^ n;
|
||||
return ((shifted * (shifted * shifted * 15731 + 789221) + 1376312589) & 0x7fffffff) / 0x7fffffff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D 值噪声函数
|
||||
* 2D value noise function
|
||||
*
|
||||
* 基于双线性插值的简化值噪声实现,返回 [-1, 1] 范围的值
|
||||
* Simplified value noise using bilinear interpolation, returns value in [-1, 1] range
|
||||
*/
|
||||
export function valueNoise2D(x: number, y: number): number {
|
||||
const ix = Math.floor(x);
|
||||
const iy = Math.floor(y);
|
||||
const fx = x - ix;
|
||||
const fy = y - iy;
|
||||
|
||||
const n00 = noiseHash(ix, iy);
|
||||
const n10 = noiseHash(ix + 1, iy);
|
||||
const n01 = noiseHash(ix, iy + 1);
|
||||
const n11 = noiseHash(ix + 1, iy + 1);
|
||||
|
||||
// 双线性插值 | Bilinear interpolation
|
||||
const nx0 = n00 + (n10 - n00) * fx;
|
||||
const nx1 = n01 + (n11 - n01) * fx;
|
||||
return (nx0 + (nx1 - nx0) * fy) * 2 - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 噪声模块 - 添加随机扰动
|
||||
* Noise module - adds random perturbation
|
||||
*/
|
||||
export class NoiseModule implements IParticleModule {
|
||||
readonly name = 'Noise';
|
||||
enabled = true;
|
||||
|
||||
/** 位置噪声强度 | Position noise strength */
|
||||
positionAmount: number = 0;
|
||||
|
||||
/** 速度噪声强度 | Velocity noise strength */
|
||||
velocityAmount: number = 0;
|
||||
|
||||
/** 旋转噪声强度 | Rotation noise strength */
|
||||
rotationAmount: number = 0;
|
||||
|
||||
/** 缩放噪声强度 | Scale noise strength */
|
||||
scaleAmount: number = 0;
|
||||
|
||||
/** 噪声频率 | Noise frequency */
|
||||
frequency: number = 1;
|
||||
|
||||
/** 噪声滚动速度 | Noise scroll speed */
|
||||
scrollSpeed: number = 1;
|
||||
|
||||
private _time: number = 0;
|
||||
|
||||
update(p: Particle, dt: number, _normalizedAge: number): void {
|
||||
this._time += dt * this.scrollSpeed;
|
||||
|
||||
// 基于粒子位置和时间的噪声 | Noise based on particle position and time
|
||||
const noiseX = valueNoise2D(p.x * this.frequency + this._time, p.y * this.frequency);
|
||||
const noiseY = valueNoise2D(p.x * this.frequency, p.y * this.frequency + this._time);
|
||||
|
||||
// 位置噪声 | Position noise
|
||||
if (this.positionAmount !== 0) {
|
||||
p.x += noiseX * this.positionAmount * dt;
|
||||
p.y += noiseY * this.positionAmount * dt;
|
||||
}
|
||||
|
||||
// 速度噪声 | Velocity noise
|
||||
if (this.velocityAmount !== 0) {
|
||||
p.vx += noiseX * this.velocityAmount * dt;
|
||||
p.vy += noiseY * this.velocityAmount * dt;
|
||||
}
|
||||
|
||||
// 旋转噪声 | Rotation noise
|
||||
if (this.rotationAmount !== 0) {
|
||||
p.rotation += noiseX * this.rotationAmount * dt;
|
||||
}
|
||||
|
||||
// 缩放噪声 | Scale noise
|
||||
if (this.scaleAmount !== 0) {
|
||||
const scaleDelta = noiseX * this.scaleAmount * dt;
|
||||
p.scaleX += scaleDelta;
|
||||
p.scaleY += scaleDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/particle/src/modules/RotationOverLifetimeModule.ts
Normal file
40
packages/particle/src/modules/RotationOverLifetimeModule.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 旋转随生命周期变化模块
|
||||
* Rotation over lifetime module
|
||||
*/
|
||||
export class RotationOverLifetimeModule implements IParticleModule {
|
||||
readonly name = 'RotationOverLifetime';
|
||||
enabled = true;
|
||||
|
||||
/** 角速度乘数起点 | Angular velocity multiplier start */
|
||||
angularVelocityMultiplierStart: number = 1;
|
||||
|
||||
/** 角速度乘数终点 | Angular velocity multiplier end */
|
||||
angularVelocityMultiplierEnd: number = 1;
|
||||
|
||||
/** 附加旋转(随生命周期累加的旋转量)| Additional rotation over lifetime */
|
||||
additionalRotation: number = 0;
|
||||
|
||||
update(p: Particle, dt: number, normalizedAge: number): void {
|
||||
// 应用角速度乘数 | Apply angular velocity multiplier
|
||||
const multiplier = lerp(
|
||||
this.angularVelocityMultiplierStart,
|
||||
this.angularVelocityMultiplierEnd,
|
||||
normalizedAge
|
||||
);
|
||||
|
||||
p.rotation += p.angularVelocity * multiplier * dt;
|
||||
|
||||
// 附加旋转 | Additional rotation
|
||||
if (this.additionalRotation !== 0) {
|
||||
p.rotation += this.additionalRotation * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
119
packages/particle/src/modules/SizeOverLifetimeModule.ts
Normal file
119
packages/particle/src/modules/SizeOverLifetimeModule.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 缩放关键帧
|
||||
* Scale keyframe
|
||||
*/
|
||||
export interface ScaleKey {
|
||||
/** 时间点 (0-1) | Time (0-1) */
|
||||
time: number;
|
||||
/** 缩放值 | Scale value */
|
||||
scale: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放曲线类型
|
||||
* Scale curve type
|
||||
*/
|
||||
export enum ScaleCurveType {
|
||||
/** 线性 | Linear */
|
||||
Linear = 'linear',
|
||||
/** 缓入 | Ease in */
|
||||
EaseIn = 'easeIn',
|
||||
/** 缓出 | Ease out */
|
||||
EaseOut = 'easeOut',
|
||||
/** 缓入缓出 | Ease in out */
|
||||
EaseInOut = 'easeInOut',
|
||||
/** 自定义关键帧 | Custom keyframes */
|
||||
Custom = 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放随生命周期变化模块
|
||||
* Size over lifetime module
|
||||
*/
|
||||
export class SizeOverLifetimeModule implements IParticleModule {
|
||||
readonly name = 'SizeOverLifetime';
|
||||
enabled = true;
|
||||
|
||||
/** 曲线类型 | Curve type */
|
||||
curveType: ScaleCurveType = ScaleCurveType.Linear;
|
||||
|
||||
/** 起始缩放乘数 | Start scale multiplier */
|
||||
startMultiplier: number = 1;
|
||||
|
||||
/** 结束缩放乘数 | End scale multiplier */
|
||||
endMultiplier: number = 0;
|
||||
|
||||
/** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */
|
||||
customCurve: ScaleKey[] = [];
|
||||
|
||||
/** X/Y 分离缩放 | Separate X/Y scaling */
|
||||
separateAxes: boolean = false;
|
||||
|
||||
/** X轴结束缩放乘数 | End scale multiplier for X axis */
|
||||
endMultiplierX: number = 0;
|
||||
|
||||
/** Y轴结束缩放乘数 | End scale multiplier for Y axis */
|
||||
endMultiplierY: number = 0;
|
||||
|
||||
update(p: Particle, _dt: number, normalizedAge: number): void {
|
||||
let t: number;
|
||||
|
||||
switch (this.curveType) {
|
||||
case ScaleCurveType.Linear:
|
||||
t = normalizedAge;
|
||||
break;
|
||||
case ScaleCurveType.EaseIn:
|
||||
t = normalizedAge * normalizedAge;
|
||||
break;
|
||||
case ScaleCurveType.EaseOut:
|
||||
t = 1 - (1 - normalizedAge) * (1 - normalizedAge);
|
||||
break;
|
||||
case ScaleCurveType.EaseInOut:
|
||||
t = normalizedAge < 0.5
|
||||
? 2 * normalizedAge * normalizedAge
|
||||
: 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2;
|
||||
break;
|
||||
case ScaleCurveType.Custom:
|
||||
t = this._evaluateCustomCurve(normalizedAge);
|
||||
break;
|
||||
default:
|
||||
t = normalizedAge;
|
||||
}
|
||||
|
||||
if (this.separateAxes) {
|
||||
p.scaleX = p.startScaleX * lerp(this.startMultiplier, this.endMultiplierX, t);
|
||||
p.scaleY = p.startScaleY * lerp(this.startMultiplier, this.endMultiplierY, t);
|
||||
} else {
|
||||
const scale = lerp(this.startMultiplier, this.endMultiplier, t);
|
||||
p.scaleX = p.startScaleX * scale;
|
||||
p.scaleY = p.startScaleY * scale;
|
||||
}
|
||||
}
|
||||
|
||||
private _evaluateCustomCurve(normalizedAge: number): number {
|
||||
if (this.customCurve.length === 0) return normalizedAge;
|
||||
if (this.customCurve.length === 1) return this.customCurve[0].scale;
|
||||
|
||||
let startKey = this.customCurve[0];
|
||||
let endKey = this.customCurve[this.customCurve.length - 1];
|
||||
|
||||
for (let i = 0; i < this.customCurve.length - 1; i++) {
|
||||
if (normalizedAge >= this.customCurve[i].time && normalizedAge <= this.customCurve[i + 1].time) {
|
||||
startKey = this.customCurve[i];
|
||||
endKey = this.customCurve[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const range = endKey.time - startKey.time;
|
||||
const t = range > 0 ? (normalizedAge - startKey.time) / range : 0;
|
||||
return lerp(startKey.scale, endKey.scale, t);
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
232
packages/particle/src/modules/TextureSheetAnimationModule.ts
Normal file
232
packages/particle/src/modules/TextureSheetAnimationModule.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 动画播放模式
|
||||
* Animation playback mode
|
||||
*/
|
||||
export enum AnimationPlayMode {
|
||||
/** 单次播放(生命周期内完成一次循环)| Single loop over lifetime */
|
||||
LifetimeLoop = 'lifetimeLoop',
|
||||
/** 固定帧率播放 | Fixed frame rate */
|
||||
FixedFPS = 'fixedFps',
|
||||
/** 随机选择帧 | Random frame selection */
|
||||
Random = 'random',
|
||||
/** 使用速度控制帧 | Speed-based frame */
|
||||
SpeedBased = 'speedBased',
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画循环模式
|
||||
* Animation loop mode
|
||||
*/
|
||||
export enum AnimationLoopMode {
|
||||
/** 不循环(停在最后一帧)| No loop (stop at last frame) */
|
||||
Once = 'once',
|
||||
/** 循环播放 | Loop continuously */
|
||||
Loop = 'loop',
|
||||
/** 往返循环 | Ping-pong loop */
|
||||
PingPong = 'pingPong',
|
||||
}
|
||||
|
||||
/**
|
||||
* 纹理图集动画模块
|
||||
* Texture sheet animation module
|
||||
*
|
||||
* Animates particles through sprite sheet frames.
|
||||
* 通过精灵图帧动画化粒子。
|
||||
*/
|
||||
export class TextureSheetAnimationModule implements IParticleModule {
|
||||
readonly name = 'TextureSheetAnimation';
|
||||
enabled = false;
|
||||
|
||||
// 图集配置 | Sheet configuration
|
||||
/** 水平帧数 | Number of columns */
|
||||
tilesX: number = 1;
|
||||
|
||||
/** 垂直帧数 | Number of rows */
|
||||
tilesY: number = 1;
|
||||
|
||||
/** 总帧数(0=自动计算为 tilesX * tilesY)| Total frames (0 = auto-calculate) */
|
||||
totalFrames: number = 0;
|
||||
|
||||
/** 起始帧 | Start frame index */
|
||||
startFrame: number = 0;
|
||||
|
||||
// 播放配置 | Playback configuration
|
||||
/** 播放模式 | Playback mode */
|
||||
playMode: AnimationPlayMode = AnimationPlayMode.LifetimeLoop;
|
||||
|
||||
/** 循环模式 | Loop mode */
|
||||
loopMode: AnimationLoopMode = AnimationLoopMode.Loop;
|
||||
|
||||
/** 固定帧率(FPS,用于 FixedFPS 模式)| Fixed frame rate (for FixedFPS mode) */
|
||||
frameRate: number = 30;
|
||||
|
||||
/** 播放速度乘数 | Playback speed multiplier */
|
||||
speedMultiplier: number = 1;
|
||||
|
||||
/** 循环次数(0=无限)| Number of loops (0 = infinite) */
|
||||
cycleCount: number = 0;
|
||||
|
||||
// 内部状态 | Internal state
|
||||
private _cachedTotalFrames: number = 0;
|
||||
|
||||
/**
|
||||
* 获取实际总帧数
|
||||
* Get actual total frames
|
||||
*/
|
||||
get actualTotalFrames(): number {
|
||||
return this.totalFrames > 0 ? this.totalFrames : this.tilesX * this.tilesY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新粒子
|
||||
* Update particle
|
||||
*
|
||||
* @param p - 粒子 | Particle
|
||||
* @param dt - 增量时间 | Delta time
|
||||
* @param normalizedAge - 归一化年龄 (0-1) | Normalized age
|
||||
*/
|
||||
update(p: Particle, dt: number, normalizedAge: number): void {
|
||||
const frameCount = this.actualTotalFrames;
|
||||
if (frameCount <= 1) return;
|
||||
|
||||
let frameIndex: number;
|
||||
|
||||
switch (this.playMode) {
|
||||
case AnimationPlayMode.LifetimeLoop:
|
||||
frameIndex = this._getLifetimeFrame(normalizedAge, frameCount);
|
||||
break;
|
||||
|
||||
case AnimationPlayMode.FixedFPS:
|
||||
frameIndex = this._getFixedFPSFrame(p, dt, frameCount);
|
||||
break;
|
||||
|
||||
case AnimationPlayMode.Random:
|
||||
frameIndex = this._getRandomFrame(p, frameCount);
|
||||
break;
|
||||
|
||||
case AnimationPlayMode.SpeedBased:
|
||||
frameIndex = this._getSpeedBasedFrame(p, frameCount);
|
||||
break;
|
||||
|
||||
default:
|
||||
frameIndex = this.startFrame;
|
||||
}
|
||||
|
||||
// 设置粒子的 UV 坐标 | Set particle UV coordinates
|
||||
this._setParticleUV(p, frameIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期帧计算
|
||||
* Calculate frame based on lifetime
|
||||
*/
|
||||
private _getLifetimeFrame(normalizedAge: number, frameCount: number): number {
|
||||
const progress = normalizedAge * this.speedMultiplier;
|
||||
return this._applyLoopMode(progress, frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定帧率计算
|
||||
* Calculate frame based on fixed FPS
|
||||
*/
|
||||
private _getFixedFPSFrame(p: Particle, dt: number, frameCount: number): number {
|
||||
// 使用粒子的 age 来计算当前帧 | Use particle age to calculate current frame
|
||||
const animTime = p.age * this.frameRate * this.speedMultiplier;
|
||||
const progress = animTime / frameCount;
|
||||
return this._applyLoopMode(progress, frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机帧选择(每个粒子使用固定随机帧)
|
||||
* Random frame selection (each particle uses fixed random frame)
|
||||
*/
|
||||
private _getRandomFrame(p: Particle, frameCount: number): number {
|
||||
// 使用粒子的起始位置作为随机种子(确保一致性)
|
||||
// Use particle's start position as random seed (for consistency)
|
||||
const seed = Math.abs(p.startR * 1000 + p.startG * 100 + p.startB * 10) % 1;
|
||||
return Math.floor(seed * frameCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于速度的帧计算
|
||||
* Calculate frame based on particle speed
|
||||
*/
|
||||
private _getSpeedBasedFrame(p: Particle, frameCount: number): number {
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
// 归一化速度(假设最大速度为 500)| Normalize speed (assume max 500)
|
||||
const normalizedSpeed = Math.min(speed / 500, 1);
|
||||
const frameIndex = Math.floor(normalizedSpeed * (frameCount - 1));
|
||||
return this.startFrame + frameIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用循环模式
|
||||
* Apply loop mode to progress
|
||||
*/
|
||||
private _applyLoopMode(progress: number, frameCount: number): number {
|
||||
let loopProgress = progress;
|
||||
|
||||
switch (this.loopMode) {
|
||||
case AnimationLoopMode.Once:
|
||||
// 停在最后一帧 | Stop at last frame
|
||||
loopProgress = Math.min(progress, 0.9999);
|
||||
break;
|
||||
|
||||
case AnimationLoopMode.Loop:
|
||||
// 简单循环 | Simple loop
|
||||
loopProgress = progress % 1;
|
||||
break;
|
||||
|
||||
case AnimationLoopMode.PingPong:
|
||||
// 往返循环 | Ping-pong
|
||||
const cycle = Math.floor(progress);
|
||||
const remainder = progress - cycle;
|
||||
loopProgress = (cycle % 2 === 0) ? remainder : (1 - remainder);
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查循环次数限制 | Check cycle count limit
|
||||
if (this.cycleCount > 0) {
|
||||
const currentCycle = Math.floor(progress);
|
||||
if (currentCycle >= this.cycleCount) {
|
||||
loopProgress = 0.9999; // 停在最后一帧 | Stop at last frame
|
||||
}
|
||||
}
|
||||
|
||||
// 计算帧索引 | Calculate frame index
|
||||
const frameIndex = Math.floor(loopProgress * frameCount);
|
||||
return this.startFrame + Math.min(frameIndex, frameCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置粒子 UV 坐标
|
||||
* Set particle UV coordinates
|
||||
*/
|
||||
private _setParticleUV(p: Particle, frameIndex: number): void {
|
||||
// 计算 UV 坐标 | Calculate UV coordinates
|
||||
const col = frameIndex % this.tilesX;
|
||||
const row = Math.floor(frameIndex / this.tilesX);
|
||||
|
||||
const uWidth = 1 / this.tilesX;
|
||||
const vHeight = 1 / this.tilesY;
|
||||
|
||||
// UV 坐标(左上角为原点)| UV coordinates (top-left origin)
|
||||
const u0 = col * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const u1 = u0 + uWidth;
|
||||
const v1 = v0 + vHeight;
|
||||
|
||||
// 存储 UV 到粒子的自定义属性
|
||||
// Store UV in particle's custom properties
|
||||
// 这里我们使用粒子的 startR/startG/startB 不太合适,需要扩展 Particle
|
||||
// 暂时通过覆盖 rotation 的高位来存储帧索引(临时方案)
|
||||
// Temporary: store frame index in a way that can be read by renderer
|
||||
// The actual UV calculation will be done in the render data provider
|
||||
(p as any)._animFrame = frameIndex;
|
||||
(p as any)._animTilesX = this.tilesX;
|
||||
(p as any)._animTilesY = this.tilesY;
|
||||
}
|
||||
}
|
||||
201
packages/particle/src/modules/VelocityOverLifetimeModule.ts
Normal file
201
packages/particle/src/modules/VelocityOverLifetimeModule.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
/**
|
||||
* 速度关键帧
|
||||
* Velocity keyframe
|
||||
*/
|
||||
export interface VelocityKey {
|
||||
/** 时间点 (0-1) | Time (0-1) */
|
||||
time: number;
|
||||
/** 速度乘数 | Velocity multiplier */
|
||||
multiplier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度曲线类型
|
||||
* Velocity curve type
|
||||
*/
|
||||
export enum VelocityCurveType {
|
||||
/** 常量(无变化)| Constant (no change) */
|
||||
Constant = 'constant',
|
||||
/** 线性 | Linear */
|
||||
Linear = 'linear',
|
||||
/** 缓入(先慢后快)| Ease in (slow then fast) */
|
||||
EaseIn = 'easeIn',
|
||||
/** 缓出(先快后慢)| Ease out (fast then slow) */
|
||||
EaseOut = 'easeOut',
|
||||
/** 缓入缓出 | Ease in out */
|
||||
EaseInOut = 'easeInOut',
|
||||
/** 自定义关键帧 | Custom keyframes */
|
||||
Custom = 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度随生命周期变化模块
|
||||
* Velocity over lifetime module
|
||||
*/
|
||||
export class VelocityOverLifetimeModule implements IParticleModule {
|
||||
readonly name = 'VelocityOverLifetime';
|
||||
enabled = true;
|
||||
|
||||
// ============= 速度曲线 | Velocity Curve =============
|
||||
|
||||
/** 速度曲线类型 | Velocity curve type */
|
||||
curveType: VelocityCurveType = VelocityCurveType.Constant;
|
||||
|
||||
/** 起始速度乘数 | Start velocity multiplier */
|
||||
startMultiplier: number = 1;
|
||||
|
||||
/** 结束速度乘数 | End velocity multiplier */
|
||||
endMultiplier: number = 1;
|
||||
|
||||
/** 自定义关键帧(当 curveType 为 Custom 时使用)| Custom keyframes */
|
||||
customCurve: VelocityKey[] = [];
|
||||
|
||||
// ============= 阻力 | Drag =============
|
||||
|
||||
/** 线性阻力 (0-1),每秒速度衰减比例 | Linear drag (0-1), velocity decay per second */
|
||||
linearDrag: number = 0;
|
||||
|
||||
// ============= 额外速度 | Additional Velocity =============
|
||||
|
||||
/** 轨道速度(绕发射点旋转)| Orbital velocity (rotation around emitter) */
|
||||
orbitalVelocity: number = 0;
|
||||
|
||||
/** 径向速度(向外/向内扩散)| Radial velocity (expand/contract) */
|
||||
radialVelocity: number = 0;
|
||||
|
||||
/** 附加 X 速度 | Additional X velocity */
|
||||
additionalVelocityX: number = 0;
|
||||
|
||||
/** 附加 Y 速度 | Additional Y velocity */
|
||||
additionalVelocityY: number = 0;
|
||||
|
||||
update(p: Particle, dt: number, normalizedAge: number): void {
|
||||
// 计算速度乘数 | Calculate velocity multiplier
|
||||
const multiplier = this._evaluateMultiplier(normalizedAge);
|
||||
|
||||
// 应用速度乘数到当前速度 | Apply multiplier to current velocity
|
||||
// 我们需要存储初始速度来正确应用曲线 | We need to store initial velocity to properly apply curve
|
||||
if (!('startVx' in p)) {
|
||||
(p as any).startVx = p.vx;
|
||||
(p as any).startVy = p.vy;
|
||||
}
|
||||
|
||||
const startVx = (p as any).startVx;
|
||||
const startVy = (p as any).startVy;
|
||||
|
||||
// 应用曲线乘数 | Apply curve multiplier
|
||||
p.vx = startVx * multiplier;
|
||||
p.vy = startVy * multiplier;
|
||||
|
||||
// 应用阻力(在曲线乘数之后)| Apply drag (after curve multiplier)
|
||||
if (this.linearDrag > 0) {
|
||||
const dragFactor = Math.pow(1 - this.linearDrag, dt);
|
||||
p.vx *= dragFactor;
|
||||
p.vy *= dragFactor;
|
||||
// 更新存储的起始速度以反映阻力 | Update stored start velocity to reflect drag
|
||||
(p as any).startVx *= dragFactor;
|
||||
(p as any).startVy *= dragFactor;
|
||||
}
|
||||
|
||||
// 附加速度 | Additional velocity
|
||||
if (this.additionalVelocityX !== 0 || this.additionalVelocityY !== 0) {
|
||||
p.vx += this.additionalVelocityX * dt;
|
||||
p.vy += this.additionalVelocityY * dt;
|
||||
}
|
||||
|
||||
// 轨道速度 | Orbital velocity
|
||||
if (this.orbitalVelocity !== 0) {
|
||||
const angle = Math.atan2(p.y, p.x) + this.orbitalVelocity * dt;
|
||||
const dist = Math.sqrt(p.x * p.x + p.y * p.y);
|
||||
p.x = Math.cos(angle) * dist;
|
||||
p.y = Math.sin(angle) * dist;
|
||||
}
|
||||
|
||||
// 径向速度 | Radial velocity
|
||||
if (this.radialVelocity !== 0) {
|
||||
const dist = Math.sqrt(p.x * p.x + p.y * p.y);
|
||||
if (dist > 0.001) {
|
||||
const nx = p.x / dist;
|
||||
const ny = p.y / dist;
|
||||
p.vx += nx * this.radialVelocity * dt;
|
||||
p.vy += ny * this.radialVelocity * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算速度乘数
|
||||
* Evaluate velocity multiplier
|
||||
*/
|
||||
private _evaluateMultiplier(normalizedAge: number): number {
|
||||
let t: number;
|
||||
|
||||
switch (this.curveType) {
|
||||
case VelocityCurveType.Constant:
|
||||
return this.startMultiplier;
|
||||
|
||||
case VelocityCurveType.Linear:
|
||||
t = normalizedAge;
|
||||
break;
|
||||
|
||||
case VelocityCurveType.EaseIn:
|
||||
t = normalizedAge * normalizedAge;
|
||||
break;
|
||||
|
||||
case VelocityCurveType.EaseOut:
|
||||
t = 1 - (1 - normalizedAge) * (1 - normalizedAge);
|
||||
break;
|
||||
|
||||
case VelocityCurveType.EaseInOut:
|
||||
t = normalizedAge < 0.5
|
||||
? 2 * normalizedAge * normalizedAge
|
||||
: 1 - Math.pow(-2 * normalizedAge + 2, 2) / 2;
|
||||
break;
|
||||
|
||||
case VelocityCurveType.Custom:
|
||||
return this._evaluateCustomCurve(normalizedAge);
|
||||
|
||||
default:
|
||||
t = normalizedAge;
|
||||
}
|
||||
|
||||
return lerp(this.startMultiplier, this.endMultiplier, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算自定义曲线值
|
||||
* Evaluate custom curve value
|
||||
*/
|
||||
private _evaluateCustomCurve(normalizedAge: number): number {
|
||||
if (this.customCurve.length === 0) return this.startMultiplier;
|
||||
if (this.customCurve.length === 1) return this.customCurve[0].multiplier;
|
||||
|
||||
// 在边界外返回边界值 | Return boundary values outside range
|
||||
if (normalizedAge <= this.customCurve[0].time) {
|
||||
return this.customCurve[0].multiplier;
|
||||
}
|
||||
if (normalizedAge >= this.customCurve[this.customCurve.length - 1].time) {
|
||||
return this.customCurve[this.customCurve.length - 1].multiplier;
|
||||
}
|
||||
|
||||
// 找到相邻关键帧 | Find adjacent keyframes
|
||||
for (let i = 0; i < this.customCurve.length - 1; i++) {
|
||||
const start = this.customCurve[i];
|
||||
const end = this.customCurve[i + 1];
|
||||
if (normalizedAge >= start.time && normalizedAge <= end.time) {
|
||||
const range = end.time - start.time;
|
||||
const t = range > 0 ? (normalizedAge - start.time) / range : 0;
|
||||
return lerp(start.multiplier, end.multiplier, t);
|
||||
}
|
||||
}
|
||||
|
||||
return this.startMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
22
packages/particle/src/modules/index.ts
Normal file
22
packages/particle/src/modules/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type { IParticleModule } from './IParticleModule';
|
||||
export { ColorOverLifetimeModule, type ColorKey } from './ColorOverLifetimeModule';
|
||||
export { SizeOverLifetimeModule, ScaleCurveType, type ScaleKey } from './SizeOverLifetimeModule';
|
||||
export { VelocityOverLifetimeModule, VelocityCurveType, type VelocityKey } from './VelocityOverLifetimeModule';
|
||||
export { RotationOverLifetimeModule } from './RotationOverLifetimeModule';
|
||||
export { NoiseModule, valueNoise2D, noiseHash } from './NoiseModule';
|
||||
export {
|
||||
TextureSheetAnimationModule,
|
||||
AnimationPlayMode,
|
||||
AnimationLoopMode
|
||||
} from './TextureSheetAnimationModule';
|
||||
export {
|
||||
CollisionModule,
|
||||
BoundaryType,
|
||||
CollisionBehavior
|
||||
} from './CollisionModule';
|
||||
export {
|
||||
ForceFieldModule,
|
||||
ForceFieldType,
|
||||
createDefaultForceField,
|
||||
type ForceField
|
||||
} from './ForceFieldModule';
|
||||
Reference in New Issue
Block a user