refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
210
packages/rendering/particle/src/ClickFxComponent.ts
Normal file
210
packages/rendering/particle/src/ClickFxComponent.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 点击特效组件 - 在点击位置播放粒子效果
|
||||
* Click FX Component - Play particle effects at click position
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在编辑器中添加此组件到相机或空实体上
|
||||
* // Add this component to camera or empty entity in editor
|
||||
*
|
||||
* // 配置粒子资产列表,点击时会轮换播放
|
||||
* // Configure particle asset list, will cycle through on click
|
||||
* clickFx.particleAssets = ['guid1', 'guid2', 'guid3'];
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 点击特效触发模式
|
||||
* Click FX trigger mode
|
||||
*/
|
||||
export enum ClickFxTriggerMode {
|
||||
/** 鼠标左键点击 | Left mouse button click */
|
||||
LeftClick = 'leftClick',
|
||||
/** 鼠标右键点击 | Right mouse button click */
|
||||
RightClick = 'rightClick',
|
||||
/** 任意鼠标按钮 | Any mouse button */
|
||||
AnyClick = 'anyClick',
|
||||
/** 触摸 | Touch */
|
||||
Touch = 'touch',
|
||||
/** 鼠标和触摸都响应 | Both mouse and touch */
|
||||
All = 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击特效组件
|
||||
* Click FX Component
|
||||
*
|
||||
* 在用户点击/触摸屏幕时,在点击位置播放粒子效果。
|
||||
* Plays particle effects at the click/touch position when user interacts.
|
||||
*/
|
||||
@ECSComponent('ClickFx')
|
||||
@Serializable({ version: 1, typeId: 'ClickFx' })
|
||||
export class ClickFxComponent extends Component {
|
||||
/**
|
||||
* 粒子资产 GUID 列表
|
||||
* List of particle asset GUIDs
|
||||
*
|
||||
* 多个资产会轮换播放,实现多样化的点击效果。
|
||||
* Multiple assets will cycle through for varied click effects.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'array',
|
||||
label: 'Particle Assets',
|
||||
itemType: { type: 'asset', extensions: ['.particle', '.particle.json'] },
|
||||
reorderable: true
|
||||
})
|
||||
public particleAssets: string[] = [];
|
||||
|
||||
/**
|
||||
* 触发模式
|
||||
* Trigger mode
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({
|
||||
type: 'enum',
|
||||
label: 'Trigger Mode',
|
||||
options: [
|
||||
{ label: 'Left Click', value: ClickFxTriggerMode.LeftClick },
|
||||
{ label: 'Right Click', value: ClickFxTriggerMode.RightClick },
|
||||
{ label: 'Any Click', value: ClickFxTriggerMode.AnyClick },
|
||||
{ label: 'Touch', value: ClickFxTriggerMode.Touch },
|
||||
{ label: 'All', value: ClickFxTriggerMode.All }
|
||||
]
|
||||
})
|
||||
public triggerMode: ClickFxTriggerMode = ClickFxTriggerMode.All;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
* Whether enabled
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'boolean', label: 'Enabled' })
|
||||
public fxEnabled: boolean = true;
|
||||
|
||||
/**
|
||||
* 最大同时播放数量
|
||||
* Maximum concurrent effects
|
||||
*
|
||||
* 限制同时播放的粒子效果数量,防止性能问题。
|
||||
* Limits concurrent particle effects to prevent performance issues.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'integer', label: 'Max Concurrent', min: 1, max: 50 })
|
||||
public maxConcurrent: number = 10;
|
||||
|
||||
/**
|
||||
* 粒子效果生命周期(秒)
|
||||
* Particle effect lifetime in seconds
|
||||
*
|
||||
* 效果播放多长时间后自动销毁。
|
||||
* How long before the effect is automatically destroyed.
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Effect Lifetime', min: 0.1, max: 30, step: 0.1 })
|
||||
public effectLifetime: number = 3;
|
||||
|
||||
/**
|
||||
* 位置偏移(相对于点击位置)
|
||||
* Position offset (relative to click position)
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'vector2', label: 'Position Offset' })
|
||||
public positionOffset: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* 缩放
|
||||
* Scale
|
||||
*/
|
||||
@Serialize()
|
||||
@Property({ type: 'number', label: 'Scale', min: 0.1, max: 10, step: 0.1 })
|
||||
public scale: number = 1;
|
||||
|
||||
// ============= 运行时状态(不序列化)| Runtime state (not serialized) =============
|
||||
|
||||
/** 当前粒子索引 | Current particle index */
|
||||
private _currentIndex: number = 0;
|
||||
|
||||
/** 活跃的特效实体 ID 列表 | Active effect entity IDs */
|
||||
private _activeEffects: { entityId: number; startTime: number }[] = [];
|
||||
|
||||
/**
|
||||
* 获取下一个要播放的粒子资产 GUID
|
||||
* Get next particle asset GUID to play
|
||||
*/
|
||||
public getNextParticleAsset(): string | null {
|
||||
if (this.particleAssets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guid = this.particleAssets[this._currentIndex];
|
||||
this._currentIndex = (this._currentIndex + 1) % this.particleAssets.length;
|
||||
return guid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加活跃特效
|
||||
* Add active effect
|
||||
*/
|
||||
public addActiveEffect(entityId: number): void {
|
||||
this._activeEffects.push({ entityId, startTime: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃特效列表
|
||||
* Get active effects list
|
||||
*/
|
||||
public getActiveEffects(): ReadonlyArray<{ entityId: number; startTime: number }> {
|
||||
return this._activeEffects;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除活跃特效
|
||||
* Remove active effect
|
||||
*/
|
||||
public removeActiveEffect(entityId: number): void {
|
||||
const index = this._activeEffects.findIndex(e => e.entityId === entityId);
|
||||
if (index !== -1) {
|
||||
this._activeEffects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有活跃特效记录
|
||||
* Clear all active effect records
|
||||
*/
|
||||
public clearActiveEffects(): void {
|
||||
this._activeEffects = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃特效数量
|
||||
* Get active effect count
|
||||
*/
|
||||
public get activeEffectCount(): number {
|
||||
return this._activeEffects.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可以添加新特效
|
||||
* Whether can add new effect
|
||||
*/
|
||||
public canAddEffect(): boolean {
|
||||
return this._activeEffects.length < this.maxConcurrent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
* Reset state
|
||||
*/
|
||||
public reset(): void {
|
||||
this._currentIndex = 0;
|
||||
this._activeEffects = [];
|
||||
}
|
||||
|
||||
override onRemovedFromEntity(): void {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
248
packages/rendering/particle/src/Particle.ts
Normal file
248
packages/rendering/particle/src/Particle.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 粒子数据结构
|
||||
* Particle data structure
|
||||
*
|
||||
* Represents a single particle with all its runtime state.
|
||||
* 表示单个粒子及其所有运行时状态。
|
||||
*/
|
||||
export interface Particle {
|
||||
/** 是否存活 | Whether particle is alive */
|
||||
alive: boolean;
|
||||
|
||||
/** 位置X | Position X */
|
||||
x: number;
|
||||
/** 位置Y | Position Y */
|
||||
y: number;
|
||||
|
||||
/** 速度X | Velocity X */
|
||||
vx: number;
|
||||
/** 速度Y | Velocity Y */
|
||||
vy: number;
|
||||
|
||||
/** 加速度X | Acceleration X */
|
||||
ax: number;
|
||||
/** 加速度Y | Acceleration Y */
|
||||
ay: number;
|
||||
|
||||
/** 旋转角度(弧度)| Rotation (radians) */
|
||||
rotation: number;
|
||||
/** 角速度 | Angular velocity */
|
||||
angularVelocity: number;
|
||||
|
||||
/** 缩放X | Scale X */
|
||||
scaleX: number;
|
||||
/** 缩放Y | Scale Y */
|
||||
scaleY: 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) */
|
||||
alpha: number;
|
||||
|
||||
/** 当前生命时间(秒)| Current lifetime (seconds) */
|
||||
age: number;
|
||||
/** 总生命时间(秒)| Total lifetime (seconds) */
|
||||
lifetime: number;
|
||||
|
||||
/** 初始缩放X | Initial scale X */
|
||||
startScaleX: number;
|
||||
/** 初始缩放Y | Initial scale Y */
|
||||
startScaleY: number;
|
||||
|
||||
/** 初始颜色R | Initial color R */
|
||||
startR: number;
|
||||
/** 初始颜色G | Initial color G */
|
||||
startG: number;
|
||||
/** 初始颜色B | Initial color B */
|
||||
startB: number;
|
||||
/** 初始透明度 | Initial alpha */
|
||||
startAlpha: number;
|
||||
|
||||
// ============= 模块运行时状态 | Module Runtime State =============
|
||||
// 这些字段由各模块在运行时设置 | These fields are set by modules at runtime
|
||||
|
||||
/** 初始速度X(VelocityOverLifetimeModule 使用)| Initial velocity X (used by VelocityOverLifetimeModule) */
|
||||
startVx?: number;
|
||||
/** 初始速度Y(VelocityOverLifetimeModule 使用)| Initial velocity Y (used by VelocityOverLifetimeModule) */
|
||||
startVy?: number;
|
||||
|
||||
/** 动画帧索引(TextureSheetAnimationModule 使用)| Animation frame index (used by TextureSheetAnimationModule) */
|
||||
_animFrame?: number;
|
||||
/** 动画图块列数(TextureSheetAnimationModule 使用)| Animation tiles X (used by TextureSheetAnimationModule) */
|
||||
_animTilesX?: number;
|
||||
/** 动画图块行数(TextureSheetAnimationModule 使用)| Animation tiles Y (used by TextureSheetAnimationModule) */
|
||||
_animTilesY?: number;
|
||||
|
||||
/** 自定义数据槽 | Custom data slot */
|
||||
userData?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新粒子
|
||||
* Create a new particle
|
||||
*/
|
||||
export function createParticle(): Particle {
|
||||
return {
|
||||
alive: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
ax: 0,
|
||||
ay: 0,
|
||||
rotation: 0,
|
||||
angularVelocity: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
r: 1,
|
||||
g: 1,
|
||||
b: 1,
|
||||
alpha: 1,
|
||||
age: 0,
|
||||
lifetime: 1,
|
||||
startScaleX: 1,
|
||||
startScaleY: 1,
|
||||
startR: 1,
|
||||
startG: 1,
|
||||
startB: 1,
|
||||
startAlpha: 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置粒子状态
|
||||
* Reset particle state
|
||||
*/
|
||||
export function resetParticle(p: Particle): void {
|
||||
p.alive = false;
|
||||
p.x = 0;
|
||||
p.y = 0;
|
||||
p.vx = 0;
|
||||
p.vy = 0;
|
||||
p.ax = 0;
|
||||
p.ay = 0;
|
||||
p.rotation = 0;
|
||||
p.angularVelocity = 0;
|
||||
p.scaleX = 1;
|
||||
p.scaleY = 1;
|
||||
p.r = 1;
|
||||
p.g = 1;
|
||||
p.b = 1;
|
||||
p.alpha = 1;
|
||||
p.age = 0;
|
||||
p.lifetime = 1;
|
||||
p.startScaleX = 1;
|
||||
p.startScaleY = 1;
|
||||
p.startR = 1;
|
||||
p.startG = 1;
|
||||
p.startB = 1;
|
||||
p.startAlpha = 1;
|
||||
p.userData = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子池
|
||||
* Particle pool for efficient memory management
|
||||
*/
|
||||
export class ParticlePool {
|
||||
private _particles: Particle[] = [];
|
||||
private _capacity: number;
|
||||
private _activeCount: number = 0;
|
||||
|
||||
constructor(capacity: number) {
|
||||
this._capacity = capacity;
|
||||
for (let i = 0; i < capacity; i++) {
|
||||
this._particles.push(createParticle());
|
||||
}
|
||||
}
|
||||
|
||||
/** 池容量 | Pool capacity */
|
||||
get capacity(): number {
|
||||
return this._capacity;
|
||||
}
|
||||
|
||||
/** 活跃粒子数 | Active particle count */
|
||||
get activeCount(): number {
|
||||
return this._activeCount;
|
||||
}
|
||||
|
||||
/** 所有粒子(包括不活跃的)| All particles (including inactive) */
|
||||
get particles(): readonly Particle[] {
|
||||
return this._particles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个空闲粒子
|
||||
* Get an inactive particle
|
||||
*/
|
||||
spawn(): Particle | null {
|
||||
for (const p of this._particles) {
|
||||
if (!p.alive) {
|
||||
p.alive = true;
|
||||
this._activeCount++;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收粒子
|
||||
* Recycle a particle
|
||||
*/
|
||||
recycle(p: Particle): void {
|
||||
if (p.alive) {
|
||||
p.alive = false;
|
||||
this._activeCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收所有粒子
|
||||
* Recycle all particles
|
||||
*/
|
||||
recycleAll(): void {
|
||||
for (const p of this._particles) {
|
||||
p.alive = false;
|
||||
}
|
||||
this._activeCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历活跃粒子
|
||||
* Iterate over active particles
|
||||
*/
|
||||
forEachActive(callback: (p: Particle, index: number) => void): void {
|
||||
let index = 0;
|
||||
for (const p of this._particles) {
|
||||
if (p.alive) {
|
||||
callback(p, index++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整池大小
|
||||
* Resize the pool
|
||||
*/
|
||||
resize(newCapacity: number): void {
|
||||
if (newCapacity > this._capacity) {
|
||||
for (let i = this._capacity; i < newCapacity; i++) {
|
||||
this._particles.push(createParticle());
|
||||
}
|
||||
} else if (newCapacity < this._capacity) {
|
||||
// 回收超出容量的活跃粒子 | Recycle active particles beyond capacity
|
||||
for (let i = newCapacity; i < this._capacity; i++) {
|
||||
if (this._particles[i].alive) {
|
||||
this._activeCount--;
|
||||
}
|
||||
}
|
||||
this._particles.length = newCapacity;
|
||||
}
|
||||
this._capacity = newCapacity;
|
||||
}
|
||||
}
|
||||
388
packages/rendering/particle/src/ParticleEmitter.ts
Normal file
388
packages/rendering/particle/src/ParticleEmitter.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import type { Particle, ParticlePool } from './Particle';
|
||||
|
||||
/**
|
||||
* 发射形状类型
|
||||
* Emission shape type
|
||||
*/
|
||||
export enum EmissionShape {
|
||||
/** 点发射 | Point emission */
|
||||
Point = 'point',
|
||||
/** 圆形发射(填充)| Circle emission (filled) */
|
||||
Circle = 'circle',
|
||||
/** 矩形发射 | Rectangle emission */
|
||||
Rectangle = 'rectangle',
|
||||
/** 线段发射 | Line emission */
|
||||
Line = 'line',
|
||||
/** 圆锥/扇形发射 | Cone/fan emission */
|
||||
Cone = 'cone',
|
||||
/** 圆环发射(边缘)| Ring emission (edge only) */
|
||||
Ring = 'ring',
|
||||
/** 矩形边缘发射 | Rectangle edge emission */
|
||||
Edge = 'edge'
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值范围
|
||||
* Value range for randomization
|
||||
*/
|
||||
export interface ValueRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色值
|
||||
* Color value (RGBA)
|
||||
*/
|
||||
export interface ColorValue {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射器配置
|
||||
* Emitter configuration
|
||||
*/
|
||||
export interface EmitterConfig {
|
||||
/** 每秒发射数量 | Particles per second */
|
||||
emissionRate: number;
|
||||
|
||||
/** 单次爆发数量(0表示持续发射)| Burst count (0 for continuous) */
|
||||
burstCount: number;
|
||||
|
||||
/** 粒子生命时间范围(秒)| Particle lifetime range (seconds) */
|
||||
lifetime: ValueRange;
|
||||
|
||||
/** 发射形状 | Emission shape */
|
||||
shape: EmissionShape;
|
||||
|
||||
/** 形状半径(用于圆形/圆锥)| Shape radius (for circle/cone) */
|
||||
shapeRadius: number;
|
||||
|
||||
/** 形状宽度(用于矩形/线段)| Shape width (for rectangle/line) */
|
||||
shapeWidth: number;
|
||||
|
||||
/** 形状高度(用于矩形)| Shape height (for rectangle) */
|
||||
shapeHeight: number;
|
||||
|
||||
/** 圆锥角度(弧度,用于圆锥发射)| Cone angle (radians, for cone shape) */
|
||||
coneAngle: number;
|
||||
|
||||
/** 发射方向(弧度,0=右)| Emission direction (radians, 0=right) */
|
||||
direction: number;
|
||||
|
||||
/** 发射方向随机范围(弧度)| Direction random spread (radians) */
|
||||
directionSpread: number;
|
||||
|
||||
/** 初始速度范围 | Initial speed range */
|
||||
speed: ValueRange;
|
||||
|
||||
/** 初始角速度范围 | Initial angular velocity range */
|
||||
angularVelocity: ValueRange;
|
||||
|
||||
/** 初始缩放范围 | Initial scale range */
|
||||
startScale: ValueRange;
|
||||
|
||||
/** 初始旋转范围(弧度)| Initial rotation range (radians) */
|
||||
startRotation: ValueRange;
|
||||
|
||||
/** 初始颜色 | Initial color */
|
||||
startColor: ColorValue;
|
||||
|
||||
/** 初始颜色变化范围 | Initial color variance */
|
||||
startColorVariance: ColorValue;
|
||||
|
||||
/** 重力X | Gravity X */
|
||||
gravityX: number;
|
||||
|
||||
/** 重力Y | Gravity Y */
|
||||
gravityY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认发射器配置
|
||||
* Create default emitter configuration
|
||||
*/
|
||||
export function createDefaultEmitterConfig(): EmitterConfig {
|
||||
return {
|
||||
emissionRate: 10,
|
||||
burstCount: 0,
|
||||
lifetime: { min: 1, max: 2 },
|
||||
shape: EmissionShape.Point,
|
||||
shapeRadius: 0,
|
||||
shapeWidth: 0,
|
||||
shapeHeight: 0,
|
||||
coneAngle: Math.PI / 6,
|
||||
direction: -Math.PI / 2,
|
||||
directionSpread: 0,
|
||||
speed: { min: 50, max: 100 },
|
||||
angularVelocity: { min: 0, max: 0 },
|
||||
startScale: { min: 1, max: 1 },
|
||||
startRotation: { min: 0, max: 0 },
|
||||
startColor: { r: 1, g: 1, b: 1, a: 1 },
|
||||
startColorVariance: { r: 0, g: 0, b: 0, a: 0 },
|
||||
gravityX: 0,
|
||||
gravityY: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子发射器
|
||||
* Particle emitter - handles particle spawning
|
||||
*/
|
||||
export class ParticleEmitter {
|
||||
public config: EmitterConfig;
|
||||
|
||||
private _emissionAccumulator: number = 0;
|
||||
private _isEmitting: boolean = true;
|
||||
|
||||
constructor(config?: Partial<EmitterConfig>) {
|
||||
this.config = { ...createDefaultEmitterConfig(), ...config };
|
||||
}
|
||||
|
||||
/** 是否正在发射 | Whether emitter is active */
|
||||
get isEmitting(): boolean {
|
||||
return this._isEmitting;
|
||||
}
|
||||
|
||||
set isEmitting(value: boolean) {
|
||||
this._isEmitting = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射粒子
|
||||
* Emit particles
|
||||
*
|
||||
* @param pool - Particle pool
|
||||
* @param dt - Delta time in seconds
|
||||
* @param worldX - World position X
|
||||
* @param worldY - World position Y
|
||||
* @param worldRotation - World rotation in radians (applied to emission direction)
|
||||
* @param worldScaleX - World scale X (applied to emission offset and speed)
|
||||
* @param worldScaleY - World scale Y (applied to emission offset and speed)
|
||||
* @returns Number of particles emitted
|
||||
*/
|
||||
emit(
|
||||
pool: ParticlePool,
|
||||
dt: number,
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
worldRotation: number = 0,
|
||||
worldScaleX: number = 1,
|
||||
worldScaleY: number = 1
|
||||
): number {
|
||||
if (!this._isEmitting) return 0;
|
||||
|
||||
let emitted = 0;
|
||||
|
||||
if (this.config.burstCount > 0) {
|
||||
// 爆发模式 | Burst mode
|
||||
for (let i = 0; i < this.config.burstCount; i++) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
this._isEmitting = false;
|
||||
} else {
|
||||
// 持续发射 | Continuous emission
|
||||
this._emissionAccumulator += this.config.emissionRate * dt;
|
||||
while (this._emissionAccumulator >= 1) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
||||
emitted++;
|
||||
}
|
||||
this._emissionAccumulator -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即爆发发射
|
||||
* Burst emit immediately
|
||||
*
|
||||
* @param pool - Particle pool
|
||||
* @param count - Number of particles to emit
|
||||
* @param worldX - World position X
|
||||
* @param worldY - World position Y
|
||||
* @param worldRotation - World rotation in radians
|
||||
* @param worldScaleX - World scale X
|
||||
* @param worldScaleY - World scale Y
|
||||
*/
|
||||
burst(
|
||||
pool: ParticlePool,
|
||||
count: number,
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
worldRotation: number = 0,
|
||||
worldScaleX: number = 1,
|
||||
worldScaleY: number = 1
|
||||
): number {
|
||||
let emitted = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = pool.spawn();
|
||||
if (p) {
|
||||
this._initParticle(p, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置发射器
|
||||
* Reset emitter
|
||||
*/
|
||||
reset(): void {
|
||||
this._emissionAccumulator = 0;
|
||||
this._isEmitting = true;
|
||||
}
|
||||
|
||||
private _initParticle(
|
||||
p: Particle,
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
worldRotation: number = 0,
|
||||
worldScaleX: number = 1,
|
||||
worldScaleY: number = 1
|
||||
): void {
|
||||
const config = this.config;
|
||||
|
||||
// 获取形状偏移 | Get shape offset
|
||||
const [ox, oy] = this._getShapeOffset();
|
||||
|
||||
// 应用旋转和缩放到发射偏移 | Apply rotation and scale to emission offset
|
||||
// 先缩放,再旋转 | Scale first, then rotate
|
||||
const scaledOx = ox * worldScaleX;
|
||||
const scaledOy = oy * worldScaleY;
|
||||
const cos = Math.cos(worldRotation);
|
||||
const sin = Math.sin(worldRotation);
|
||||
const rotatedOx = scaledOx * cos - scaledOy * sin;
|
||||
const rotatedOy = scaledOx * sin + scaledOy * cos;
|
||||
|
||||
// 位置 | Position
|
||||
p.x = worldX + rotatedOx;
|
||||
p.y = worldY + rotatedOy;
|
||||
|
||||
// 生命时间 | Lifetime
|
||||
p.lifetime = randomRange(config.lifetime.min, config.lifetime.max);
|
||||
p.age = 0;
|
||||
|
||||
// 速度方向(应用世界旋转)| Velocity direction (apply world rotation)
|
||||
const baseDir = config.direction + randomRange(-config.directionSpread / 2, config.directionSpread / 2);
|
||||
const dir = baseDir + worldRotation;
|
||||
const speed = randomRange(config.speed.min, config.speed.max);
|
||||
|
||||
// 速度也应用缩放(使用平均缩放)| Speed also applies scale (use average scale)
|
||||
const avgScale = (worldScaleX + worldScaleY) / 2;
|
||||
p.vx = Math.cos(dir) * speed * avgScale;
|
||||
p.vy = Math.sin(dir) * speed * avgScale;
|
||||
|
||||
// 加速度(重力)| Acceleration (gravity)
|
||||
p.ax = config.gravityX;
|
||||
p.ay = config.gravityY;
|
||||
|
||||
// 旋转 | Rotation
|
||||
p.rotation = randomRange(config.startRotation.min, config.startRotation.max);
|
||||
p.angularVelocity = randomRange(config.angularVelocity.min, config.angularVelocity.max);
|
||||
|
||||
// 缩放(应用世界缩放)| Scale (apply world scale)
|
||||
const baseScale = randomRange(config.startScale.min, config.startScale.max);
|
||||
p.scaleX = baseScale * worldScaleX;
|
||||
p.scaleY = baseScale * worldScaleY;
|
||||
p.startScaleX = p.scaleX;
|
||||
p.startScaleY = p.scaleY;
|
||||
|
||||
// 颜色 | Color
|
||||
p.r = clamp(config.startColor.r + randomRange(-config.startColorVariance.r, config.startColorVariance.r), 0, 1);
|
||||
p.g = clamp(config.startColor.g + randomRange(-config.startColorVariance.g, config.startColorVariance.g), 0, 1);
|
||||
p.b = clamp(config.startColor.b + randomRange(-config.startColorVariance.b, config.startColorVariance.b), 0, 1);
|
||||
p.alpha = clamp(config.startColor.a + randomRange(-config.startColorVariance.a, config.startColorVariance.a), 0, 1);
|
||||
p.startR = p.r;
|
||||
p.startG = p.g;
|
||||
p.startB = p.b;
|
||||
p.startAlpha = p.alpha;
|
||||
}
|
||||
|
||||
private _getShapeOffset(): [number, number] {
|
||||
const config = this.config;
|
||||
|
||||
switch (config.shape) {
|
||||
case EmissionShape.Point:
|
||||
return [0, 0];
|
||||
|
||||
case EmissionShape.Circle: {
|
||||
// 填充圆形 | Filled circle
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = Math.random() * config.shapeRadius;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}
|
||||
|
||||
case EmissionShape.Ring: {
|
||||
// 圆环边缘 | Ring edge only
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
return [Math.cos(angle) * config.shapeRadius, Math.sin(angle) * config.shapeRadius];
|
||||
}
|
||||
|
||||
case EmissionShape.Rectangle: {
|
||||
// 填充矩形 | Filled rectangle
|
||||
const x = randomRange(-config.shapeWidth / 2, config.shapeWidth / 2);
|
||||
const y = randomRange(-config.shapeHeight / 2, config.shapeHeight / 2);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
case EmissionShape.Edge: {
|
||||
// 矩形边缘 | Rectangle edge only
|
||||
const perimeter = 2 * (config.shapeWidth + config.shapeHeight);
|
||||
const t = Math.random() * perimeter;
|
||||
|
||||
const w = config.shapeWidth;
|
||||
const h = config.shapeHeight;
|
||||
|
||||
if (t < w) {
|
||||
// 上边 | Top edge
|
||||
return [t - w / 2, h / 2];
|
||||
} else if (t < w + h) {
|
||||
// 右边 | Right edge
|
||||
return [w / 2, h / 2 - (t - w)];
|
||||
} else if (t < 2 * w + h) {
|
||||
// 下边 | Bottom edge
|
||||
return [w / 2 - (t - w - h), -h / 2];
|
||||
} else {
|
||||
// 左边 | Left edge
|
||||
return [-w / 2, -h / 2 + (t - 2 * w - h)];
|
||||
}
|
||||
}
|
||||
|
||||
case EmissionShape.Line: {
|
||||
const t = Math.random() - 0.5;
|
||||
const cos = Math.cos(config.direction + Math.PI / 2);
|
||||
const sin = Math.sin(config.direction + Math.PI / 2);
|
||||
return [cos * config.shapeWidth * t, sin * config.shapeWidth * t];
|
||||
}
|
||||
|
||||
case EmissionShape.Cone: {
|
||||
const angle = config.direction + randomRange(-config.coneAngle / 2, config.coneAngle / 2);
|
||||
const radius = Math.random() * config.shapeRadius;
|
||||
return [Math.cos(angle) * radius, Math.sin(angle) * radius];
|
||||
}
|
||||
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomRange(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
138
packages/rendering/particle/src/ParticleRuntimeModule.ts
Normal file
138
packages/rendering/particle/src/ParticleRuntimeModule.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
|
||||
import { AssetManagerToken } from '@esengine/asset-system';
|
||||
import { RenderSystemToken, EngineIntegrationToken, TextureServiceToken, CoordinateServiceToken } from '@esengine/ecs-engine-bindgen';
|
||||
import { Physics2DQueryToken } from '@esengine/physics-rapier2d';
|
||||
import { ParticleSystemComponent } from './ParticleSystemComponent';
|
||||
import { ClickFxComponent } from './ClickFxComponent';
|
||||
import { ParticleUpdateSystem } from './systems/ParticleSystem';
|
||||
import { ClickFxSystem } from './systems/ClickFxSystem';
|
||||
import { ParticleLoader, ParticleAssetType } from './loaders/ParticleLoader';
|
||||
import { ParticleUpdateSystemToken } from './tokens';
|
||||
|
||||
export type { SystemContext, ModuleManifest, IRuntimeModule, IRuntimePlugin };
|
||||
|
||||
// 重新导出 tokens | Re-export tokens
|
||||
export { ParticleUpdateSystemToken } from './tokens';
|
||||
|
||||
class ParticleRuntimeModule implements IRuntimeModule {
|
||||
private _updateSystem: ParticleUpdateSystem | null = null;
|
||||
private _loaderRegistered = false;
|
||||
|
||||
registerComponents(registry: IComponentRegistry): void {
|
||||
registry.register(ParticleSystemComponent);
|
||||
registry.register(ClickFxComponent);
|
||||
}
|
||||
|
||||
createSystems(scene: IScene, context: SystemContext): void {
|
||||
// 从服务注册表获取依赖 | Get dependencies from service registry
|
||||
const assetManager = context.services.get(AssetManagerToken);
|
||||
const transformType = context.services.get(TransformTypeToken);
|
||||
const engineIntegration = context.services.get(EngineIntegrationToken);
|
||||
const textureService = context.services.get(TextureServiceToken);
|
||||
const coordinateService = context.services.get(CoordinateServiceToken);
|
||||
const physics2DQuery = context.services.get(Physics2DQueryToken);
|
||||
const renderSystem = context.services.get(RenderSystemToken);
|
||||
|
||||
// 注册粒子资产加载器到上下文的 assetManager
|
||||
// Register particle asset loader to context assetManager
|
||||
if (!this._loaderRegistered && assetManager) {
|
||||
const loader = new ParticleLoader();
|
||||
assetManager.registerLoader(ParticleAssetType, loader);
|
||||
this._loaderRegistered = true;
|
||||
console.log('[ParticleRuntimeModule] Registered ParticleLoader to context assetManager');
|
||||
}
|
||||
|
||||
this._updateSystem = new ParticleUpdateSystem();
|
||||
|
||||
// 设置资产管理器 | Set asset manager
|
||||
if (assetManager) {
|
||||
this._updateSystem.setAssetManager(assetManager);
|
||||
}
|
||||
|
||||
// 设置 Transform 组件类型 | Set Transform component type
|
||||
if (transformType) {
|
||||
this._updateSystem.setTransformType(transformType);
|
||||
}
|
||||
|
||||
// 设置引擎集成(用于加载纹理)| Set engine integration (for loading textures)
|
||||
if (engineIntegration) {
|
||||
this._updateSystem.setEngineIntegration(engineIntegration);
|
||||
}
|
||||
|
||||
// 设置纹理服务(用于加载默认纹理)| Set texture service (for loading default texture)
|
||||
if (textureService) {
|
||||
this._updateSystem.setTextureService(textureService);
|
||||
}
|
||||
|
||||
// 设置 2D 物理查询(用于粒子与场景碰撞)| Set 2D physics query (for particle-scene collision)
|
||||
if (physics2DQuery) {
|
||||
this._updateSystem.setPhysics2DQuery(physics2DQuery);
|
||||
}
|
||||
|
||||
scene.addSystem(this._updateSystem);
|
||||
|
||||
// 添加点击特效系统 | Add click FX system
|
||||
// ClickFxSystem 不再需要 AssetManager,资产由 ParticleUpdateSystem 统一加载
|
||||
// ClickFxSystem no longer needs AssetManager, assets are loaded by ParticleUpdateSystem
|
||||
const clickFxSystem = new ClickFxSystem();
|
||||
|
||||
// 设置坐标服务(用于屏幕坐标转世界坐标)
|
||||
// Set coordinate service (for screen to world coordinate conversion)
|
||||
if (coordinateService) {
|
||||
clickFxSystem.setCoordinateService(coordinateService);
|
||||
}
|
||||
|
||||
// 从服务注册表获取 Canvas 元素(用于计算相对坐标)
|
||||
// Get canvas element from service registry (for calculating relative coordinates)
|
||||
const canvas = context.services.get(CanvasElementToken);
|
||||
if (canvas) {
|
||||
clickFxSystem.setCanvas(canvas);
|
||||
}
|
||||
|
||||
scene.addSystem(clickFxSystem);
|
||||
|
||||
// 注册粒子更新系统到服务注册表 | Register particle update system to service registry
|
||||
context.services.register(ParticleUpdateSystemToken, this._updateSystem);
|
||||
|
||||
// 注册渲染数据提供者 | Register render data provider
|
||||
if (renderSystem) {
|
||||
const renderDataProvider = this._updateSystem.getRenderDataProvider();
|
||||
renderSystem.addRenderDataProvider(renderDataProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取粒子更新系统
|
||||
* Get particle update system
|
||||
*/
|
||||
get updateSystem(): ParticleUpdateSystem | null {
|
||||
return this._updateSystem;
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'particle',
|
||||
name: '@esengine/particle',
|
||||
displayName: 'Particle System',
|
||||
version: '1.0.0',
|
||||
description: 'Particle system for 2D effects',
|
||||
category: 'Rendering',
|
||||
icon: 'Sparkles',
|
||||
isCore: false,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
canContainContent: true,
|
||||
dependencies: ['core', 'math', 'sprite'],
|
||||
exports: { components: ['ParticleSystemComponent', 'ClickFxComponent'] },
|
||||
editorPackage: '@esengine/particle-editor',
|
||||
requiresWasm: false
|
||||
};
|
||||
|
||||
export const ParticlePlugin: IRuntimePlugin = {
|
||||
manifest,
|
||||
runtimeModule: new ParticleRuntimeModule()
|
||||
};
|
||||
|
||||
export { ParticleRuntimeModule };
|
||||
1048
packages/rendering/particle/src/ParticleSystemComponent.ts
Normal file
1048
packages/rendering/particle/src/ParticleSystemComponent.ts
Normal file
File diff suppressed because it is too large
Load Diff
401
packages/rendering/particle/src/__tests__/particle-e2e-test.html
Normal file
401
packages/rendering/particle/src/__tests__/particle-e2e-test.html
Normal file
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Particle System End-to-End Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||
.section { margin: 20px 0; padding: 15px; background: #252525; border-radius: 8px; }
|
||||
h2 { color: #8cf; margin-top: 0; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 11px; }
|
||||
.pass { color: #2a5; }
|
||||
.fail { color: #f55; }
|
||||
.log { color: #aaa; font-size: 11px; }
|
||||
table { border-collapse: collapse; margin: 10px 0; }
|
||||
td, th { border: 1px solid #444; padding: 5px 10px; text-align: left; }
|
||||
th { background: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Particle System End-to-End Test</h1>
|
||||
<p>This test simulates the COMPLETE particle rendering pipeline.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 1: Test Texture</h2>
|
||||
<pre>
|
||||
2x2 Spritesheet (128x128 pixels):
|
||||
┌───────────┬───────────┐
|
||||
│ RED (0) │ GREEN (1) │ row=0, v: 0.0 - 0.5
|
||||
├───────────┼───────────┤
|
||||
│ BLUE (2) │ YELLOW(3) │ row=1, v: 0.5 - 1.0
|
||||
└───────────┴───────────┘
|
||||
</pre>
|
||||
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 2: TextureSheetAnimationModule._setParticleUV()</h2>
|
||||
<pre id="step2Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 3: ParticleRenderDataProvider._updateRenderData()</h2>
|
||||
<pre id="step3Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 4: EngineRenderSystem.convertProviderDataToSprites()</h2>
|
||||
<pre id="step4Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 5: sprite_batch.rs add_sprite_vertices_to_batch()</h2>
|
||||
<pre id="step5Log"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Step 6: Final Rendering Result</h2>
|
||||
<canvas id="mainCanvas" width="500" height="150"></canvas>
|
||||
<div id="renderResult"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Test Results</h2>
|
||||
<table>
|
||||
<tr><th>Frame</th><th>Expected</th><th>Got</th><th>Status</th></tr>
|
||||
<tbody id="resultsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========== Shaders ==========
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
uniform mat4 uProjection;
|
||||
void main() {
|
||||
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
// ========== WebGL Setup ==========
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTestTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); // NO FLIP - same as engine
|
||||
|
||||
const data = new Uint8Array([
|
||||
255, 50, 50, 255, 50, 255, 50, 255, // Row 0: Red, Green
|
||||
50, 50, 255, 255, 255, 255, 50, 255 // Row 1: Blue, Yellow
|
||||
]);
|
||||
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ========== Step 2: TextureSheetAnimationModule._setParticleUV ==========
|
||||
function simulateTextureSheetAnimationModule(frameIndex, tilesX, tilesY) {
|
||||
const col = frameIndex % tilesX;
|
||||
const row = Math.floor(frameIndex / tilesX);
|
||||
|
||||
// This is what TextureSheetAnimationModule stores on the particle
|
||||
return {
|
||||
_animFrame: frameIndex,
|
||||
_animTilesX: tilesX,
|
||||
_animTilesY: tilesY,
|
||||
col,
|
||||
row
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 3: ParticleRenderDataProvider._updateRenderData ==========
|
||||
function simulateParticleRenderDataProvider(particle, tilesX, tilesY) {
|
||||
const frame = particle._animFrame;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
// This is exactly what ParticleRenderDataProvider does
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
return {
|
||||
uvs: [u0, v0, u1, v1],
|
||||
transforms: [0, 0, 0, 64, 64, 0.5, 0.5], // x, y, rotation, scaleX, scaleY, originX, originY
|
||||
tileCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 4: EngineRenderSystem.convertProviderDataToSprites ==========
|
||||
function simulateConvertProviderDataToSprites(providerData, x, y) {
|
||||
const tOffset = 0;
|
||||
const uvOffset = 0;
|
||||
|
||||
return {
|
||||
x: x,
|
||||
y: y,
|
||||
rotation: providerData.transforms[tOffset + 2],
|
||||
scaleX: providerData.transforms[tOffset + 3],
|
||||
scaleY: providerData.transforms[tOffset + 4],
|
||||
originX: providerData.transforms[tOffset + 5],
|
||||
originY: providerData.transforms[tOffset + 6],
|
||||
uv: [
|
||||
providerData.uvs[0],
|
||||
providerData.uvs[1],
|
||||
providerData.uvs[2],
|
||||
providerData.uvs[3]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Step 5: sprite_batch.rs add_sprite_vertices_to_batch ==========
|
||||
function simulateSpriteBatch(sprite, batch) {
|
||||
const { x, y, scaleX: width, scaleY: height, rotation, originX, originY } = sprite;
|
||||
const [u0, v0, u1, v1] = sprite.uv;
|
||||
|
||||
const ox = originX * width;
|
||||
const oy = originY * height;
|
||||
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
// Exactly as sprite_batch.rs
|
||||
const corners = [
|
||||
[-ox, height - oy], // 0: Top-left (high Y)
|
||||
[width - ox, height - oy], // 1: Top-right
|
||||
[width - ox, -oy], // 2: Bottom-right (low Y)
|
||||
[-ox, -oy] // 3: Bottom-left
|
||||
];
|
||||
|
||||
const texCoords = [
|
||||
[u0, v0], // 0: Top-left vertex gets (u0, v0)
|
||||
[u1, v0], // 1: Top-right vertex gets (u1, v0)
|
||||
[u1, v1], // 2: Bottom-right vertex gets (u1, v1)
|
||||
[u0, v1] // 3: Bottom-left vertex gets (u0, v1)
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [lx, ly] = corners[i];
|
||||
const rx = lx * cos - ly * sin;
|
||||
const ry = lx * sin + ly * cos;
|
||||
const px = rx + x;
|
||||
const py = ry + y;
|
||||
|
||||
batch.positions.push(px, py);
|
||||
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||
}
|
||||
|
||||
const base = batch.vertexCount;
|
||||
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||
batch.vertexCount += 4;
|
||||
|
||||
return { corners, texCoords };
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
// ========== Main Test ==========
|
||||
function runTest() {
|
||||
const tilesX = 2, tilesY = 2;
|
||||
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||
const results = [];
|
||||
|
||||
let step2Log = '';
|
||||
let step3Log = '';
|
||||
let step4Log = '';
|
||||
let step5Log = '';
|
||||
|
||||
// Draw texture preview
|
||||
const previewCanvas = document.getElementById('texturePreview');
|
||||
const previewCtx = previewCanvas.getContext('2d');
|
||||
previewCtx.fillStyle = '#ff3232'; previewCtx.fillRect(0, 0, 64, 64);
|
||||
previewCtx.fillStyle = '#32ff32'; previewCtx.fillRect(64, 0, 64, 64);
|
||||
previewCtx.fillStyle = '#3232ff'; previewCtx.fillRect(0, 64, 64, 64);
|
||||
previewCtx.fillStyle = '#ffff32'; previewCtx.fillRect(64, 64, 64, 64);
|
||||
previewCtx.fillStyle = '#fff'; previewCtx.font = '20px monospace';
|
||||
previewCtx.fillText('0', 28, 38); previewCtx.fillText('1', 92, 38);
|
||||
previewCtx.fillText('2', 28, 102); previewCtx.fillText('3', 92, 102);
|
||||
|
||||
// Setup WebGL
|
||||
const canvas = document.getElementById('mainCanvas');
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const program = createProgram(gl);
|
||||
const texture = createTestTexture(gl);
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||
const left = 0, right = 500, bottom = 0, top = 150;
|
||||
const projection = new Float32Array([
|
||||
2/(right-left), 0, 0, 0,
|
||||
0, 2/(top-bottom), 0, 0,
|
||||
0, 0, -1, 0,
|
||||
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||
]);
|
||||
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||
|
||||
gl.viewport(0, 0, 500, 150);
|
||||
gl.clearColor(0.15, 0.15, 0.15, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||
|
||||
// Process each frame
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = 60 + frame * 110;
|
||||
const y = 75;
|
||||
|
||||
// Step 2
|
||||
const particleData = simulateTextureSheetAnimationModule(frame, tilesX, tilesY);
|
||||
step2Log += `Frame ${frame}: _animFrame=${particleData._animFrame}, col=${particleData.col}, row=${particleData.row}\n`;
|
||||
|
||||
// Step 3
|
||||
const providerData = simulateParticleRenderDataProvider(particleData, tilesX, tilesY);
|
||||
step3Log += `Frame ${frame}: uvs=[${providerData.uvs.map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||
|
||||
// Step 4
|
||||
const sprite = simulateConvertProviderDataToSprites(providerData, x, y);
|
||||
step4Log += `Frame ${frame}: uv=[${sprite.uv.map(v => v.toFixed(2)).join(', ')}], pos=(${x}, ${y})\n`;
|
||||
|
||||
// Step 5
|
||||
const batchResult = simulateSpriteBatch(sprite, batch);
|
||||
step5Log += `Frame ${frame}: vertex0(top-left)=[${batchResult.texCoords[0].map(v => v.toFixed(2)).join(', ')}], `;
|
||||
step5Log += `vertex2(bottom-right)=[${batchResult.texCoords[2].map(v => v.toFixed(2)).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
document.getElementById('step2Log').textContent = step2Log;
|
||||
document.getElementById('step3Log').textContent = step3Log;
|
||||
document.getElementById('step4Log').textContent = step4Log;
|
||||
document.getElementById('step5Log').textContent = step5Log;
|
||||
|
||||
// Render
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const idxBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||
|
||||
// Read back and verify
|
||||
const tableBody = document.getElementById('resultsTable');
|
||||
let allPassed = true;
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = 60 + frame * 110;
|
||||
const y = 75;
|
||||
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
const expected = expectedColors[frame];
|
||||
const passed = actual === expected;
|
||||
|
||||
if (!passed) allPassed = false;
|
||||
results.push({ frame, expected, actual, passed });
|
||||
|
||||
tableBody.innerHTML += `
|
||||
<tr class="${passed ? 'pass' : 'fail'}">
|
||||
<td>Frame ${frame}</td>
|
||||
<td>${expected}</td>
|
||||
<td>${actual}</td>
|
||||
<td>${passed ? '✓ PASS' : '✗ FAIL'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// Conclusion
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
if (allPassed) {
|
||||
conclusionEl.innerHTML = `<span class="pass">ALL TESTS PASSED!</span>
|
||||
|
||||
The entire particle rendering pipeline is CORRECT:
|
||||
- TextureSheetAnimationModule ✓
|
||||
- ParticleRenderDataProvider ✓
|
||||
- EngineRenderSystem ✓
|
||||
- sprite_batch.rs ✓
|
||||
|
||||
If your actual particles still show wrong colors, the problem must be:
|
||||
1. Your spritesheet image has a different layout
|
||||
2. Something else is modifying the UV values
|
||||
3. The texture is being loaded differently
|
||||
|
||||
Try using the test image: F:\\ecs-framework\\test_spritesheet_2x2.png`;
|
||||
} else {
|
||||
const failedFrames = results.filter(r => !r.passed);
|
||||
conclusionEl.innerHTML = `<span class="fail">TESTS FAILED!</span>
|
||||
|
||||
Failed frames:
|
||||
${failedFrames.map(f => `Frame ${f.frame}: expected ${f.expected}, got ${f.actual}`).join('\n')}
|
||||
|
||||
This indicates a bug in the rendering pipeline.`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
328
packages/rendering/particle/src/__tests__/sprite-batch-test.html
Normal file
328
packages/rendering/particle/src/__tests__/sprite-batch-test.html
Normal file
@@ -0,0 +1,328 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sprite Batch Rendering Test (模拟 sprite_batch.rs)</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; background: #333; }
|
||||
.test-row { display: flex; align-items: flex-start; margin: 20px 0; gap: 20px; }
|
||||
.info { background: #333; padding: 10px; border-radius: 4px; font-size: 12px; }
|
||||
h2 { color: #8cf; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
||||
.pass { color: #2a5; }
|
||||
.fail { color: #f55; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sprite Batch Rendering Test</h1>
|
||||
<p>This test simulates exactly how sprite_batch.rs renders sprites with UV coordinates.</p>
|
||||
|
||||
<h2>Test Texture (2x2 spritesheet)</h2>
|
||||
<pre>
|
||||
┌─────────┬─────────┐
|
||||
│ RED (0) │ GREEN(1)│ v: 0.0 - 0.5
|
||||
├─────────┼─────────┤
|
||||
│ BLUE(2) │ YELLOW(3)│ v: 0.5 - 1.0
|
||||
└─────────┴─────────┘
|
||||
</pre>
|
||||
|
||||
<h2>Rendering Test (same as sprite_batch.rs)</h2>
|
||||
<div class="test-row">
|
||||
<div>
|
||||
<canvas id="mainCanvas" width="400" height="300"></canvas>
|
||||
<div>Main rendering canvas</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>sprite_batch.rs vertex mapping:</h3>
|
||||
<pre>
|
||||
corners = [
|
||||
(-ox, height-oy), // 0: Top-left (high Y)
|
||||
(width-ox, height-oy), // 1: Top-right
|
||||
(width-ox, -oy), // 2: Bottom-right (low Y)
|
||||
(-ox, -oy), // 3: Bottom-left
|
||||
];
|
||||
|
||||
tex_coords = [
|
||||
[u0, v0], // 0: Top-left
|
||||
[u1, v0], // 1: Top-right
|
||||
[u1, v1], // 2: Bottom-right
|
||||
[u0, v1], // 3: Bottom-left
|
||||
];
|
||||
|
||||
indices = [0, 1, 2, 2, 3, 0];
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Frame-by-Frame Test Results</h2>
|
||||
<div id="results"></div>
|
||||
|
||||
<h2>Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
|
||||
<script>
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
uniform mat4 uProjection;
|
||||
void main() {
|
||||
gl_Position = uProjection * vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl, vsSource, fsSource) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTestTexture(gl) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// NO FLIP_Y - same as our engine
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
|
||||
// 2x2 texture: Red, Green, Blue, Yellow
|
||||
const data = new Uint8Array([
|
||||
255, 0, 0, 255, 0, 255, 0, 255, // Row 0: Red, Green
|
||||
0, 0, 255, 255, 255, 255, 0, 255 // Row 1: Blue, Yellow
|
||||
]);
|
||||
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Simulate sprite_batch.rs add_sprite_vertices_to_batch
|
||||
function addSpriteVertices(batch, x, y, width, height, rotation, originX, originY, u0, v0, u1, v1, color) {
|
||||
const ox = originX * width;
|
||||
const oy = originY * height;
|
||||
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
// Same as sprite_batch.rs
|
||||
const corners = [
|
||||
[-ox, height - oy], // 0: Top-left
|
||||
[width - ox, height - oy], // 1: Top-right
|
||||
[width - ox, -oy], // 2: Bottom-right
|
||||
[-ox, -oy] // 3: Bottom-left
|
||||
];
|
||||
|
||||
const texCoords = [
|
||||
[u0, v0], // 0: Top-left
|
||||
[u1, v0], // 1: Top-right
|
||||
[u1, v1], // 2: Bottom-right
|
||||
[u0, v1] // 3: Bottom-left
|
||||
];
|
||||
|
||||
// Transform and add vertices
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const [lx, ly] = corners[i];
|
||||
|
||||
// Apply rotation
|
||||
const rx = lx * cos - ly * sin;
|
||||
const ry = lx * sin + ly * cos;
|
||||
|
||||
// Apply translation
|
||||
const px = rx + x;
|
||||
const py = ry + y;
|
||||
|
||||
batch.positions.push(px, py);
|
||||
batch.texCoords.push(texCoords[i][0], texCoords[i][1]);
|
||||
}
|
||||
|
||||
// Add indices (0, 1, 2, 2, 3, 0)
|
||||
const base = batch.vertexCount;
|
||||
batch.indices.push(base, base + 1, base + 2, base + 2, base + 3, base);
|
||||
batch.vertexCount += 4;
|
||||
}
|
||||
|
||||
function calculateUV(frame, tilesX, tilesY) {
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
return {
|
||||
u0: col * uWidth,
|
||||
v0: row * vHeight,
|
||||
u1: (col + 1) * uWidth,
|
||||
v1: (row + 1) * vHeight
|
||||
};
|
||||
}
|
||||
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function runTest() {
|
||||
const canvas = document.getElementById('mainCanvas');
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
|
||||
if (!gl) {
|
||||
document.getElementById('conclusion').textContent = 'WebGL not supported!';
|
||||
return;
|
||||
}
|
||||
|
||||
const program = createProgram(gl, vsSource, fsSource);
|
||||
const texture = createTestTexture(gl);
|
||||
|
||||
gl.useProgram(program);
|
||||
|
||||
// Set up orthographic projection (Y-up, like our engine)
|
||||
const projLoc = gl.getUniformLocation(program, 'uProjection');
|
||||
const left = 0, right = 400, bottom = 0, top = 300;
|
||||
const projection = new Float32Array([
|
||||
2/(right-left), 0, 0, 0,
|
||||
0, 2/(top-bottom), 0, 0,
|
||||
0, 0, -1, 0,
|
||||
-(right+left)/(right-left), -(top+bottom)/(top-bottom), 0, 1
|
||||
]);
|
||||
gl.uniformMatrix4fv(projLoc, false, projection);
|
||||
|
||||
// Clear
|
||||
gl.viewport(0, 0, 400, 300);
|
||||
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Create batch
|
||||
const batch = { positions: [], texCoords: [], indices: [], vertexCount: 0 };
|
||||
|
||||
// Add 4 sprites for 4 frames
|
||||
const spriteSize = 80;
|
||||
const spacing = 90;
|
||||
const startX = 50;
|
||||
const startY = 150;
|
||||
|
||||
const expectedColors = ['RED', 'GREEN', 'BLUE', 'YELLOW'];
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const uv = calculateUV(frame, 2, 2);
|
||||
const x = startX + frame * spacing;
|
||||
const y = startY;
|
||||
|
||||
addSpriteVertices(
|
||||
batch,
|
||||
x, y, // position
|
||||
spriteSize, spriteSize, // size
|
||||
0, // rotation
|
||||
0.5, 0.5, // origin (center)
|
||||
uv.u0, uv.v0, uv.u1, uv.v1, // UV
|
||||
[1, 1, 1, 1] // color
|
||||
);
|
||||
}
|
||||
|
||||
// Upload and render
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.positions), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(batch.texCoords), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const idxBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(batch.indices), gl.STATIC_DRAW);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
gl.drawElements(gl.TRIANGLES, batch.indices.length, gl.UNSIGNED_SHORT, 0);
|
||||
|
||||
// Read back colors and verify
|
||||
const resultsDiv = document.getElementById('results');
|
||||
let allPassed = true;
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const x = startX + frame * spacing;
|
||||
const y = startY;
|
||||
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
const expected = expectedColors[frame];
|
||||
const passed = actual === expected;
|
||||
|
||||
if (!passed) allPassed = false;
|
||||
|
||||
const uv = calculateUV(frame, 2, 2);
|
||||
resultsDiv.innerHTML += `
|
||||
<div class="${passed ? 'pass' : 'fail'}">
|
||||
Frame ${frame}: UV=[${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]
|
||||
→ Expected: ${expected}, Got: ${actual} ${passed ? '✓' : '✗'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
if (allPassed) {
|
||||
conclusionEl.innerHTML = `
|
||||
<span class="pass">ALL TESTS PASSED!</span>
|
||||
|
||||
The sprite_batch.rs rendering logic is CORRECT.
|
||||
UV calculation is CORRECT.
|
||||
|
||||
If particles still show wrong frames in the actual engine, possible causes:
|
||||
1. The spritesheet image layout is different (frame 0 not at top-left)
|
||||
2. Image loading is flipping the texture somewhere
|
||||
3. The particle system is using different UV values than expected
|
||||
|
||||
<b>NEXT STEP:</b> Check the actual spritesheet image in the editor.
|
||||
Is frame 0 really at the top-left corner of the image?
|
||||
`;
|
||||
} else {
|
||||
conclusionEl.innerHTML = `
|
||||
<span class="fail">SOME TESTS FAILED!</span>
|
||||
|
||||
There's a bug in the vertex/UV mapping logic.
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
packages/rendering/particle/src/__tests__/uv-calculation.test.ts
Normal file
142
packages/rendering/particle/src/__tests__/uv-calculation.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* UV 计算测试
|
||||
* UV Calculation Test
|
||||
*
|
||||
* 用于验证 TextureSheetAnimation 的 UV 坐标计算是否正确
|
||||
* Used to verify TextureSheetAnimation UV coordinate calculation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 模拟 ParticleRenderDataProvider 中的 UV 计算
|
||||
* Simulate UV calculation from ParticleRenderDataProvider
|
||||
*/
|
||||
function calculateUV(frame: number, tilesX: number, tilesY: number) {
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
return { u0, v0, u1, v1, col, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 4x4 spritesheet (16帧)
|
||||
*
|
||||
* 预期布局(标准 spritesheet,从左上角开始):
|
||||
* ┌────┬────┬────┬────┐
|
||||
* │ 0 │ 1 │ 2 │ 3 │ row=0, v: 0.00 - 0.25
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 4 │ 5 │ 6 │ 7 │ row=1, v: 0.25 - 0.50
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 8 │ 9 │ 10 │ 11 │ row=2, v: 0.50 - 0.75
|
||||
* ├────┼────┼────┼────┤
|
||||
* │ 12 │ 13 │ 14 │ 15 │ row=3, v: 0.75 - 1.00
|
||||
* └────┴────┴────┴────┘
|
||||
*/
|
||||
function test4x4Spritesheet() {
|
||||
console.log('=== 4x4 Spritesheet UV Test ===\n');
|
||||
|
||||
const tilesX = 4;
|
||||
const tilesY = 4;
|
||||
|
||||
console.log('Expected layout (standard spritesheet, top-left origin):');
|
||||
console.log('Frame 0 should be at TOP-LEFT (v: 0.00-0.25)');
|
||||
console.log('Frame 12 should be at BOTTOM-LEFT (v: 0.75-1.00)\n');
|
||||
|
||||
// 测试关键帧
|
||||
const testFrames = [0, 1, 4, 5, 12, 15];
|
||||
|
||||
for (const frame of testFrames) {
|
||||
const uv = calculateUV(frame, tilesX, tilesY);
|
||||
console.log(`Frame ${frame.toString().padStart(2)}: col=${uv.col}, row=${uv.row}`);
|
||||
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 2x2 spritesheet (4帧) - 最简单的情况
|
||||
*/
|
||||
function test2x2Spritesheet() {
|
||||
console.log('=== 2x2 Spritesheet UV Test ===\n');
|
||||
|
||||
const tilesX = 2;
|
||||
const tilesY = 2;
|
||||
|
||||
console.log('Layout:');
|
||||
console.log('┌─────┬─────┐');
|
||||
console.log('│ 0 │ 1 │ v: 0.0 - 0.5');
|
||||
console.log('├─────┼─────┤');
|
||||
console.log('│ 2 │ 3 │ v: 0.5 - 1.0');
|
||||
console.log('└─────┴─────┘\n');
|
||||
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const uv = calculateUV(frame, tilesX, tilesY);
|
||||
console.log(`Frame ${frame}: col=${uv.col}, row=${uv.row}`);
|
||||
console.log(` UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL 纹理坐标系说明
|
||||
*/
|
||||
function explainWebGLTextureCoords() {
|
||||
console.log('=== WebGL Texture Coordinate System ===\n');
|
||||
|
||||
console.log('Without UNPACK_FLIP_Y_WEBGL:');
|
||||
console.log('- Image row 0 (top of image file) -> stored at texture row 0');
|
||||
console.log('- Texture coordinate V=0 samples texture row 0');
|
||||
console.log('- Therefore: V=0 = image top, V=1 = image bottom');
|
||||
console.log('');
|
||||
|
||||
console.log('sprite_batch.rs vertex mapping:');
|
||||
console.log('- Vertex 0 (top-left on screen, high Y) uses tex_coords[0] = [u0, v0]');
|
||||
console.log('- Vertex 2 (bottom-right on screen, low Y) uses tex_coords[2] = [u1, v1]');
|
||||
console.log('');
|
||||
|
||||
console.log('Expected behavior:');
|
||||
console.log('- Frame 0 UV [0, 0, 0.25, 0.25] should show TOP-LEFT quarter of spritesheet');
|
||||
console.log('- If frame 0 shows BOTTOM-LEFT, the image is being rendered upside down');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断当前问题
|
||||
*/
|
||||
function diagnoseIssue() {
|
||||
console.log('=== Diagnosis ===\n');
|
||||
|
||||
console.log('If TextureSheetAnimation shows wrong frames, check:');
|
||||
console.log('');
|
||||
console.log('1. Is frame 0 showing the TOP-LEFT of the spritesheet?');
|
||||
console.log(' - YES: UV calculation is correct');
|
||||
console.log(' - NO (shows bottom-left): Image is flipped vertically in WebGL');
|
||||
console.log('');
|
||||
console.log('2. Are frames playing in wrong ORDER (e.g., 3,2,1,0 instead of 0,1,2,3)?');
|
||||
console.log(' - Check animation frame index calculation');
|
||||
console.log('');
|
||||
console.log('3. Is the spritesheet itself laid out correctly?');
|
||||
console.log(' - Frame 0 should be at TOP-LEFT of the image file');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 运行所有测试
|
||||
export function runUVTests() {
|
||||
explainWebGLTextureCoords();
|
||||
test2x2Spritesheet();
|
||||
test4x4Spritesheet();
|
||||
diagnoseIssue();
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (typeof window !== 'undefined') {
|
||||
runUVTests();
|
||||
}
|
||||
|
||||
export { calculateUV, test2x2Spritesheet, test4x4Spritesheet };
|
||||
278
packages/rendering/particle/src/__tests__/webgl-uv-test.html
Normal file
278
packages/rendering/particle/src/__tests__/webgl-uv-test.html
Normal file
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebGL UV Coordinate Test</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a1a; color: #fff; padding: 20px; }
|
||||
canvas { border: 1px solid #444; margin: 10px; }
|
||||
.test-row { display: flex; align-items: center; margin: 20px 0; }
|
||||
.label { width: 200px; }
|
||||
.result { margin-left: 20px; padding: 5px 10px; border-radius: 4px; }
|
||||
.pass { background: #2a5; }
|
||||
.fail { background: #a33; }
|
||||
h2 { color: #8cf; }
|
||||
pre { background: #333; padding: 10px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebGL UV Coordinate System Test</h1>
|
||||
|
||||
<h2>1. Test Texture (2x2 grid)</h2>
|
||||
<pre>
|
||||
Image file layout (how it looks in image editor):
|
||||
┌─────────┬─────────┐
|
||||
│ RED (0) │ GREEN(1)│ row 0 (top of image file)
|
||||
├─────────┼─────────┤
|
||||
│ BLUE(2) │ YELLOW(3)│ row 1 (bottom of image file)
|
||||
└─────────┴─────────┘
|
||||
</pre>
|
||||
<canvas id="texturePreview" width="128" height="128"></canvas>
|
||||
<span>← This is the source texture</span>
|
||||
|
||||
<h2>2. UV Sampling Test</h2>
|
||||
<p>Each square below samples a different UV region. We test what color appears.</p>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0, 0, 0.5, 0.5] (Frame 0):</div>
|
||||
<canvas id="uv0" width="64" height="64"></canvas>
|
||||
<div id="result0" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0.5, 0, 1, 0.5] (Frame 1):</div>
|
||||
<canvas id="uv1" width="64" height="64"></canvas>
|
||||
<div id="result1" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0, 0.5, 0.5, 1] (Frame 2):</div>
|
||||
<canvas id="uv2" width="64" height="64"></canvas>
|
||||
<div id="result2" class="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div class="label">UV [0.5, 0.5, 1, 1] (Frame 3):</div>
|
||||
<canvas id="uv3" width="64" height="64"></canvas>
|
||||
<div id="result3" class="result"></div>
|
||||
</div>
|
||||
|
||||
<h2>3. Conclusion</h2>
|
||||
<pre id="conclusion"></pre>
|
||||
|
||||
<script>
|
||||
// Vertex shader
|
||||
const vsSource = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader
|
||||
const fsSource = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTexCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(gl, type, source) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl) {
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
// Create 2x2 test texture data
|
||||
// Row 0: Red, Green
|
||||
// Row 1: Blue, Yellow
|
||||
function createTestTextureData() {
|
||||
const size = 2;
|
||||
const data = new Uint8Array(size * size * 4);
|
||||
// Row 0 (will be uploaded first)
|
||||
data[0] = 255; data[1] = 0; data[2] = 0; data[3] = 255; // Red
|
||||
data[4] = 0; data[5] = 255; data[6] = 0; data[7] = 255; // Green
|
||||
// Row 1
|
||||
data[8] = 0; data[9] = 0; data[10] = 255; data[11] = 255; // Blue
|
||||
data[12] = 255; data[13] = 255; data[14] = 0; data[15] = 255; // Yellow
|
||||
return data;
|
||||
}
|
||||
|
||||
function createTexture(gl, flipY = false) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
// Set FLIP_Y if requested
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY ? 1 : 0);
|
||||
|
||||
const data = createTestTextureData();
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 2, 2, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function renderQuad(gl, program, u0, v0, u1, v1) {
|
||||
gl.useProgram(program);
|
||||
|
||||
const posLoc = gl.getAttribLocation(program, 'aPosition');
|
||||
const texLoc = gl.getAttribLocation(program, 'aTexCoord');
|
||||
|
||||
// Full screen quad
|
||||
const positions = new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1
|
||||
]);
|
||||
|
||||
// UV coordinates - map corners to the specified UV region
|
||||
// Vertex order: bottom-left, bottom-right, top-left, top-left, bottom-right, top-right
|
||||
const texCoords = new Float32Array([
|
||||
u0, v1, u1, v1, u0, v0,
|
||||
u0, v0, u1, v1, u1, v0
|
||||
]);
|
||||
|
||||
const posBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const texBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(texLoc);
|
||||
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
function getCanvasColor(canvas) {
|
||||
const ctx = canvas.getContext('2d') || canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
if (ctx instanceof WebGLRenderingContext || ctx instanceof WebGL2RenderingContext) {
|
||||
const pixels = new Uint8Array(4);
|
||||
ctx.readPixels(32, 32, 1, 1, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels);
|
||||
return { r: pixels[0], g: pixels[1], b: pixels[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colorName(r, g, b) {
|
||||
if (r > 200 && g < 100 && b < 100) return 'RED';
|
||||
if (r < 100 && g > 200 && b < 100) return 'GREEN';
|
||||
if (r < 100 && g < 100 && b > 200) return 'BLUE';
|
||||
if (r > 200 && g > 200 && b < 100) return 'YELLOW';
|
||||
return `RGB(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function runTest() {
|
||||
// Draw texture preview
|
||||
const previewCanvas = document.getElementById('texturePreview');
|
||||
const previewCtx = previewCanvas.getContext('2d');
|
||||
previewCtx.fillStyle = 'red';
|
||||
previewCtx.fillRect(0, 0, 64, 64);
|
||||
previewCtx.fillStyle = 'green';
|
||||
previewCtx.fillRect(64, 0, 64, 64);
|
||||
previewCtx.fillStyle = 'blue';
|
||||
previewCtx.fillRect(0, 64, 64, 64);
|
||||
previewCtx.fillStyle = 'yellow';
|
||||
previewCtx.fillRect(64, 64, 64, 64);
|
||||
|
||||
// Test UV regions
|
||||
const uvTests = [
|
||||
{ id: 'uv0', uv: [0, 0, 0.5, 0.5], expected: 'RED' },
|
||||
{ id: 'uv1', uv: [0.5, 0, 1, 0.5], expected: 'GREEN' },
|
||||
{ id: 'uv2', uv: [0, 0.5, 0.5, 1], expected: 'BLUE' },
|
||||
{ id: 'uv3', uv: [0.5, 0.5, 1, 1], expected: 'YELLOW' }
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of uvTests) {
|
||||
const canvas = document.getElementById(test.id);
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
|
||||
if (!gl) {
|
||||
document.getElementById('result' + test.id.slice(-1)).textContent = 'WebGL not supported';
|
||||
continue;
|
||||
}
|
||||
|
||||
const program = createProgram(gl);
|
||||
const texture = createTexture(gl, false); // No FLIP_Y
|
||||
|
||||
gl.viewport(0, 0, 64, 64);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
renderQuad(gl, program, ...test.uv);
|
||||
|
||||
// Read back color
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(32, 32, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const actual = colorName(pixels[0], pixels[1], pixels[2]);
|
||||
|
||||
const passed = actual === test.expected;
|
||||
results.push({ test: test.id, expected: test.expected, actual, passed });
|
||||
|
||||
const resultEl = document.getElementById('result' + test.id.slice(-1));
|
||||
resultEl.textContent = `Expected: ${test.expected}, Got: ${actual}`;
|
||||
resultEl.className = 'result ' + (passed ? 'pass' : 'fail');
|
||||
}
|
||||
|
||||
// Conclusion
|
||||
const allPassed = results.every(r => r.passed);
|
||||
const conclusionEl = document.getElementById('conclusion');
|
||||
|
||||
if (allPassed) {
|
||||
conclusionEl.textContent = `
|
||||
ALL TESTS PASSED!
|
||||
|
||||
UV coordinate system (without FLIP_Y):
|
||||
- V=0 samples the TOP of the image (row 0)
|
||||
- V=1 samples the BOTTOM of the image (row N)
|
||||
|
||||
This means the current particle UV calculation should be CORRECT:
|
||||
v0 = row * vHeight; // row 0 -> v0=0 -> image top
|
||||
v1 = (row + 1) * vHeight;
|
||||
|
||||
If particles still show wrong frames, the problem is elsewhere.
|
||||
`;
|
||||
} else {
|
||||
const failedTests = results.filter(r => !r.passed);
|
||||
conclusionEl.textContent = `
|
||||
SOME TESTS FAILED!
|
||||
|
||||
${failedTests.map(t => `${t.test}: expected ${t.expected}, got ${t.actual}`).join('\n')}
|
||||
|
||||
This indicates the UV coordinate system behaves differently than expected.
|
||||
The V axis may need to be flipped in particle UV calculation.
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = runTest;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
97
packages/rendering/particle/src/index.ts
Normal file
97
packages/rendering/particle/src/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Core types
|
||||
export { type Particle, createParticle, resetParticle, ParticlePool } from './Particle';
|
||||
|
||||
// Emitter
|
||||
export {
|
||||
ParticleEmitter,
|
||||
EmissionShape,
|
||||
createDefaultEmitterConfig,
|
||||
type EmitterConfig,
|
||||
type ValueRange,
|
||||
type ColorValue
|
||||
} from './ParticleEmitter';
|
||||
|
||||
// Component
|
||||
export { ParticleSystemComponent, ParticleBlendMode, SimulationSpace, RenderSpace, type BurstConfig } from './ParticleSystemComponent';
|
||||
export { ClickFxComponent, ClickFxTriggerMode } from './ClickFxComponent';
|
||||
|
||||
// System
|
||||
export { ParticleUpdateSystem } from './systems/ParticleSystem';
|
||||
export { ClickFxSystem } from './systems/ClickFxSystem';
|
||||
|
||||
// Modules
|
||||
export {
|
||||
type IParticleModule,
|
||||
ColorOverLifetimeModule,
|
||||
type ColorKey,
|
||||
SizeOverLifetimeModule,
|
||||
ScaleCurveType,
|
||||
type ScaleKey,
|
||||
VelocityOverLifetimeModule,
|
||||
VelocityCurveType,
|
||||
type VelocityKey,
|
||||
RotationOverLifetimeModule,
|
||||
NoiseModule,
|
||||
valueNoise2D,
|
||||
noiseHash,
|
||||
TextureSheetAnimationModule,
|
||||
AnimationPlayMode,
|
||||
AnimationLoopMode,
|
||||
CollisionModule,
|
||||
BoundaryType,
|
||||
CollisionBehavior,
|
||||
ForceFieldModule,
|
||||
ForceFieldType,
|
||||
createDefaultForceField,
|
||||
type ForceField,
|
||||
// Physics 2D collision module | 2D 物理碰撞模块
|
||||
Physics2DCollisionModule,
|
||||
Physics2DCollisionBehavior,
|
||||
type IPhysics2DQuery,
|
||||
type ParticleCollisionInfo
|
||||
} from './modules';
|
||||
|
||||
// Rendering
|
||||
export {
|
||||
ParticleRenderDataProvider,
|
||||
type ParticleProviderRenderData
|
||||
} from './rendering';
|
||||
|
||||
// Presets
|
||||
export {
|
||||
type ParticlePreset,
|
||||
PresetCategory,
|
||||
AllPresets,
|
||||
DefaultPreset,
|
||||
FirePreset,
|
||||
SmokePreset,
|
||||
SparklePreset,
|
||||
ExplosionPreset,
|
||||
RainPreset,
|
||||
SnowPreset,
|
||||
MagicAuraPreset,
|
||||
DustPreset,
|
||||
BubblePreset,
|
||||
StarTrailPreset,
|
||||
VortexPreset,
|
||||
LeavesPreset,
|
||||
BouncingPreset,
|
||||
getPresetsByCategory,
|
||||
getPresetNames,
|
||||
getPresetByName,
|
||||
} from './presets';
|
||||
|
||||
// Loaders
|
||||
export {
|
||||
ParticleLoader,
|
||||
ParticleAssetType,
|
||||
createDefaultParticleAsset,
|
||||
type IParticleAsset,
|
||||
type IParticleModuleConfig
|
||||
} from './loaders';
|
||||
|
||||
// Plugin
|
||||
export { ParticleRuntimeModule, ParticlePlugin } from './ParticleRuntimeModule';
|
||||
|
||||
// Service tokens | 服务令牌
|
||||
export { ParticleUpdateSystemToken } from './tokens';
|
||||
243
packages/rendering/particle/src/loaders/ParticleLoader.ts
Normal file
243
packages/rendering/particle/src/loaders/ParticleLoader.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 粒子效果资源加载器
|
||||
* Particle effect asset loader
|
||||
*/
|
||||
|
||||
import type {
|
||||
IAssetLoader,
|
||||
IAssetContent,
|
||||
IAssetParseContext,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { SortingLayers } from '@esengine/engine-core';
|
||||
import { EmissionShape, type ColorValue } from '../ParticleEmitter';
|
||||
import { ParticleBlendMode } from '../ParticleSystemComponent';
|
||||
|
||||
/**
|
||||
* 粒子资产类型常量
|
||||
* Particle asset type constant
|
||||
*/
|
||||
export const ParticleAssetType = 'particle';
|
||||
|
||||
/**
|
||||
* 粒子模块配置
|
||||
* Particle module configuration
|
||||
*/
|
||||
export interface IParticleModuleConfig {
|
||||
/** 模块类型 | Module type */
|
||||
type: string;
|
||||
/** 是否启用 | Enabled */
|
||||
enabled: boolean;
|
||||
/** 模块参数 | Module parameters */
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 爆发配置
|
||||
* Burst configuration
|
||||
*/
|
||||
export interface IBurstConfig {
|
||||
/** 触发时间(秒)| Trigger time (seconds) */
|
||||
time: number;
|
||||
/** 发射数量 | Particle count */
|
||||
count: number;
|
||||
/** 循环次数(0=无限)| Number of cycles (0=infinite) */
|
||||
cycles: number;
|
||||
/** 循环间隔(秒)| Interval between cycles (seconds) */
|
||||
interval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子效果资源数据接口
|
||||
* Particle effect asset data interface
|
||||
*/
|
||||
export interface IParticleAsset {
|
||||
/** 资源版本 | Asset version */
|
||||
version: number;
|
||||
/** 效果名称 | Effect name */
|
||||
name: string;
|
||||
/** 效果描述 | Effect description */
|
||||
description?: string;
|
||||
|
||||
// 基础属性 | Basic properties
|
||||
/** 最大粒子数 | Maximum particles */
|
||||
maxParticles: number;
|
||||
/** 是否循环 | Looping */
|
||||
looping: boolean;
|
||||
/** 持续时间 | Duration in seconds */
|
||||
duration: number;
|
||||
/** 播放速度 | Playback speed */
|
||||
playbackSpeed: number;
|
||||
/** 启动时自动播放 | Auto play on start */
|
||||
playOnAwake: boolean;
|
||||
|
||||
// 发射属性 | Emission properties
|
||||
/** 发射速率 | Emission rate */
|
||||
emissionRate: number;
|
||||
/** 发射形状 | Emission shape */
|
||||
emissionShape: EmissionShape;
|
||||
/** 形状半径 | Shape radius */
|
||||
shapeRadius: number;
|
||||
/** 形状宽度 | Shape width */
|
||||
shapeWidth: number;
|
||||
/** 形状高度 | Shape height */
|
||||
shapeHeight: number;
|
||||
/** 圆锥角度 | Cone angle */
|
||||
shapeAngle: number;
|
||||
|
||||
// 粒子属性 | Particle properties
|
||||
/** 生命时间最小值 | Lifetime min */
|
||||
lifetimeMin: number;
|
||||
/** 生命时间最大值 | Lifetime max */
|
||||
lifetimeMax: number;
|
||||
/** 速度最小值 | Speed min */
|
||||
speedMin: number;
|
||||
/** 速度最大值 | Speed max */
|
||||
speedMax: number;
|
||||
/** 发射方向(度数)| Direction in degrees */
|
||||
direction: number;
|
||||
/** 方向扩散(度数)| Direction spread in degrees */
|
||||
directionSpread: number;
|
||||
/** 缩放最小值 | Scale min */
|
||||
scaleMin: number;
|
||||
/** 缩放最大值 | Scale max */
|
||||
scaleMax: number;
|
||||
/** 重力 X | Gravity X */
|
||||
gravityX: number;
|
||||
/** 重力 Y | Gravity Y */
|
||||
gravityY: number;
|
||||
|
||||
// 颜色属性 | Color properties
|
||||
/** 起始颜色 | Start color */
|
||||
startColor: ColorValue;
|
||||
/** 起始透明度 | Start alpha */
|
||||
startAlpha: number;
|
||||
/** 结束透明度 | End alpha */
|
||||
endAlpha: number;
|
||||
/** 结束缩放 | End scale */
|
||||
endScale: number;
|
||||
|
||||
// 渲染属性 | Rendering properties
|
||||
/** 粒子大小 | Particle size */
|
||||
particleSize: number;
|
||||
/** 混合模式 | Blend mode */
|
||||
blendMode: ParticleBlendMode;
|
||||
/**
|
||||
* 排序层名称
|
||||
* Sorting layer name
|
||||
*/
|
||||
sortingLayer: string;
|
||||
/**
|
||||
* 层内排序顺序
|
||||
* Order within the sorting layer
|
||||
*/
|
||||
orderInLayer: number;
|
||||
/** 纹理资产 GUID | Texture asset GUID */
|
||||
textureGuid?: string;
|
||||
|
||||
// 模块配置 | Module configurations
|
||||
/** 模块列表 | Module list */
|
||||
modules?: IParticleModuleConfig[];
|
||||
|
||||
// 爆发配置 | Burst configurations
|
||||
/** 爆发列表 | Burst list */
|
||||
bursts?: IBurstConfig[];
|
||||
|
||||
// 纹理动画(可选)| Texture animation (optional)
|
||||
/** 纹理图集列数 | Texture sheet columns */
|
||||
textureTilesX?: number;
|
||||
/** 纹理图集行数 | Texture sheet rows */
|
||||
textureTilesY?: number;
|
||||
/** 动画帧率 | Animation frame rate */
|
||||
textureAnimationFPS?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认粒子资源数据
|
||||
* Create default particle asset data
|
||||
*/
|
||||
export function createDefaultParticleAsset(name: string = 'New Particle'): IParticleAsset {
|
||||
return {
|
||||
version: 1,
|
||||
name,
|
||||
description: '',
|
||||
|
||||
maxParticles: 100,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
playOnAwake: true,
|
||||
|
||||
emissionRate: 10,
|
||||
emissionShape: EmissionShape.Point,
|
||||
shapeRadius: 0,
|
||||
shapeWidth: 0,
|
||||
shapeHeight: 0,
|
||||
shapeAngle: 30,
|
||||
|
||||
lifetimeMin: 1,
|
||||
lifetimeMax: 2,
|
||||
speedMin: 50,
|
||||
speedMax: 100,
|
||||
direction: 90,
|
||||
directionSpread: 30,
|
||||
scaleMin: 1,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: { r: 1, g: 1, b: 1, a: 1 },
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 8,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
sortingLayer: SortingLayers.Default,
|
||||
orderInLayer: 0,
|
||||
|
||||
modules: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子效果加载器实现
|
||||
* Particle effect loader implementation
|
||||
*/
|
||||
export class ParticleLoader implements IAssetLoader<IParticleAsset> {
|
||||
readonly supportedType = ParticleAssetType;
|
||||
readonly supportedExtensions = ['.particle', '.particle.json'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* 从文本内容解析粒子资源
|
||||
* Parse particle asset from text content
|
||||
*/
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<IParticleAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Particle content is empty');
|
||||
}
|
||||
|
||||
const jsonData = JSON.parse(content.text) as IParticleAsset;
|
||||
|
||||
// 验证必要字段 | Validate required fields
|
||||
if (jsonData.maxParticles === undefined) {
|
||||
throw new Error('Invalid particle format: missing maxParticles');
|
||||
}
|
||||
|
||||
// 填充默认值 | Fill default values
|
||||
const defaults = createDefaultParticleAsset();
|
||||
return { ...defaults, ...jsonData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放已加载的资源
|
||||
* Dispose loaded asset
|
||||
*/
|
||||
dispose(asset: IParticleAsset): void {
|
||||
// 清理模块引用 | Clean up module references
|
||||
if (asset.modules) {
|
||||
asset.modules.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/rendering/particle/src/loaders/index.ts
Normal file
8
packages/rendering/particle/src/loaders/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
ParticleLoader,
|
||||
ParticleAssetType,
|
||||
createDefaultParticleAsset,
|
||||
type IParticleAsset,
|
||||
type IParticleModuleConfig,
|
||||
type IBurstConfig
|
||||
} from './ParticleLoader';
|
||||
222
packages/rendering/particle/src/modules/CollisionModule.ts
Normal file
222
packages/rendering/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 (p.startVx !== undefined && normalX !== 0) {
|
||||
p.startVx = -(p.startVx) * this.bounceFactor;
|
||||
}
|
||||
if (p.startVy !== undefined && normalY !== 0) {
|
||||
p.startVy = -(p.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/rendering/particle/src/modules/ForceFieldModule.ts
Normal file
305
packages/rendering/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/rendering/particle/src/modules/IParticleModule.ts
Normal file
26
packages/rendering/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/rendering/particle/src/modules/NoiseModule.ts
Normal file
100
packages/rendering/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { Particle } from '../Particle';
|
||||
import type { IParticleModule } from './IParticleModule';
|
||||
|
||||
// 从 physics-rapier2d 导入共享接口
|
||||
// Import shared interface from physics-rapier2d
|
||||
import type { IPhysics2DQuery } from '@esengine/physics-rapier2d';
|
||||
|
||||
// 重新导出以保持向后兼容
|
||||
// Re-export for backward compatibility
|
||||
export type { IPhysics2DQuery };
|
||||
|
||||
/**
|
||||
* 物理碰撞行为
|
||||
* Physics collision behavior
|
||||
*/
|
||||
export enum Physics2DCollisionBehavior {
|
||||
/** 销毁粒子 | Kill particle */
|
||||
Kill = 'kill',
|
||||
/** 反弹 | Bounce */
|
||||
Bounce = 'bounce',
|
||||
/** 停止运动 | Stop movement */
|
||||
Stop = 'stop'
|
||||
}
|
||||
|
||||
/**
|
||||
* 碰撞回调数据
|
||||
* Collision callback data
|
||||
*/
|
||||
export interface ParticleCollisionInfo {
|
||||
/** 粒子引用 | Particle reference */
|
||||
particle: Particle;
|
||||
/** 碰撞位置 | Collision point */
|
||||
point: { x: number; y: number };
|
||||
/** 碰撞法线 | Collision normal */
|
||||
normal: { x: number; y: number };
|
||||
/** 碰撞的实体 ID | Collided entity ID */
|
||||
entityId: number;
|
||||
/** 碰撞体句柄 | Collider handle */
|
||||
colliderHandle: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D 物理碰撞模块
|
||||
* 2D Physics collision module
|
||||
*
|
||||
* 使用 Physics2DService 的查询 API 检测粒子与场景碰撞体的碰撞。
|
||||
* Uses Physics2DService query API to detect particle collisions with scene colliders.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 获取物理服务
|
||||
* const physicsService = scene.services.resolve(Physics2DService);
|
||||
*
|
||||
* // 创建模块
|
||||
* const collisionModule = new Physics2DCollisionModule();
|
||||
* collisionModule.setPhysicsQuery(physicsService);
|
||||
* collisionModule.particleRadius = 4;
|
||||
* collisionModule.behavior = Physics2DCollisionBehavior.Bounce;
|
||||
*
|
||||
* // 添加到粒子系统
|
||||
* particleSystem.addModule(collisionModule);
|
||||
*
|
||||
* // 监听碰撞事件
|
||||
* collisionModule.onCollision = (info) => {
|
||||
* console.log('Particle hit entity:', info.entityId);
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export class Physics2DCollisionModule implements IParticleModule {
|
||||
readonly name = 'Physics2DCollision';
|
||||
enabled = true;
|
||||
|
||||
// ============= 物理查询 | Physics Query =============
|
||||
|
||||
/** 物理查询接口 | Physics query interface */
|
||||
private _physicsQuery: IPhysics2DQuery | null = null;
|
||||
|
||||
// ============= 碰撞设置 | Collision Settings =============
|
||||
|
||||
/**
|
||||
* 粒子碰撞半径
|
||||
* Particle collision radius
|
||||
*
|
||||
* 用于圆形重叠检测的半径
|
||||
*/
|
||||
particleRadius: number = 4;
|
||||
|
||||
/**
|
||||
* 碰撞行为
|
||||
* Collision behavior
|
||||
*/
|
||||
behavior: Physics2DCollisionBehavior = Physics2DCollisionBehavior.Bounce;
|
||||
|
||||
/**
|
||||
* 碰撞层掩码
|
||||
* Collision layer mask
|
||||
*
|
||||
* 默认 0xFFFF 表示与所有层碰撞
|
||||
* Default 0xFFFF means collide with all layers
|
||||
*/
|
||||
collisionMask: number = 0xFFFF;
|
||||
|
||||
/**
|
||||
* 反弹系数 (0-1)
|
||||
* Bounce factor (0-1)
|
||||
*
|
||||
* 1 = 完全弹性,0 = 无弹性
|
||||
* 1 = fully elastic, 0 = no bounce
|
||||
*/
|
||||
bounceFactor: number = 0.6;
|
||||
|
||||
/**
|
||||
* 反弹时的生命损失 (0-1)
|
||||
* Life loss on bounce (0-1)
|
||||
*/
|
||||
lifeLossOnBounce: number = 0;
|
||||
|
||||
/**
|
||||
* 最小速度阈值
|
||||
* Minimum velocity threshold
|
||||
*
|
||||
* 低于此速度时销毁粒子(防止无限小弹跳)
|
||||
* Kill particle when velocity falls below this (prevents infinite tiny bounces)
|
||||
*/
|
||||
minVelocityThreshold: number = 5;
|
||||
|
||||
/**
|
||||
* 使用射线检测代替重叠检测
|
||||
* Use raycast instead of overlap detection
|
||||
*
|
||||
* 射线检测更精确,可防止快速粒子穿透,但性能开销更大
|
||||
* Raycast is more accurate and prevents fast particle tunneling, but more expensive
|
||||
*/
|
||||
useRaycast: boolean = false;
|
||||
|
||||
/**
|
||||
* 检测频率(每 N 帧检测一次)
|
||||
* Detection frequency (detect every N frames)
|
||||
*
|
||||
* 增大此值可提高性能,但降低精度
|
||||
* Increase to improve performance at cost of accuracy
|
||||
*/
|
||||
detectionInterval: number = 1;
|
||||
|
||||
// ============= 内部状态 | Internal State =============
|
||||
|
||||
/** 帧计数器 | Frame counter */
|
||||
private _frameCounter: number = 0;
|
||||
|
||||
/** 需要销毁的粒子 | Particles to kill */
|
||||
private _particlesToKill: Set<Particle> = new Set();
|
||||
|
||||
// ============= 回调 | Callbacks =============
|
||||
|
||||
/**
|
||||
* 碰撞回调
|
||||
* Collision callback
|
||||
*
|
||||
* 每次粒子碰撞时调用
|
||||
*/
|
||||
onCollision: ((info: ParticleCollisionInfo) => void) | null = null;
|
||||
|
||||
// ============= 公开方法 | Public Methods =============
|
||||
|
||||
/**
|
||||
* 设置物理查询接口
|
||||
* Set physics query interface
|
||||
*
|
||||
* @param query - 物理查询接口(通常是 Physics2DService)| Physics query (usually Physics2DService)
|
||||
*/
|
||||
setPhysicsQuery(query: IPhysics2DQuery | null): void {
|
||||
this._physicsQuery = query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要销毁的粒子
|
||||
* Get particles to kill
|
||||
*/
|
||||
getParticlesToKill(): Set<Particle> {
|
||||
return this._particlesToKill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除死亡标记
|
||||
* Clear death flags
|
||||
*/
|
||||
clearDeathFlags(): void {
|
||||
this._particlesToKill.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置帧计数器
|
||||
* Reset frame counter
|
||||
*/
|
||||
resetFrameCounter(): void {
|
||||
this._frameCounter = 0;
|
||||
}
|
||||
|
||||
// ============= IParticleModule 实现 | IParticleModule Implementation =============
|
||||
|
||||
update(p: Particle, dt: number, _normalizedAge: number): void {
|
||||
if (!this._physicsQuery) return;
|
||||
|
||||
// 检测频率控制 | Detection frequency control
|
||||
this._frameCounter++;
|
||||
if (this._frameCounter % this.detectionInterval !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.useRaycast) {
|
||||
this._updateWithRaycast(p, dt);
|
||||
} else {
|
||||
this._updateWithOverlap(p);
|
||||
}
|
||||
}
|
||||
|
||||
// ============= 私有方法 | Private Methods =============
|
||||
|
||||
/**
|
||||
* 使用圆形重叠检测
|
||||
* Update using circle overlap detection
|
||||
*/
|
||||
private _updateWithOverlap(p: Particle): void {
|
||||
if (!this._physicsQuery) return;
|
||||
|
||||
const result = this._physicsQuery.overlapCircle(
|
||||
{ x: p.x, y: p.y },
|
||||
this.particleRadius,
|
||||
this.collisionMask
|
||||
);
|
||||
|
||||
if (result.entityIds.length > 0) {
|
||||
// 发生碰撞 | Collision occurred
|
||||
const entityId = result.entityIds[0];
|
||||
const colliderHandle = result.colliderHandles[0];
|
||||
|
||||
// 估算法线(从粒子速度反向)| Estimate normal (from particle velocity)
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
const normal = speed > 0.001
|
||||
? { x: -p.vx / speed, y: -p.vy / speed }
|
||||
: { x: 0, y: 1 };
|
||||
|
||||
this._handleCollision(p, { x: p.x, y: p.y }, normal, entityId, colliderHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用射线检测
|
||||
* Update using raycast detection
|
||||
*/
|
||||
private _updateWithRaycast(p: Particle, dt: number): void {
|
||||
if (!this._physicsQuery) return;
|
||||
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
if (speed < 0.001) return;
|
||||
|
||||
// 归一化方向 | Normalize direction
|
||||
const direction = { x: p.vx / speed, y: p.vy / speed };
|
||||
const distance = speed * dt + this.particleRadius;
|
||||
|
||||
const hit = this._physicsQuery.raycast(
|
||||
{ x: p.x, y: p.y },
|
||||
direction,
|
||||
distance,
|
||||
this.collisionMask
|
||||
);
|
||||
|
||||
if (hit) {
|
||||
this._handleCollision(p, hit.point, hit.normal, hit.entityId, hit.colliderHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理碰撞
|
||||
* Handle collision
|
||||
*/
|
||||
private _handleCollision(
|
||||
p: Particle,
|
||||
point: { x: number; y: number },
|
||||
normal: { x: number; y: number },
|
||||
entityId: number,
|
||||
colliderHandle: number
|
||||
): void {
|
||||
// 触发回调 | Trigger callback
|
||||
if (this.onCollision) {
|
||||
this.onCollision({
|
||||
particle: p,
|
||||
point,
|
||||
normal,
|
||||
entityId,
|
||||
colliderHandle
|
||||
});
|
||||
}
|
||||
|
||||
switch (this.behavior) {
|
||||
case Physics2DCollisionBehavior.Kill:
|
||||
this._particlesToKill.add(p);
|
||||
break;
|
||||
|
||||
case Physics2DCollisionBehavior.Bounce:
|
||||
this._applyBounce(p, normal);
|
||||
break;
|
||||
|
||||
case Physics2DCollisionBehavior.Stop:
|
||||
p.vx = 0;
|
||||
p.vy = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用反弹
|
||||
* Apply bounce effect
|
||||
*/
|
||||
private _applyBounce(p: Particle, normal: { x: number; y: number }): void {
|
||||
// 反射公式: v' = v - 2 * (v · n) * n
|
||||
// Reflection formula: v' = v - 2 * (v · n) * n
|
||||
const dot = p.vx * normal.x + p.vy * normal.y;
|
||||
|
||||
// 只在粒子朝向表面时反弹 | Only bounce if particle is moving towards surface
|
||||
if (dot < 0) {
|
||||
p.vx = (p.vx - 2 * dot * normal.x) * this.bounceFactor;
|
||||
p.vy = (p.vy - 2 * dot * normal.y) * this.bounceFactor;
|
||||
|
||||
// 稍微移开粒子防止重复碰撞 | Move particle slightly away to prevent repeated collision
|
||||
p.x += normal.x * this.particleRadius * 0.1;
|
||||
p.y += normal.y * this.particleRadius * 0.1;
|
||||
}
|
||||
|
||||
// 应用生命损失 | 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
|
||||
// 存储动画帧信息到粒子 | Store animation frame info to particle
|
||||
// 渲染数据提供者会使用这些值计算实际的 UV | Render data provider will use these to calculate actual UVs
|
||||
p._animFrame = frameIndex;
|
||||
p._animTilesX = this.tilesX;
|
||||
p._animTilesY = this.tilesY;
|
||||
}
|
||||
}
|
||||
@@ -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 (p.startVx === undefined) {
|
||||
p.startVx = p.vx;
|
||||
p.startVy = p.vy;
|
||||
}
|
||||
|
||||
const startVx = p.startVx!;
|
||||
const startVy = p.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.startVx = startVx * dragFactor;
|
||||
p.startVy = 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;
|
||||
}
|
||||
28
packages/rendering/particle/src/modules/index.ts
Normal file
28
packages/rendering/particle/src/modules/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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';
|
||||
export {
|
||||
Physics2DCollisionModule,
|
||||
Physics2DCollisionBehavior,
|
||||
type IPhysics2DQuery,
|
||||
type ParticleCollisionInfo
|
||||
} from './Physics2DCollisionModule';
|
||||
774
packages/rendering/particle/src/presets/index.ts
Normal file
774
packages/rendering/particle/src/presets/index.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* 粒子效果预设
|
||||
* Particle effect presets
|
||||
*
|
||||
* Collection of pre-configured particle system settings.
|
||||
* 预配置的粒子系统设置集合。
|
||||
*/
|
||||
|
||||
import { EmissionShape, type ColorValue } from '../ParticleEmitter';
|
||||
import { ParticleBlendMode, SimulationSpace } from '../ParticleSystemComponent';
|
||||
import { ForceFieldType } from '../modules/ForceFieldModule';
|
||||
import { BoundaryType, CollisionBehavior } from '../modules/CollisionModule';
|
||||
|
||||
/**
|
||||
* 辅助函数:十六进制转 ColorValue
|
||||
* Helper: hex to ColorValue
|
||||
*/
|
||||
function hexToColor(hex: string, alpha = 1): ColorValue {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16) / 255,
|
||||
g: parseInt(h.slice(2, 4), 16) / 255,
|
||||
b: parseInt(h.slice(4, 6), 16) / 255,
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设配置接口
|
||||
* Preset configuration interface
|
||||
*/
|
||||
export interface ParticlePreset {
|
||||
/** 预设名称 | Preset name */
|
||||
name: string;
|
||||
/** 预设描述 | Preset description */
|
||||
description: string;
|
||||
/** 预设分类 | Preset category */
|
||||
category: PresetCategory;
|
||||
/** 预设图标 | Preset icon */
|
||||
icon?: string;
|
||||
|
||||
// 基础属性 | Basic properties
|
||||
maxParticles: number;
|
||||
looping: boolean;
|
||||
duration: number;
|
||||
playbackSpeed: number;
|
||||
|
||||
// 发射属性 | Emission properties
|
||||
emissionRate: number;
|
||||
emissionShape: EmissionShape;
|
||||
shapeRadius: number;
|
||||
shapeWidth: number;
|
||||
shapeHeight: number;
|
||||
shapeAngle: number;
|
||||
|
||||
// 粒子属性 | Particle properties
|
||||
lifetimeMin: number;
|
||||
lifetimeMax: number;
|
||||
speedMin: number;
|
||||
speedMax: number;
|
||||
direction: number;
|
||||
directionSpread: number;
|
||||
scaleMin: number;
|
||||
scaleMax: number;
|
||||
gravityX: number;
|
||||
gravityY: number;
|
||||
|
||||
// 颜色属性 | Color properties
|
||||
startColor: ColorValue;
|
||||
endColor?: ColorValue;
|
||||
startAlpha: number;
|
||||
endAlpha: number;
|
||||
endScale: number;
|
||||
|
||||
// 渲染属性 | Rendering properties
|
||||
particleSize: number;
|
||||
blendMode: ParticleBlendMode;
|
||||
|
||||
// 可选模块 | Optional modules
|
||||
simulationSpace?: SimulationSpace;
|
||||
forceField?: {
|
||||
type: ForceFieldType;
|
||||
strength: number;
|
||||
directionX?: number;
|
||||
directionY?: number;
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
inwardStrength?: number;
|
||||
frequency?: number;
|
||||
};
|
||||
collision?: {
|
||||
boundaryType: BoundaryType;
|
||||
behavior: CollisionBehavior;
|
||||
radius?: number;
|
||||
bounceFactor?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设分类
|
||||
* Preset category
|
||||
*/
|
||||
export enum PresetCategory {
|
||||
/** 自然效果 | Natural effects */
|
||||
Nature = 'nature',
|
||||
/** 魔法效果 | Magic effects */
|
||||
Magic = 'magic',
|
||||
/** 爆炸效果 | Explosion effects */
|
||||
Explosion = 'explosion',
|
||||
/** 环境效果 | Environment effects */
|
||||
Environment = 'environment',
|
||||
/** UI 效果 | UI effects */
|
||||
UI = 'ui',
|
||||
/** 基础效果 | Basic effects */
|
||||
Basic = 'basic',
|
||||
}
|
||||
|
||||
/**
|
||||
* 火焰预设
|
||||
* Fire preset
|
||||
*/
|
||||
export const FirePreset: ParticlePreset = {
|
||||
name: 'Fire',
|
||||
description: 'Realistic fire effect with hot colors',
|
||||
category: PresetCategory.Nature,
|
||||
icon: 'Flame',
|
||||
|
||||
maxParticles: 200,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 40,
|
||||
emissionShape: EmissionShape.Rectangle,
|
||||
shapeRadius: 20,
|
||||
shapeWidth: 30,
|
||||
shapeHeight: 5,
|
||||
shapeAngle: 30,
|
||||
|
||||
lifetimeMin: 0.5,
|
||||
lifetimeMax: 1.2,
|
||||
speedMin: 80,
|
||||
speedMax: 150,
|
||||
direction: 90,
|
||||
directionSpread: 25,
|
||||
scaleMin: 0.8,
|
||||
scaleMax: 1.2,
|
||||
gravityX: 0,
|
||||
gravityY: 50,
|
||||
|
||||
startColor: hexToColor('#ff6600'),
|
||||
endColor: hexToColor('#ff0000'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 0.3,
|
||||
|
||||
particleSize: 16,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
};
|
||||
|
||||
/**
|
||||
* 烟雾预设
|
||||
* Smoke preset
|
||||
*/
|
||||
export const SmokePreset: ParticlePreset = {
|
||||
name: 'Smoke',
|
||||
description: 'Soft rising smoke effect',
|
||||
category: PresetCategory.Nature,
|
||||
icon: 'Cloud',
|
||||
|
||||
maxParticles: 150,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 15,
|
||||
emissionShape: EmissionShape.Circle,
|
||||
shapeRadius: 15,
|
||||
shapeWidth: 30,
|
||||
shapeHeight: 30,
|
||||
shapeAngle: 30,
|
||||
|
||||
lifetimeMin: 2,
|
||||
lifetimeMax: 4,
|
||||
speedMin: 20,
|
||||
speedMax: 50,
|
||||
direction: 90,
|
||||
directionSpread: 20,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 1,
|
||||
gravityX: 10,
|
||||
gravityY: -5,
|
||||
|
||||
startColor: hexToColor('#888888'),
|
||||
endColor: hexToColor('#cccccc'),
|
||||
startAlpha: 0.6,
|
||||
endAlpha: 0,
|
||||
endScale: 2.5,
|
||||
|
||||
particleSize: 32,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 火花预设
|
||||
* Sparkle preset
|
||||
*/
|
||||
export const SparklePreset: ParticlePreset = {
|
||||
name: 'Sparkle',
|
||||
description: 'Twinkling star-like particles',
|
||||
category: PresetCategory.Magic,
|
||||
icon: 'Sparkles',
|
||||
|
||||
maxParticles: 100,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 20,
|
||||
emissionShape: EmissionShape.Circle,
|
||||
shapeRadius: 40,
|
||||
shapeWidth: 40,
|
||||
shapeHeight: 40,
|
||||
shapeAngle: 360,
|
||||
|
||||
lifetimeMin: 0.3,
|
||||
lifetimeMax: 0.8,
|
||||
speedMin: 10,
|
||||
speedMax: 30,
|
||||
direction: 90,
|
||||
directionSpread: 360,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 1.5,
|
||||
gravityX: 0,
|
||||
gravityY: -20,
|
||||
|
||||
startColor: hexToColor('#ffffff'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 0,
|
||||
|
||||
particleSize: 8,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
};
|
||||
|
||||
/**
|
||||
* 爆炸预设
|
||||
* Explosion preset
|
||||
*/
|
||||
export const ExplosionPreset: ParticlePreset = {
|
||||
name: 'Explosion',
|
||||
description: 'Radial burst explosion effect',
|
||||
category: PresetCategory.Explosion,
|
||||
icon: 'Zap',
|
||||
|
||||
maxParticles: 300,
|
||||
looping: false,
|
||||
duration: 1,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 0, // Burst only
|
||||
emissionShape: EmissionShape.Point,
|
||||
shapeRadius: 5,
|
||||
shapeWidth: 10,
|
||||
shapeHeight: 10,
|
||||
shapeAngle: 360,
|
||||
|
||||
lifetimeMin: 0.3,
|
||||
lifetimeMax: 0.8,
|
||||
speedMin: 200,
|
||||
speedMax: 400,
|
||||
direction: 0,
|
||||
directionSpread: 360,
|
||||
scaleMin: 0.8,
|
||||
scaleMax: 1.2,
|
||||
gravityX: 0,
|
||||
gravityY: 200,
|
||||
|
||||
startColor: hexToColor('#ffaa00'),
|
||||
endColor: hexToColor('#ff4400'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 0.2,
|
||||
|
||||
particleSize: 12,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
};
|
||||
|
||||
/**
|
||||
* 雨滴预设
|
||||
* Rain preset
|
||||
*/
|
||||
export const RainPreset: ParticlePreset = {
|
||||
name: 'Rain',
|
||||
description: 'Falling rain drops',
|
||||
category: PresetCategory.Environment,
|
||||
icon: 'CloudRain',
|
||||
|
||||
maxParticles: 500,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 80,
|
||||
emissionShape: EmissionShape.Line,
|
||||
shapeRadius: 200,
|
||||
shapeWidth: 400,
|
||||
shapeHeight: 10,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 0.5,
|
||||
lifetimeMax: 1,
|
||||
speedMin: 400,
|
||||
speedMax: 600,
|
||||
direction: -80,
|
||||
directionSpread: 5,
|
||||
scaleMin: 0.8,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: hexToColor('#88ccff'),
|
||||
startAlpha: 0.7,
|
||||
endAlpha: 0.3,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 6,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 雪花预设
|
||||
* Snow preset
|
||||
*/
|
||||
export const SnowPreset: ParticlePreset = {
|
||||
name: 'Snow',
|
||||
description: 'Gently falling snowflakes',
|
||||
category: PresetCategory.Environment,
|
||||
icon: 'Snowflake',
|
||||
|
||||
maxParticles: 300,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 30,
|
||||
emissionShape: EmissionShape.Line,
|
||||
shapeRadius: 200,
|
||||
shapeWidth: 400,
|
||||
shapeHeight: 10,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 3,
|
||||
lifetimeMax: 6,
|
||||
speedMin: 20,
|
||||
speedMax: 50,
|
||||
direction: -90,
|
||||
directionSpread: 30,
|
||||
scaleMin: 0.3,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: hexToColor('#ffffff'),
|
||||
startAlpha: 0.9,
|
||||
endAlpha: 0.5,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 8,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 魔法光环预设
|
||||
* Magic aura preset
|
||||
*/
|
||||
export const MagicAuraPreset: ParticlePreset = {
|
||||
name: 'Magic Aura',
|
||||
description: 'Mystical swirling aura',
|
||||
category: PresetCategory.Magic,
|
||||
icon: 'Star',
|
||||
|
||||
maxParticles: 100,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 20,
|
||||
emissionShape: EmissionShape.Circle,
|
||||
shapeRadius: 50,
|
||||
shapeWidth: 50,
|
||||
shapeHeight: 50,
|
||||
shapeAngle: 360,
|
||||
|
||||
lifetimeMin: 1,
|
||||
lifetimeMax: 2,
|
||||
speedMin: 5,
|
||||
speedMax: 15,
|
||||
direction: 0,
|
||||
directionSpread: 360,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: -30,
|
||||
|
||||
startColor: hexToColor('#aa55ff'),
|
||||
endColor: hexToColor('#5555ff'),
|
||||
startAlpha: 0.8,
|
||||
endAlpha: 0,
|
||||
endScale: 0.5,
|
||||
|
||||
particleSize: 10,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
};
|
||||
|
||||
/**
|
||||
* 灰尘预设
|
||||
* Dust preset
|
||||
*/
|
||||
export const DustPreset: ParticlePreset = {
|
||||
name: 'Dust',
|
||||
description: 'Floating dust particles',
|
||||
category: PresetCategory.Environment,
|
||||
icon: 'Wind',
|
||||
|
||||
maxParticles: 200,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 15,
|
||||
emissionShape: EmissionShape.Rectangle,
|
||||
shapeRadius: 100,
|
||||
shapeWidth: 200,
|
||||
shapeHeight: 150,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 4,
|
||||
lifetimeMax: 8,
|
||||
speedMin: 5,
|
||||
speedMax: 15,
|
||||
direction: 45,
|
||||
directionSpread: 90,
|
||||
scaleMin: 0.2,
|
||||
scaleMax: 0.6,
|
||||
gravityX: 10,
|
||||
gravityY: -2,
|
||||
|
||||
startColor: hexToColor('#ccbb99'),
|
||||
startAlpha: 0.3,
|
||||
endAlpha: 0.1,
|
||||
endScale: 1.2,
|
||||
|
||||
particleSize: 4,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 泡泡预设
|
||||
* Bubble preset
|
||||
*/
|
||||
export const BubblePreset: ParticlePreset = {
|
||||
name: 'Bubbles',
|
||||
description: 'Rising soap bubbles',
|
||||
category: PresetCategory.Environment,
|
||||
icon: 'CircleDot',
|
||||
|
||||
maxParticles: 50,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 5,
|
||||
emissionShape: EmissionShape.Rectangle,
|
||||
shapeRadius: 40,
|
||||
shapeWidth: 80,
|
||||
shapeHeight: 20,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 2,
|
||||
lifetimeMax: 4,
|
||||
speedMin: 30,
|
||||
speedMax: 60,
|
||||
direction: 90,
|
||||
directionSpread: 20,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 1.5,
|
||||
gravityX: 10,
|
||||
gravityY: -10,
|
||||
|
||||
startColor: hexToColor('#aaddff'),
|
||||
startAlpha: 0.5,
|
||||
endAlpha: 0.2,
|
||||
endScale: 1.3,
|
||||
|
||||
particleSize: 16,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 星轨预设
|
||||
* Star trail preset
|
||||
*/
|
||||
export const StarTrailPreset: ParticlePreset = {
|
||||
name: 'Star Trail',
|
||||
description: 'Glowing star trail effect',
|
||||
category: PresetCategory.Magic,
|
||||
icon: 'Sparkle',
|
||||
|
||||
maxParticles: 150,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 50,
|
||||
emissionShape: EmissionShape.Point,
|
||||
shapeRadius: 2,
|
||||
shapeWidth: 4,
|
||||
shapeHeight: 4,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 0.2,
|
||||
lifetimeMax: 0.6,
|
||||
speedMin: 5,
|
||||
speedMax: 20,
|
||||
direction: 180,
|
||||
directionSpread: 30,
|
||||
scaleMin: 0.8,
|
||||
scaleMax: 1.2,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: hexToColor('#ffff88'),
|
||||
endColor: hexToColor('#ffaa44'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 0.1,
|
||||
|
||||
particleSize: 6,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认预设(简单)
|
||||
* Default preset (simple)
|
||||
*/
|
||||
export const DefaultPreset: ParticlePreset = {
|
||||
name: 'Default',
|
||||
description: 'Basic particle emitter',
|
||||
category: PresetCategory.Basic,
|
||||
icon: 'Circle',
|
||||
|
||||
maxParticles: 100,
|
||||
looping: true,
|
||||
duration: 5,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 10,
|
||||
emissionShape: EmissionShape.Point,
|
||||
shapeRadius: 0,
|
||||
shapeWidth: 0,
|
||||
shapeHeight: 0,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 1,
|
||||
lifetimeMax: 2,
|
||||
speedMin: 50,
|
||||
speedMax: 100,
|
||||
direction: 90,
|
||||
directionSpread: 30,
|
||||
scaleMin: 1,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: hexToColor('#ffffff'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 8,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
};
|
||||
|
||||
/**
|
||||
* 漩涡预设
|
||||
* Vortex preset
|
||||
*/
|
||||
export const VortexPreset: ParticlePreset = {
|
||||
name: 'Vortex',
|
||||
description: 'Swirling vortex with inward pull',
|
||||
category: PresetCategory.Magic,
|
||||
icon: 'Tornado',
|
||||
|
||||
maxParticles: 200,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 30,
|
||||
emissionShape: EmissionShape.Circle,
|
||||
shapeRadius: 80,
|
||||
shapeWidth: 160,
|
||||
shapeHeight: 160,
|
||||
shapeAngle: 360,
|
||||
|
||||
lifetimeMin: 2,
|
||||
lifetimeMax: 4,
|
||||
speedMin: 10,
|
||||
speedMax: 30,
|
||||
direction: 0,
|
||||
directionSpread: 360,
|
||||
scaleMin: 0.5,
|
||||
scaleMax: 1,
|
||||
gravityX: 0,
|
||||
gravityY: 0,
|
||||
|
||||
startColor: hexToColor('#88ccff'),
|
||||
startAlpha: 0.8,
|
||||
endAlpha: 0,
|
||||
endScale: 0.3,
|
||||
|
||||
particleSize: 8,
|
||||
blendMode: ParticleBlendMode.Additive,
|
||||
|
||||
forceField: {
|
||||
type: ForceFieldType.Vortex,
|
||||
strength: 150,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
inwardStrength: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 落叶预设
|
||||
* Falling leaves preset
|
||||
*/
|
||||
export const LeavesPreset: ParticlePreset = {
|
||||
name: 'Leaves',
|
||||
description: 'Falling leaves with wind effect',
|
||||
category: PresetCategory.Environment,
|
||||
icon: 'Leaf',
|
||||
|
||||
maxParticles: 100,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 8,
|
||||
emissionShape: EmissionShape.Line,
|
||||
shapeRadius: 200,
|
||||
shapeWidth: 400,
|
||||
shapeHeight: 10,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 4,
|
||||
lifetimeMax: 8,
|
||||
speedMin: 30,
|
||||
speedMax: 60,
|
||||
direction: -90,
|
||||
directionSpread: 20,
|
||||
scaleMin: 0.6,
|
||||
scaleMax: 1.2,
|
||||
gravityX: 0,
|
||||
gravityY: 20,
|
||||
|
||||
startColor: hexToColor('#dd8844'),
|
||||
startAlpha: 0.9,
|
||||
endAlpha: 0.6,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 12,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
|
||||
forceField: {
|
||||
type: ForceFieldType.Turbulence,
|
||||
strength: 40,
|
||||
frequency: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 弹球预设
|
||||
* Bouncing balls preset
|
||||
*/
|
||||
export const BouncingPreset: ParticlePreset = {
|
||||
name: 'Bouncing',
|
||||
description: 'Bouncing particles in a box',
|
||||
category: PresetCategory.Basic,
|
||||
icon: 'Circle',
|
||||
|
||||
maxParticles: 50,
|
||||
looping: true,
|
||||
duration: 10,
|
||||
playbackSpeed: 1,
|
||||
|
||||
emissionRate: 5,
|
||||
emissionShape: EmissionShape.Point,
|
||||
shapeRadius: 0,
|
||||
shapeWidth: 0,
|
||||
shapeHeight: 0,
|
||||
shapeAngle: 0,
|
||||
|
||||
lifetimeMin: 8,
|
||||
lifetimeMax: 12,
|
||||
speedMin: 100,
|
||||
speedMax: 200,
|
||||
direction: 90,
|
||||
directionSpread: 60,
|
||||
scaleMin: 0.8,
|
||||
scaleMax: 1.2,
|
||||
gravityX: 0,
|
||||
gravityY: 200,
|
||||
|
||||
startColor: hexToColor('#66aaff'),
|
||||
startAlpha: 1,
|
||||
endAlpha: 0.8,
|
||||
endScale: 1,
|
||||
|
||||
particleSize: 16,
|
||||
blendMode: ParticleBlendMode.Normal,
|
||||
|
||||
collision: {
|
||||
boundaryType: BoundaryType.Rectangle,
|
||||
behavior: CollisionBehavior.Bounce,
|
||||
bounceFactor: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 所有预设
|
||||
* All presets
|
||||
*/
|
||||
export const AllPresets: ParticlePreset[] = [
|
||||
DefaultPreset,
|
||||
FirePreset,
|
||||
SmokePreset,
|
||||
SparklePreset,
|
||||
ExplosionPreset,
|
||||
RainPreset,
|
||||
SnowPreset,
|
||||
MagicAuraPreset,
|
||||
DustPreset,
|
||||
BubblePreset,
|
||||
StarTrailPreset,
|
||||
VortexPreset,
|
||||
LeavesPreset,
|
||||
BouncingPreset,
|
||||
];
|
||||
|
||||
/**
|
||||
* 按分类获取预设
|
||||
* Get presets by category
|
||||
*/
|
||||
export function getPresetsByCategory(category: PresetCategory): ParticlePreset[] {
|
||||
return AllPresets.filter(p => p.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预设名称列表
|
||||
* Get preset name list
|
||||
*/
|
||||
export function getPresetNames(): string[] {
|
||||
return AllPresets.map(p => p.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称获取预设
|
||||
* Get preset by name
|
||||
*/
|
||||
export function getPresetByName(name: string): ParticlePreset | undefined {
|
||||
return AllPresets.find(p => p.name === name);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { type ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import { sortingLayerManager, SortingLayers } from '@esengine/engine-core';
|
||||
|
||||
/**
|
||||
* 粒子渲染数据(与 EngineRenderSystem 兼容)
|
||||
* Particle render data (compatible with EngineRenderSystem)
|
||||
*
|
||||
* This interface is compatible with ProviderRenderData from EngineRenderSystem.
|
||||
* 此接口与 EngineRenderSystem 的 ProviderRenderData 兼容。
|
||||
*/
|
||||
export interface ParticleProviderRenderData {
|
||||
transforms: Float32Array;
|
||||
textureIds: Uint32Array;
|
||||
uvs: Float32Array;
|
||||
colors: Uint32Array;
|
||||
tileCount: number;
|
||||
/**
|
||||
* 排序层名称
|
||||
* Sorting layer name
|
||||
*/
|
||||
sortingLayer: string;
|
||||
/**
|
||||
* 层内排序顺序
|
||||
* Order within the sorting layer
|
||||
*/
|
||||
orderInLayer: number;
|
||||
/** 纹理 GUID | Texture GUID */
|
||||
textureGuid?: string;
|
||||
/**
|
||||
* 是否在屏幕空间渲染
|
||||
* Whether to render in screen space
|
||||
*/
|
||||
bScreenSpace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform 接口(避免直接依赖 engine-core)
|
||||
* Transform interface (avoid direct dependency on engine-core)
|
||||
*/
|
||||
interface ITransformLike {
|
||||
worldPosition?: { x: number; y: number };
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染数据提供者接口(与 EngineRenderSystem 兼容)
|
||||
* Render data provider interface (compatible with EngineRenderSystem)
|
||||
*
|
||||
* This interface matches IRenderDataProvider from @esengine/ecs-engine-bindgen.
|
||||
* 此接口与 @esengine/ecs-engine-bindgen 的 IRenderDataProvider 匹配。
|
||||
*/
|
||||
export interface IRenderDataProvider {
|
||||
getRenderData(): readonly ParticleProviderRenderData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子渲染数据提供者
|
||||
* Particle render data provider
|
||||
*
|
||||
* Collects render data from all active particle systems.
|
||||
* 从所有活跃的粒子系统收集渲染数据。
|
||||
*
|
||||
* Implements IRenderDataProvider for integration with EngineRenderSystem.
|
||||
* 实现 IRenderDataProvider 以便与 EngineRenderSystem 集成。
|
||||
*/
|
||||
export class ParticleRenderDataProvider implements IRenderDataProvider {
|
||||
private _particleSystems: Map<ParticleSystemComponent, ITransformLike> = new Map();
|
||||
private _renderDataCache: ParticleProviderRenderData[] = [];
|
||||
private _dirty: boolean = true;
|
||||
|
||||
// 预分配的缓冲区 | Pre-allocated buffers
|
||||
private _maxParticles: number = 0;
|
||||
private _transforms: Float32Array = new Float32Array(0);
|
||||
private _textureIds: Uint32Array = new Uint32Array(0);
|
||||
private _uvs: Float32Array = new Float32Array(0);
|
||||
private _colors: Uint32Array = new Uint32Array(0);
|
||||
|
||||
/**
|
||||
* 注册粒子系统
|
||||
* Register particle system
|
||||
*/
|
||||
register(component: ParticleSystemComponent, transform: ITransformLike): void {
|
||||
this._particleSystems.set(component, transform);
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销粒子系统
|
||||
* Unregister particle system
|
||||
*/
|
||||
unregister(component: ParticleSystemComponent): void {
|
||||
this._particleSystems.delete(component);
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记为脏
|
||||
* Mark as dirty
|
||||
*/
|
||||
markDirty(): void {
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取渲染数据
|
||||
* Get render data
|
||||
*/
|
||||
getRenderData(): readonly ParticleProviderRenderData[] {
|
||||
this._updateRenderData();
|
||||
return this._renderDataCache;
|
||||
}
|
||||
|
||||
private _updateRenderData(): void {
|
||||
this._renderDataCache.length = 0;
|
||||
|
||||
// 计算总粒子数 | Calculate total particle count
|
||||
let totalParticles = 0;
|
||||
for (const [component] of this._particleSystems) {
|
||||
if (component.isPlaying && component.pool) {
|
||||
totalParticles += component.pool.activeCount;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (totalParticles === 0) return;
|
||||
|
||||
// 确保缓冲区足够大 | Ensure buffers are large enough
|
||||
if (totalParticles > this._maxParticles) {
|
||||
this._maxParticles = Math.max(totalParticles, this._maxParticles * 2, 1000);
|
||||
this._transforms = new Float32Array(this._maxParticles * 7);
|
||||
this._textureIds = new Uint32Array(this._maxParticles);
|
||||
this._uvs = new Float32Array(this._maxParticles * 4);
|
||||
this._colors = new Uint32Array(this._maxParticles);
|
||||
}
|
||||
|
||||
// 按 sortKey + renderSpace 分组
|
||||
// Group by sortKey + renderSpace
|
||||
// 使用 string key 来区分不同渲染空间的相同 sortKey
|
||||
// Use string key to distinguish same sortKey with different render spaces
|
||||
const groups = new Map<string, {
|
||||
component: ParticleSystemComponent;
|
||||
transform: ITransformLike;
|
||||
sortingLayer: string;
|
||||
orderInLayer: number;
|
||||
bScreenSpace: boolean;
|
||||
sortKey: number;
|
||||
}[]>();
|
||||
|
||||
for (const [component, transform] of this._particleSystems) {
|
||||
if (!component.isPlaying || !component.pool || component.pool.activeCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sortingLayer = component.sortingLayer ?? SortingLayers.Default;
|
||||
const orderInLayer = component.orderInLayer ?? 0;
|
||||
const sortKey = sortingLayerManager.getSortKey(sortingLayer, orderInLayer);
|
||||
const bScreenSpace = component.renderSpace === RenderSpace.Screen;
|
||||
const groupKey = `${sortKey}:${bScreenSpace ? 'screen' : 'world'}`;
|
||||
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, []);
|
||||
}
|
||||
groups.get(groupKey)!.push({ component, transform, sortingLayer, orderInLayer, bScreenSpace, sortKey });
|
||||
}
|
||||
|
||||
// 按 sortKey 排序后生成渲染数据
|
||||
// Generate render data sorted by sortKey
|
||||
// 字符串 key 格式: "sortKey:space",按 sortKey 数值排序
|
||||
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||
const aKey = parseInt(a.split(':')[0], 10);
|
||||
const bKey = parseInt(b.split(':')[0], 10);
|
||||
return aKey - bKey;
|
||||
});
|
||||
|
||||
for (const groupKey of sortedKeys) {
|
||||
const systems = groups.get(groupKey)!;
|
||||
let particleIndex = 0;
|
||||
|
||||
for (const { component } of systems) {
|
||||
const pool = component.pool!;
|
||||
const size = component.particleSize;
|
||||
|
||||
pool.forEachActive((p) => {
|
||||
const tOffset = particleIndex * 7;
|
||||
const uvOffset = particleIndex * 4;
|
||||
|
||||
// Transform: x, y, rotation, scaleX, scaleY, originX, originY
|
||||
this._transforms[tOffset] = p.x;
|
||||
this._transforms[tOffset + 1] = p.y;
|
||||
this._transforms[tOffset + 2] = p.rotation;
|
||||
this._transforms[tOffset + 3] = size * p.scaleX;
|
||||
this._transforms[tOffset + 4] = size * p.scaleY;
|
||||
this._transforms[tOffset + 5] = 0.5; // originX
|
||||
this._transforms[tOffset + 6] = 0.5; // originY
|
||||
|
||||
// Texture ID: 优先使用组件上预加载的 textureId,否则让 EngineRenderSystem 通过 textureGuid 解析
|
||||
// Prefer using pre-loaded textureId from component, otherwise let EngineRenderSystem resolve via textureGuid
|
||||
this._textureIds[particleIndex] = component.textureId;
|
||||
|
||||
// UV - 支持精灵图帧动画 | Support spritesheet animation
|
||||
if (p._animTilesX !== undefined && p._animTilesY !== undefined && p._animFrame !== undefined) {
|
||||
// 计算帧的 UV 坐标 | Calculate frame UV coordinates
|
||||
// WebGL 纹理坐标:V=0 采样纹理行0(即图像顶部)
|
||||
// WebGL texture coords: V=0 samples texture row 0 (image top)
|
||||
const tilesX = p._animTilesX;
|
||||
const tilesY = p._animTilesY;
|
||||
const frame = p._animFrame;
|
||||
const col = frame % tilesX;
|
||||
const row = Math.floor(frame / tilesX);
|
||||
const uWidth = 1 / tilesX;
|
||||
const vHeight = 1 / tilesY;
|
||||
|
||||
// UV: u0, v0, u1, v1
|
||||
const u0 = col * uWidth;
|
||||
const u1 = (col + 1) * uWidth;
|
||||
const v0 = row * vHeight;
|
||||
const v1 = (row + 1) * vHeight;
|
||||
|
||||
this._uvs[uvOffset] = u0;
|
||||
this._uvs[uvOffset + 1] = v0;
|
||||
this._uvs[uvOffset + 2] = u1;
|
||||
this._uvs[uvOffset + 3] = v1;
|
||||
} else {
|
||||
// 默认:使用完整纹理 | Default: use full texture
|
||||
this._uvs[uvOffset] = 0;
|
||||
this._uvs[uvOffset + 1] = 0;
|
||||
this._uvs[uvOffset + 2] = 1;
|
||||
this._uvs[uvOffset + 3] = 1;
|
||||
}
|
||||
|
||||
// Color (packed ABGR for WebGL)
|
||||
this._colors[particleIndex] = Color.packABGR(
|
||||
Math.round(p.r * 255),
|
||||
Math.round(p.g * 255),
|
||||
Math.round(p.b * 255),
|
||||
p.alpha
|
||||
);
|
||||
|
||||
particleIndex++;
|
||||
});
|
||||
}
|
||||
|
||||
if (particleIndex > 0) {
|
||||
// 获取纹理 GUID | Get texture GUID
|
||||
const firstSystem = systems[0];
|
||||
const firstComponent = firstSystem?.component;
|
||||
const asset = firstComponent?.loadedAsset as { textureGuid?: string } | null;
|
||||
const textureGuid = asset?.textureGuid || firstComponent?.textureGuid || undefined;
|
||||
|
||||
// 创建当前组的渲染数据 | Create render data for current group
|
||||
const renderData: ParticleProviderRenderData = {
|
||||
transforms: this._transforms.subarray(0, particleIndex * 7),
|
||||
textureIds: this._textureIds.subarray(0, particleIndex),
|
||||
uvs: this._uvs.subarray(0, particleIndex * 4),
|
||||
colors: this._colors.subarray(0, particleIndex),
|
||||
tileCount: particleIndex,
|
||||
sortingLayer: firstSystem?.sortingLayer ?? SortingLayers.Default,
|
||||
orderInLayer: firstSystem?.orderInLayer ?? 0,
|
||||
textureGuid,
|
||||
bScreenSpace: firstSystem?.bScreenSpace ?? false
|
||||
};
|
||||
|
||||
this._renderDataCache.push(renderData);
|
||||
}
|
||||
}
|
||||
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理
|
||||
* Cleanup
|
||||
*/
|
||||
dispose(): void {
|
||||
this._particleSystems.clear();
|
||||
this._renderDataCache.length = 0;
|
||||
}
|
||||
}
|
||||
5
packages/rendering/particle/src/rendering/index.ts
Normal file
5
packages/rendering/particle/src/rendering/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ParticleRenderDataProvider,
|
||||
type ParticleProviderRenderData,
|
||||
type IRenderDataProvider
|
||||
} from './ParticleRenderDataProvider';
|
||||
427
packages/rendering/particle/src/systems/ClickFxSystem.ts
Normal file
427
packages/rendering/particle/src/systems/ClickFxSystem.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* 点击特效系统 - 处理点击输入并生成粒子效果
|
||||
* Click FX System - Handles click input and spawns particle effects
|
||||
*
|
||||
* 监听用户点击/触摸事件,在点击位置创建粒子效果实体。
|
||||
* Listens for user click/touch events and creates particle effect entities at click position.
|
||||
*/
|
||||
|
||||
import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
|
||||
import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
|
||||
import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
|
||||
import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
|
||||
|
||||
import { CoordinateServiceToken, type ICoordinateService } from '@esengine/ecs-engine-bindgen';
|
||||
|
||||
// ============================================================================
|
||||
// 本地服务令牌定义 | Local Service Token Definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* EngineRenderSystem 接口(最小定义,用于获取 UI Canvas 尺寸)
|
||||
* EngineRenderSystem interface (minimal definition for getting UI canvas size)
|
||||
*/
|
||||
interface IEngineRenderSystem {
|
||||
getUICanvasSize(): { width: number; height: number };
|
||||
}
|
||||
|
||||
// RenderSystem 令牌(与 ecs-engine-bindgen 中的一致)
|
||||
// RenderSystem token (consistent with ecs-engine-bindgen)
|
||||
const RenderSystemToken = createServiceToken<IEngineRenderSystem>('renderSystem');
|
||||
|
||||
/**
|
||||
* 点击特效系统
|
||||
* Click FX System
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在场景中添加系统
|
||||
* scene.addSystem(new ClickFxSystem());
|
||||
*
|
||||
* // 创建带有 ClickFxComponent 的实体
|
||||
* const clickFxEntity = scene.createEntity('ClickFx');
|
||||
* const clickFx = clickFxEntity.addComponent(new ClickFxComponent());
|
||||
* clickFx.particleAssets = ['particle-guid-1', 'particle-guid-2'];
|
||||
* ```
|
||||
*/
|
||||
@ECSSystem('ClickFx', { updateOrder: 100 })
|
||||
export class ClickFxSystem extends EntitySystem {
|
||||
private _coordinateService: ICoordinateService | null = null;
|
||||
private _renderSystem: IEngineRenderSystem | null = null;
|
||||
private _entitiesToDestroy: Entity[] = [];
|
||||
private _canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(ClickFxComponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置服务注册表(用于获取 CoordinateService 和 RenderSystem)
|
||||
* Set service registry (for getting CoordinateService and RenderSystem)
|
||||
*/
|
||||
setServiceRegistry(services: PluginServiceRegistry): void {
|
||||
this._coordinateService = services.get(CoordinateServiceToken) ?? null;
|
||||
this._renderSystem = services.get(RenderSystemToken) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置坐标服务(直接注入)
|
||||
* Set coordinate service (direct injection)
|
||||
*/
|
||||
setCoordinateService(coordinateService: ICoordinateService): void {
|
||||
this._coordinateService = coordinateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 RenderSystem(直接注入)
|
||||
* Set RenderSystem (direct injection)
|
||||
*/
|
||||
setRenderSystem(renderSystem: IEngineRenderSystem): void {
|
||||
this._renderSystem = renderSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Canvas 元素(用于计算相对坐标)
|
||||
* Set canvas element (for calculating relative coordinates)
|
||||
*/
|
||||
setCanvas(canvas: HTMLCanvasElement): void {
|
||||
this._canvas = canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该处理
|
||||
* Check if should process
|
||||
*
|
||||
* 只在运行时模式(非编辑器模式)下处理点击事件
|
||||
* Only process click events in runtime mode (not editor mode)
|
||||
*/
|
||||
protected override onCheckProcessing(): boolean {
|
||||
// 编辑器模式下不处理(预览时也不处理,只有 Play 模式才处理)
|
||||
// Don't process in editor mode (including preview, only in Play mode)
|
||||
if (this.scene?.isEditorMode) {
|
||||
return false;
|
||||
}
|
||||
return super.onCheckProcessing();
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
// 处理延迟销毁 | Process delayed destruction
|
||||
if (this._entitiesToDestroy.length > 0 && this.scene) {
|
||||
this.scene.destroyEntities(this._entitiesToDestroy);
|
||||
this._entitiesToDestroy = [];
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const clickFx = entity.getComponent(ClickFxComponent);
|
||||
if (!clickFx || !clickFx.fxEnabled) continue;
|
||||
|
||||
// 清理过期的特效 | Clean up expired effects
|
||||
this._cleanupExpiredEffects(clickFx);
|
||||
|
||||
// 检查触发条件 | Check trigger conditions
|
||||
const triggered = this._checkTrigger(clickFx);
|
||||
if (!triggered) continue;
|
||||
|
||||
// 检查是否可以添加新特效 | Check if can add new effect
|
||||
if (!clickFx.canAddEffect()) continue;
|
||||
|
||||
// 获取点击/触摸位置 | Get click/touch position
|
||||
const screenPos = this._getInputPosition(clickFx);
|
||||
if (!screenPos) continue;
|
||||
|
||||
// 转换为 canvas 相对坐标 | Convert to canvas-relative coordinates
|
||||
const canvasPos = this._windowToCanvas(screenPos.x, screenPos.y);
|
||||
|
||||
// 应用偏移 | Apply offset
|
||||
canvasPos.x += clickFx.positionOffset.x;
|
||||
canvasPos.y += clickFx.positionOffset.y;
|
||||
|
||||
// 创建粒子效果(使用屏幕空间坐标)
|
||||
// Create particle effect (using screen space coordinates)
|
||||
this._spawnEffect(clickFx, canvasPos.x, canvasPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口坐标转 canvas 相对坐标
|
||||
* Window to canvas-relative coordinate conversion
|
||||
*
|
||||
* 将窗口坐标转换为 UI Canvas 的像素坐标。
|
||||
* Converts window coordinates to UI canvas pixel coordinates.
|
||||
*/
|
||||
private _windowToCanvas(windowX: number, windowY: number): { x: number; y: number } {
|
||||
// 获取 UI Canvas 尺寸 | Get UI canvas size
|
||||
const canvasSize = this._renderSystem?.getUICanvasSize();
|
||||
const uiCanvasWidth = canvasSize?.width ?? 1920;
|
||||
const uiCanvasHeight = canvasSize?.height ?? 1080;
|
||||
|
||||
let canvasX = windowX;
|
||||
let canvasY = windowY;
|
||||
|
||||
if (this._canvas) {
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
// 计算 CSS 坐标 | Calculate CSS coordinates
|
||||
canvasX = windowX - rect.left;
|
||||
canvasY = windowY - rect.top;
|
||||
|
||||
// 将 CSS 坐标映射到 UI Canvas 坐标
|
||||
// Map CSS coordinates to UI canvas coordinates
|
||||
// UI Canvas 保持宽高比,可能会有 letterbox/pillarbox
|
||||
// UI Canvas maintains aspect ratio, may have letterbox/pillarbox
|
||||
const cssWidth = rect.width;
|
||||
const cssHeight = rect.height;
|
||||
|
||||
// 计算 UI Canvas 在 CSS 坐标中的实际显示区域
|
||||
// Calculate actual display area of UI Canvas in CSS coordinates
|
||||
const uiAspect = uiCanvasWidth / uiCanvasHeight;
|
||||
const cssAspect = cssWidth / cssHeight;
|
||||
|
||||
let displayWidth: number;
|
||||
let displayHeight: number;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (cssAspect > uiAspect) {
|
||||
// CSS 更宽,pillarbox(左右黑边)
|
||||
// CSS is wider, pillarbox (black bars on sides)
|
||||
displayHeight = cssHeight;
|
||||
displayWidth = cssHeight * uiAspect;
|
||||
offsetX = (cssWidth - displayWidth) / 2;
|
||||
} else {
|
||||
// CSS 更高,letterbox(上下黑边)
|
||||
// CSS is taller, letterbox (black bars on top/bottom)
|
||||
displayWidth = cssWidth;
|
||||
displayHeight = cssWidth / uiAspect;
|
||||
offsetY = (cssHeight - displayHeight) / 2;
|
||||
}
|
||||
|
||||
// 转换为 UI Canvas 坐标
|
||||
// Convert to UI canvas coordinates
|
||||
canvasX = ((canvasX - offsetX) / displayWidth) * uiCanvasWidth;
|
||||
canvasY = ((canvasY - offsetY) / displayHeight) * uiCanvasHeight;
|
||||
}
|
||||
|
||||
return { x: canvasX, y: canvasY };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查触发条件
|
||||
* Check trigger conditions
|
||||
*/
|
||||
private _checkTrigger(clickFx: ClickFxComponent): boolean {
|
||||
const mode = clickFx.triggerMode;
|
||||
|
||||
// 首先检查鼠标是否在 Canvas 内
|
||||
// First check if mouse is within canvas bounds
|
||||
if (!this._isMouseInCanvas()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case ClickFxTriggerMode.LeftClick:
|
||||
return Input.isMouseButtonJustPressed(MouseButton.Left);
|
||||
|
||||
case ClickFxTriggerMode.RightClick:
|
||||
return Input.isMouseButtonJustPressed(MouseButton.Right);
|
||||
|
||||
case ClickFxTriggerMode.AnyClick:
|
||||
return Input.isMouseButtonJustPressed(MouseButton.Left) ||
|
||||
Input.isMouseButtonJustPressed(MouseButton.Middle) ||
|
||||
Input.isMouseButtonJustPressed(MouseButton.Right);
|
||||
|
||||
case ClickFxTriggerMode.Touch:
|
||||
return this._checkTouchStart();
|
||||
|
||||
case ClickFxTriggerMode.All:
|
||||
return Input.isMouseButtonJustPressed(MouseButton.Left) ||
|
||||
Input.isMouseButtonJustPressed(MouseButton.Middle) ||
|
||||
Input.isMouseButtonJustPressed(MouseButton.Right) ||
|
||||
this._checkTouchStart();
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查鼠标是否在 Canvas 内
|
||||
* Check if mouse is within canvas bounds
|
||||
*/
|
||||
private _isMouseInCanvas(): boolean {
|
||||
if (!this._canvas) {
|
||||
return true; // 没有 canvas 引用时,默认允许(兼容旧行为)
|
||||
}
|
||||
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const mouseX = Input.mousePosition.x;
|
||||
const mouseY = Input.mousePosition.y;
|
||||
|
||||
// 检查鼠标是否在 canvas 边界内
|
||||
// Check if mouse is within canvas bounds
|
||||
return mouseX >= rect.left &&
|
||||
mouseX <= rect.right &&
|
||||
mouseY >= rect.top &&
|
||||
mouseY <= rect.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有新的触摸开始
|
||||
* Check if there's a new touch start
|
||||
*/
|
||||
private _checkTouchStart(): boolean {
|
||||
for (const [id] of Input.touches) {
|
||||
if (Input.isTouchJustStarted(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取输入位置
|
||||
* Get input position
|
||||
*/
|
||||
private _getInputPosition(clickFx: ClickFxComponent): { x: number; y: number } | null {
|
||||
const mode = clickFx.triggerMode;
|
||||
|
||||
// 优先检查触摸 | Check touch first
|
||||
if (mode === ClickFxTriggerMode.Touch || mode === ClickFxTriggerMode.All) {
|
||||
for (const [id, touch] of Input.touches) {
|
||||
if (Input.isTouchJustStarted(id)) {
|
||||
return { x: touch.x, y: touch.y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查鼠标 | Check mouse
|
||||
if (mode !== ClickFxTriggerMode.Touch) {
|
||||
return { x: Input.mousePosition.x, y: Input.mousePosition.y };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成粒子效果
|
||||
* Spawn particle effect
|
||||
*
|
||||
* 点击特效使用屏幕空间渲染,坐标相对于 UI Canvas 中心。
|
||||
* Click effects use screen space rendering, coordinates relative to UI canvas center.
|
||||
*/
|
||||
private _spawnEffect(clickFx: ClickFxComponent, screenX: number, screenY: number): void {
|
||||
const particleGuid = clickFx.getNextParticleAsset();
|
||||
if (!particleGuid) {
|
||||
console.warn('[ClickFxSystem] No particle assets configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scene) {
|
||||
console.warn('[ClickFxSystem] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 UI Canvas 尺寸 | Get UI canvas size
|
||||
const canvasSize = this._renderSystem?.getUICanvasSize();
|
||||
const canvasWidth = canvasSize?.width ?? 1920;
|
||||
const canvasHeight = canvasSize?.height ?? 1080;
|
||||
|
||||
// 将屏幕坐标转换为屏幕空间坐标(相对于 UI Canvas 中心)
|
||||
// Convert screen coords to screen space coords (relative to UI canvas center)
|
||||
// 屏幕空间坐标系:中心为 (0, 0),Y 轴向上
|
||||
// Screen space coordinate system: center at (0, 0), Y-axis up
|
||||
const screenSpaceX = screenX - canvasWidth / 2;
|
||||
const screenSpaceY = canvasHeight / 2 - screenY; // Y 翻转
|
||||
|
||||
// 创建特效实体 | Create effect entity
|
||||
const effectEntity = this.scene.createEntity(`ClickFx_${Date.now()}`);
|
||||
|
||||
// 添加 Transform(使用屏幕空间坐标)| Add Transform (using screen space coords)
|
||||
const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
|
||||
transform.setScale(clickFx.scale, clickFx.scale, 1);
|
||||
|
||||
// 创建 ParticleSystemComponent 并预先设置 GUID(在添加到实体前)
|
||||
// Create ParticleSystemComponent and set GUID before adding to entity
|
||||
// 这样 ParticleUpdateSystem.onAdded 触发时已经有 GUID 了
|
||||
// So ParticleUpdateSystem.onAdded has the GUID when triggered
|
||||
const particleSystem = new ParticleSystemComponent();
|
||||
particleSystem.particleAssetGuid = particleGuid;
|
||||
particleSystem.autoPlay = true;
|
||||
// 使用 ScreenOverlay 层和屏幕空间渲染
|
||||
// Use ScreenOverlay layer and screen space rendering
|
||||
particleSystem.sortingLayer = SortingLayers.ScreenOverlay;
|
||||
particleSystem.orderInLayer = 0;
|
||||
particleSystem.renderSpace = RenderSpace.Screen;
|
||||
|
||||
// 添加组件到实体(触发 ParticleUpdateSystem 的初始化和资产加载)
|
||||
// Add component to entity (triggers ParticleUpdateSystem initialization and asset loading)
|
||||
effectEntity.addComponent(particleSystem);
|
||||
|
||||
// 记录活跃特效 | Record active effect
|
||||
clickFx.addActiveEffect(effectEntity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的特效
|
||||
* Clean up expired effects
|
||||
*/
|
||||
private _cleanupExpiredEffects(clickFx: ClickFxComponent): void {
|
||||
if (!this.scene) return;
|
||||
|
||||
const now = Date.now();
|
||||
const lifetimeMs = clickFx.effectLifetime * 1000;
|
||||
const effectsToRemove: number[] = [];
|
||||
|
||||
for (const effect of clickFx.getActiveEffects()) {
|
||||
const age = now - effect.startTime;
|
||||
|
||||
if (age >= lifetimeMs) {
|
||||
// 标记为需要移除 | Mark for removal
|
||||
effectsToRemove.push(effect.entityId);
|
||||
|
||||
// 查找并销毁实体 | Find and destroy entity
|
||||
const entity = this.scene.findEntityById(effect.entityId);
|
||||
if (entity) {
|
||||
// 停止粒子系统 | Stop particle system
|
||||
const particleSystem = entity.getComponent(ParticleSystemComponent);
|
||||
if (particleSystem) {
|
||||
particleSystem.stop(true);
|
||||
}
|
||||
|
||||
// 添加到销毁队列 | Add to destroy queue
|
||||
this._entitiesToDestroy.push(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从记录中移除 | Remove from records
|
||||
for (const entityId of effectsToRemove) {
|
||||
clickFx.removeActiveEffect(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDestroy(): void {
|
||||
// 清理所有特效 | Clean up all effects
|
||||
if (this.scene) {
|
||||
const entities = this.scene.entities.buffer;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity = entities[i];
|
||||
const clickFx = entity.getComponent(ClickFxComponent);
|
||||
if (clickFx) {
|
||||
for (const effect of clickFx.getActiveEffects()) {
|
||||
const effectEntity = this.scene.findEntityById(effect.entityId);
|
||||
if (effectEntity) {
|
||||
this._entitiesToDestroy.push(effectEntity);
|
||||
}
|
||||
}
|
||||
clickFx.clearActiveEffects();
|
||||
}
|
||||
}
|
||||
|
||||
// 立即销毁 | Destroy immediately
|
||||
if (this._entitiesToDestroy.length > 0) {
|
||||
this.scene.destroyEntities(this._entitiesToDestroy);
|
||||
this._entitiesToDestroy = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
545
packages/rendering/particle/src/systems/ParticleSystem.ts
Normal file
545
packages/rendering/particle/src/systems/ParticleSystem.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { EntitySystem, Matcher, ECSSystem, Time, Entity, type Component, type ComponentType } from '@esengine/ecs-framework';
|
||||
import type { IEngineIntegration, ITextureService } from '@esengine/ecs-engine-bindgen';
|
||||
import type { IAssetManager } from '@esengine/asset-system';
|
||||
import { ParticleSystemComponent } from '../ParticleSystemComponent';
|
||||
import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider';
|
||||
import { Physics2DCollisionModule, type IPhysics2DQuery } from '../modules/Physics2DCollisionModule';
|
||||
import type { IParticleAsset } from '../loaders/ParticleLoader';
|
||||
|
||||
/**
|
||||
* 默认粒子纹理 ID
|
||||
* Default particle texture ID
|
||||
*/
|
||||
const DEFAULT_PARTICLE_TEXTURE_ID = 99999;
|
||||
|
||||
/**
|
||||
* 角度转换常量
|
||||
* Angle conversion constants
|
||||
*/
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* 生成默认粒子纹理的 Data URL(渐变圆形)
|
||||
* Generate default particle texture Data URL (gradient circle)
|
||||
*/
|
||||
function generateDefaultParticleTextureDataURL(): string {
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
// 创建径向渐变 | Create radial gradient
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2, size / 2, 0,
|
||||
size / 2, size / 2, size / 2
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
|
||||
gradient.addColorStop(0.4, 'rgba(255, 255, 255, 0.8)');
|
||||
gradient.addColorStop(0.7, 'rgba(255, 255, 255, 0.3)');
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform 组件接口(避免直接依赖 engine-core)
|
||||
* Transform component interface (avoid direct dependency on engine-core)
|
||||
*/
|
||||
interface ITransformComponent {
|
||||
worldPosition?: { x: number; y: number; z: number };
|
||||
position: { x: number; y: number; z: number };
|
||||
/** 世界旋转(Vector3,z 分量为 2D 旋转弧度)| World rotation (Vector3, z component is 2D rotation in radians) */
|
||||
worldRotation?: { x: number; y: number; z: number };
|
||||
/** 本地旋转(Vector3)| Local rotation (Vector3) */
|
||||
rotation?: { x: number; y: number; z: number };
|
||||
/** 世界缩放 | World scale */
|
||||
worldScale?: { x: number; y: number; z: number };
|
||||
/** 本地缩放 | Local scale */
|
||||
scale?: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子更新系统
|
||||
* Particle update system
|
||||
*
|
||||
* Updates all ParticleSystemComponents with their entity's world position.
|
||||
* 使用实体的世界坐标更新所有粒子系统组件。
|
||||
*/
|
||||
@ECSSystem('ParticleUpdate', { updateOrder: 100 })
|
||||
export class ParticleUpdateSystem extends EntitySystem {
|
||||
/** Transform 组件类型(运行时注入)| Transform component type (injected at runtime) */
|
||||
private _transformType: ComponentType<Component & ITransformComponent> | null = null;
|
||||
private _renderDataProvider: ParticleRenderDataProvider;
|
||||
private _engineIntegration: IEngineIntegration | null = null;
|
||||
private _textureService: ITextureService | null = null;
|
||||
private _physics2DQuery: IPhysics2DQuery | null = null;
|
||||
private _assetManager: IAssetManager | null = null;
|
||||
private _defaultTextureLoaded: boolean = false;
|
||||
private _defaultTextureLoading: boolean = false;
|
||||
/** 追踪每个粒子组件上次加载的资产 GUID | Track last loaded asset GUID for each particle component */
|
||||
private _lastLoadedGuids: WeakMap<ParticleSystemComponent, string> = new WeakMap();
|
||||
/** 正在加载资产的粒子组件 | Particle components currently loading assets */
|
||||
private _loadingComponents: WeakSet<ParticleSystemComponent> = new WeakSet();
|
||||
/** 已注入物理查询的粒子组件 | Particle components with physics query injected */
|
||||
private _physicsInjectedComponents: WeakSet<ParticleSystemComponent> = new WeakSet();
|
||||
|
||||
constructor() {
|
||||
super(Matcher.empty().all(ParticleSystemComponent));
|
||||
this._renderDataProvider = new ParticleRenderDataProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Transform 组件类型
|
||||
* Set Transform component type
|
||||
*
|
||||
* @param transformType - Transform component class | Transform 组件类
|
||||
*/
|
||||
setTransformType(transformType: ComponentType<Component & ITransformComponent>): void {
|
||||
this._transformType = transformType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置引擎集成(用于加载纹理)
|
||||
* Set engine integration (for loading textures)
|
||||
*/
|
||||
setEngineIntegration(integration: IEngineIntegration): void {
|
||||
this._engineIntegration = integration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理服务(用于加载默认纹理)
|
||||
* Set texture service (for loading default texture)
|
||||
*/
|
||||
setTextureService(textureService: ITextureService): void {
|
||||
this._textureService = textureService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 2D 物理查询接口
|
||||
* Set 2D physics query interface
|
||||
*
|
||||
* 如果设置,将自动注入到所有使用 Physics2DCollisionModule 的粒子系统。
|
||||
* If set, will be auto-injected into all particle systems using Physics2DCollisionModule.
|
||||
*
|
||||
* @param query - 物理查询接口(通常是 Physics2DService)| Physics query (usually Physics2DService)
|
||||
*/
|
||||
setPhysics2DQuery(query: IPhysics2DQuery | null): void {
|
||||
this._physics2DQuery = query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资产管理器
|
||||
* Set asset manager
|
||||
*
|
||||
* @param assetManager - 资产管理器实例 | Asset manager instance
|
||||
*/
|
||||
setAssetManager(assetManager: IAssetManager | null): void {
|
||||
this._assetManager = assetManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取渲染数据提供者
|
||||
* Get render data provider
|
||||
*/
|
||||
getRenderDataProvider(): ParticleRenderDataProvider {
|
||||
return this._renderDataProvider;
|
||||
}
|
||||
|
||||
protected override process(entities: readonly Entity[]): void {
|
||||
const deltaTime = Time.deltaTime;
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
|
||||
if (!particle) continue;
|
||||
|
||||
let worldX = 0;
|
||||
let worldY = 0;
|
||||
let worldRotation = 0;
|
||||
let worldScaleX = 1;
|
||||
let worldScaleY = 1;
|
||||
let transform: ITransformComponent | null = null;
|
||||
|
||||
// 获取 Transform 位置、旋转、缩放 | Get Transform position, rotation, scale
|
||||
if (this._transformType) {
|
||||
transform = entity.getComponent(this._transformType);
|
||||
if (transform) {
|
||||
const pos = transform.worldPosition ?? transform.position;
|
||||
worldX = pos.x;
|
||||
worldY = pos.y;
|
||||
|
||||
// 获取旋转(2D 使用 z 分量)| Get rotation (2D uses z component)
|
||||
// 转换:度(顺时针) → 弧度(逆时针) | Convert: degrees(clockwise) → radians(counter-clockwise)
|
||||
const rot = transform.worldRotation ?? transform.rotation;
|
||||
if (rot) {
|
||||
worldRotation = -rot.z * DEG_TO_RAD;
|
||||
}
|
||||
|
||||
// 获取缩放 | Get scale
|
||||
const scale = transform.worldScale ?? transform.scale;
|
||||
if (scale) {
|
||||
worldScaleX = scale.x;
|
||||
worldScaleY = scale.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在初始化中,跳过处理 | Skip processing if initializing
|
||||
if (this._loadingComponents.has(particle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload
|
||||
// 这使得编辑器中选择新的粒子资产时能够立即切换
|
||||
// This allows immediate switching when selecting a new particle asset in the editor
|
||||
this._checkAndReloadAsset(particle);
|
||||
|
||||
// 确保粒子系统已构建(即使未播放)| Ensure particle system is built (even when not playing)
|
||||
// 这使得编辑器中的属性更改能够立即生效
|
||||
// This allows property changes to take effect immediately in the editor
|
||||
particle.ensureBuilt();
|
||||
|
||||
// 自动注入物理查询到 Physics2DCollisionModule | Auto-inject physics query to Physics2DCollisionModule
|
||||
if (this._physics2DQuery && !this._physicsInjectedComponents.has(particle)) {
|
||||
this._injectPhysics2DQuery(particle);
|
||||
}
|
||||
|
||||
// 更新粒子系统 | Update particle system
|
||||
if (particle.isPlaying) {
|
||||
particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
|
||||
}
|
||||
|
||||
// 尝试加载纹理(如果还没有加载且不在初始化中)
|
||||
// Try to load texture if not loaded yet and not initializing
|
||||
if (particle.textureId === 0 && !this._loadingComponents.has(particle)) {
|
||||
this.loadParticleTexture(particle);
|
||||
}
|
||||
|
||||
// 更新渲染数据提供者的 Transform 引用 | Update render data provider's Transform reference
|
||||
// 确保粒子系统始终被注册 | Ensure particle system is always registered
|
||||
if (transform) {
|
||||
this._renderDataProvider.register(particle, transform);
|
||||
} else {
|
||||
// 使用默认 Transform | Use default transform
|
||||
this._renderDataProvider.register(particle, { position: { x: worldX, y: worldY } });
|
||||
}
|
||||
}
|
||||
|
||||
// 标记渲染数据需要更新 | Mark render data as dirty
|
||||
this._renderDataProvider.markDirty();
|
||||
}
|
||||
|
||||
protected override onAdded(entity: Entity): void {
|
||||
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
|
||||
if (particle) {
|
||||
// 异步初始化粒子系统 | Async initialize particle system
|
||||
this._initializeParticle(entity, particle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载粒子资产
|
||||
* Load particle asset
|
||||
*
|
||||
* 使用注入的 assetManager 加载资产,避免使用全局单例。
|
||||
* Uses injected assetManager to load assets, avoiding global singleton.
|
||||
*
|
||||
* @param guid 资产 GUID | Asset GUID
|
||||
* @param bForceReload 是否强制重新加载 | Whether to force reload
|
||||
* @returns 加载的资产数据或 null | Loaded asset data or null
|
||||
*/
|
||||
private async _loadParticleAsset(guid: string, bForceReload: boolean = false): Promise<IParticleAsset | null> {
|
||||
if (!guid || !this._assetManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this._assetManager.loadAsset<IParticleAsset>(guid, { forceReload: bForceReload });
|
||||
return result?.asset ?? null;
|
||||
} catch (error) {
|
||||
console.error(`[ParticleUpdateSystem] Error loading asset ${guid}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步初始化粒子系统
|
||||
* Async initialize particle system
|
||||
*/
|
||||
private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise<void> {
|
||||
// 标记为正在初始化,防止 process 中重复调用 loadParticleTexture
|
||||
// Mark as initializing to prevent duplicate loadParticleTexture calls in process
|
||||
this._loadingComponents.add(particle);
|
||||
|
||||
try {
|
||||
// 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
|
||||
if (particle.particleAssetGuid) {
|
||||
const asset = await this._loadParticleAsset(particle.particleAssetGuid);
|
||||
if (asset) {
|
||||
particle.setAssetData(asset);
|
||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
||||
if (asset.sortingLayer) {
|
||||
particle.sortingLayer = asset.sortingLayer;
|
||||
}
|
||||
if (asset.orderInLayer !== undefined) {
|
||||
particle.orderInLayer = asset.orderInLayer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化粒子系统(不自动播放,由下面的逻辑控制)
|
||||
// Initialize particle system (don't auto play, controlled by logic below)
|
||||
particle.ensureBuilt();
|
||||
|
||||
// 加载纹理 | Load texture
|
||||
await this.loadParticleTexture(particle);
|
||||
|
||||
// 注册到渲染数据提供者 | Register to render data provider
|
||||
// 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
|
||||
let transform: ITransformComponent | null = null;
|
||||
if (this._transformType) {
|
||||
transform = entity.getComponent(this._transformType);
|
||||
}
|
||||
// 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position)
|
||||
if (transform) {
|
||||
this._renderDataProvider.register(particle, transform);
|
||||
} else {
|
||||
this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } });
|
||||
}
|
||||
|
||||
// 记录已加载的资产 GUID | Record loaded asset GUID
|
||||
this._lastLoadedGuids.set(particle, particle.particleAssetGuid);
|
||||
|
||||
// 决定是否自动播放 | Decide whether to auto play
|
||||
// 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting
|
||||
const isEditorMode = this.scene?.isEditorMode ?? false;
|
||||
if (particle.particleAssetGuid && particle.loadedAsset) {
|
||||
if (isEditorMode) {
|
||||
// 编辑器模式:始终播放预览 | Editor mode: always play preview
|
||||
particle.play();
|
||||
} else if (particle.autoPlay) {
|
||||
// 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay
|
||||
particle.play();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 初始化完成,移除加载标记 | Initialization complete, remove loading mark
|
||||
this._loadingComponents.delete(particle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测资产 GUID 变化并重新加载
|
||||
* Check for asset GUID change and reload if necessary
|
||||
*
|
||||
* 当编辑器中修改 particleAssetGuid 属性时,此方法会检测变化并触发重新加载。
|
||||
* 加载完成后会自动开始播放预览,让用户立即看到效果。
|
||||
*
|
||||
* When particleAssetGuid property is modified in editor, this method detects the change and triggers reload.
|
||||
* After loading, it automatically starts playback for preview so user can see the effect immediately.
|
||||
*/
|
||||
private _checkAndReloadAsset(particle: ParticleSystemComponent): void {
|
||||
const currentGuid = particle.particleAssetGuid;
|
||||
const lastGuid = this._lastLoadedGuids.get(particle);
|
||||
|
||||
// 如果正在加载中,跳过
|
||||
// Skip if already loading
|
||||
if (this._loadingComponents.has(particle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要重新加载:
|
||||
// 1. GUID 变化了
|
||||
// 2. 或者 GUID 相同但资产数据丢失(场景恢复后)
|
||||
// 3. 或者 GUID 相同但纹理 ID 无效(纹理被清除后)
|
||||
// Check if reload is needed:
|
||||
// 1. GUID changed
|
||||
// 2. Or GUID is same but asset data is lost (after scene restore)
|
||||
// 3. Or GUID is same but texture ID is invalid (after texture clear)
|
||||
const needsReload = currentGuid !== lastGuid ||
|
||||
(currentGuid && !particle.loadedAsset) ||
|
||||
(currentGuid && particle.textureId === 0);
|
||||
|
||||
if (!needsReload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为正在加载 | Mark as loading
|
||||
this._loadingComponents.add(particle);
|
||||
this._lastLoadedGuids.set(particle, currentGuid);
|
||||
|
||||
// 停止当前播放并清除粒子 | Stop current playback and clear particles
|
||||
particle.stop(true);
|
||||
|
||||
// 重置纹理 ID,以便重新加载纹理 | Reset texture ID for texture reload
|
||||
particle.textureId = 0;
|
||||
|
||||
// 异步加载新资产 | Async load new asset
|
||||
(async () => {
|
||||
try {
|
||||
if (currentGuid) {
|
||||
const asset = await this._loadParticleAsset(currentGuid);
|
||||
if (asset) {
|
||||
particle.setAssetData(asset);
|
||||
// 应用资产的排序属性 | Apply sorting properties from asset
|
||||
if (asset.sortingLayer) {
|
||||
particle.sortingLayer = asset.sortingLayer;
|
||||
}
|
||||
if (asset.orderInLayer !== undefined) {
|
||||
particle.orderInLayer = asset.orderInLayer;
|
||||
}
|
||||
}
|
||||
// 加载纹理 | Load texture
|
||||
await this.loadParticleTexture(particle);
|
||||
|
||||
// 标记需要重建 | Mark for rebuild
|
||||
particle.markDirty();
|
||||
|
||||
// 在编辑器中自动播放预览,让用户立即看到效果
|
||||
// Auto play preview in editor so user can see the effect immediately
|
||||
particle.play();
|
||||
|
||||
console.log(`[ParticleUpdateSystem] Asset loaded and playing: ${currentGuid}`);
|
||||
} else {
|
||||
// 清空资产时,设置为 null | Clear asset when GUID is empty
|
||||
particle.setAssetData(null);
|
||||
particle.markDirty();
|
||||
console.log(`[ParticleUpdateSystem] Asset cleared`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParticleUpdateSystem] Failed to reload asset:', error);
|
||||
} finally {
|
||||
// 取消加载标记 | Remove loading mark
|
||||
this._loadingComponents.delete(particle);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载粒子纹理
|
||||
* Load particle texture
|
||||
*/
|
||||
async loadParticleTexture(particle: ParticleSystemComponent): Promise<void> {
|
||||
if (!this._engineIntegration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 已经加载过就跳过 | Skip if already loaded
|
||||
if (particle.textureId > 0) return;
|
||||
|
||||
// 从已加载的资产获取纹理 GUID | Get texture GUID from loaded asset
|
||||
const asset = particle.loadedAsset;
|
||||
const textureGuid = asset?.textureGuid || particle.textureGuid;
|
||||
|
||||
if (textureGuid) {
|
||||
// 通过 GUID 加载纹理 | Load texture by GUID
|
||||
try {
|
||||
const textureId = await this._engineIntegration.loadTextureByGuid(textureGuid);
|
||||
particle.textureId = textureId;
|
||||
} catch (error) {
|
||||
console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
|
||||
// 加载失败时使用默认纹理 | Use default texture on load failure
|
||||
const loaded = await this._ensureDefaultTexture();
|
||||
if (loaded) {
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
|
||||
const loaded = await this._ensureDefaultTexture();
|
||||
if (loaded) {
|
||||
particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保默认粒子纹理已加载
|
||||
* Ensure default particle texture is loaded
|
||||
*
|
||||
* 使用 loadTextureAsync API 等待纹理实际加载完成,
|
||||
* 避免显示灰色占位符的问题。
|
||||
* Uses loadTextureAsync API to wait for actual texture completion,
|
||||
* avoiding the gray placeholder issue.
|
||||
*
|
||||
* @returns 是否成功加载 | Whether successfully loaded
|
||||
*/
|
||||
private async _ensureDefaultTexture(): Promise<boolean> {
|
||||
// 已加载过 | Already loaded
|
||||
if (this._defaultTextureLoaded) return true;
|
||||
|
||||
// 正在加载中,等待完成 | Loading in progress, wait for completion
|
||||
if (this._defaultTextureLoading) {
|
||||
// 轮询等待加载完成 | Poll until loading completes
|
||||
while (this._defaultTextureLoading) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
return this._defaultTextureLoaded;
|
||||
}
|
||||
|
||||
// 没有引擎桥接,无法加载 | No engine bridge, cannot load
|
||||
if (!this._textureService) {
|
||||
console.warn('[ParticleUpdateSystem] TextureService not set, cannot load default texture');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._defaultTextureLoading = true;
|
||||
try {
|
||||
const dataUrl = generateDefaultParticleTextureDataURL();
|
||||
if (dataUrl) {
|
||||
// 使用 loadTextureAsync(等待纹理就绪)
|
||||
// Use loadTextureAsync (waits for texture ready)
|
||||
await this._textureService.loadTextureAsync(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
|
||||
this._defaultTextureLoaded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error);
|
||||
}
|
||||
this._defaultTextureLoading = false;
|
||||
return this._defaultTextureLoaded;
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
const particle = entity.getComponent(ParticleSystemComponent) as ParticleSystemComponent | null;
|
||||
if (particle) {
|
||||
// 从渲染数据提供者注销 | Unregister from render data provider
|
||||
this._renderDataProvider.unregister(particle);
|
||||
// 清除物理注入标记 | Clear physics injection mark
|
||||
this._physicsInjectedComponents.delete(particle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入物理查询到粒子系统的 Physics2DCollisionModule
|
||||
* Inject physics query into particle system's Physics2DCollisionModule
|
||||
*/
|
||||
private _injectPhysics2DQuery(particle: ParticleSystemComponent): void {
|
||||
if (!this._physics2DQuery) return;
|
||||
|
||||
for (const module of particle.modules) {
|
||||
if (module instanceof Physics2DCollisionModule) {
|
||||
module.setPhysicsQuery(this._physics2DQuery);
|
||||
}
|
||||
}
|
||||
|
||||
this._physicsInjectedComponents.add(particle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统销毁时清理
|
||||
* Cleanup on system destroy
|
||||
*/
|
||||
public override destroy(): void {
|
||||
super.destroy();
|
||||
this._renderDataProvider.dispose();
|
||||
}
|
||||
}
|
||||
20
packages/rendering/particle/src/tokens.ts
Normal file
20
packages/rendering/particle/src/tokens.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 粒子模块服务令牌
|
||||
* Particle module service tokens
|
||||
*
|
||||
* 定义粒子模块导出的服务令牌。
|
||||
* Defines service tokens exported by particle module.
|
||||
*/
|
||||
|
||||
import { createServiceToken } from '@esengine/ecs-framework';
|
||||
import type { ParticleUpdateSystem } from './systems/ParticleSystem';
|
||||
|
||||
// ============================================================================
|
||||
// 粒子模块导出的令牌 | Tokens exported by particle module
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 粒子更新系统令牌
|
||||
* Particle update system token
|
||||
*/
|
||||
export const ParticleUpdateSystemToken = createServiceToken<ParticleUpdateSystem>('particleUpdateSystem');
|
||||
Reference in New Issue
Block a user