feat(particle): 添加粒子与 Rapier2D 物理碰撞集成 (#295)

* feat(particle): 添加粒子与 Rapier2D 物理碰撞集成

- 新增 Physics2DCollisionModule 模块,支持粒子与场景碰撞体交互
- 支持圆形重叠检测和射线检测两种模式
- 支持 Kill/Bounce/Stop 三种碰撞行为
- 修复 module.update() 参数顺序 bug
- 物理模块自动通过 context 注入,用户只需添加模块即可

* chore: update pnpm-lock.yaml for particle physics dependency

Update lockfile to include @esengine/physics-rapier2d as optional peer dependency.
This commit is contained in:
YHH
2025-12-08 09:38:37 +08:00
committed by GitHub
parent d92c2a7b66
commit 52bbccd53c
10 changed files with 535 additions and 2 deletions

View File

@@ -30,10 +30,19 @@
"dependencies": {
"@esengine/asset-system": "workspace:*"
},
"peerDependencies": {
"@esengine/physics-rapier2d": "workspace:*"
},
"peerDependenciesMeta": {
"@esengine/physics-rapier2d": {
"optional": true
}
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",

View File

@@ -4,6 +4,7 @@ import { assetManager as globalAssetManager, type AssetManager } from '@esengine
import { ParticleSystemComponent } from './ParticleSystemComponent';
import { ParticleUpdateSystem } from './systems/ParticleSystem';
import { ParticleLoader, ParticleAssetType } from './loaders/ParticleLoader';
import type { IPhysics2DQuery } from './modules/Physics2DCollisionModule';
export type { SystemContext, ModuleManifest, IRuntimeModule as IRuntimeModuleLoader, IPlugin as IPluginLoader };
@@ -42,6 +43,17 @@ export interface ParticleSystemContext extends SystemContext {
engineBridge?: IEngineBridge;
/** 资产管理器(用于注册加载器)| Asset manager (for registering loaders) */
assetManager?: AssetManager;
/**
* 2D 物理查询接口(可选)
* 2D Physics query interface (optional)
*
* 如果提供,将自动注入到使用 Physics2DCollisionModule 的粒子系统中。
* 通常传入 Physics2DService 实例。
*
* If provided, will be auto-injected into particle systems using Physics2DCollisionModule.
* Typically pass a Physics2DService instance.
*/
physics2DQuery?: IPhysics2DQuery;
}
class ParticleRuntimeModule implements IRuntimeModule {
@@ -90,6 +102,11 @@ class ParticleRuntimeModule implements IRuntimeModule {
this._updateSystem.setEngineBridge(particleContext.engineBridge);
}
// 设置 2D 物理查询(用于粒子与场景碰撞)| Set 2D physics query (for particle-scene collision)
if (particleContext.physics2DQuery) {
this._updateSystem.setPhysics2DQuery(particleContext.physics2DQuery);
}
scene.addSystem(this._updateSystem);
particleContext.particleUpdateSystem = this._updateSystem;

View File

@@ -7,6 +7,7 @@ import { ColorOverLifetimeModule } from './modules/ColorOverLifetimeModule';
import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule';
import { CollisionModule } from './modules/CollisionModule';
import { ForceFieldModule } from './modules/ForceFieldModule';
import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule';
import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader';
// Re-export for backward compatibility
@@ -751,7 +752,33 @@ export class ParticleSystemComponent extends Component {
const normalizedAge = p.age / p.lifetime;
for (const module of this._modules) {
if (module.enabled) {
module.update(p, normalizedAge, dt);
module.update(p, dt, normalizedAge);
}
}
}
// 处理碰撞模块标记的需销毁粒子 | Process particles marked for death by collision modules
for (const module of this._modules) {
if (module.enabled) {
// 处理边界碰撞模块 | Handle boundary collision module
if (module instanceof CollisionModule) {
const toKill = module.getParticlesToKill();
for (const p of toKill) {
if (p.alive) {
particlesToRecycle.push(p);
}
}
module.clearDeathFlags();
}
// 处理物理碰撞模块 | Handle physics collision module
if (module instanceof Physics2DCollisionModule) {
const toKill = module.getParticlesToKill();
for (const p of toKill) {
if (p.alive) {
particlesToRecycle.push(p);
}
}
module.clearDeathFlags();
}
}
}

View File

@@ -41,7 +41,12 @@ export {
ForceFieldModule,
ForceFieldType,
createDefaultForceField,
type ForceField
type ForceField,
// Physics 2D collision module | 2D 物理碰撞模块
Physics2DCollisionModule,
Physics2DCollisionBehavior,
type IPhysics2DQuery,
type ParticleCollisionInfo
} from './modules';
// Rendering

View File

@@ -0,0 +1,380 @@
import type { Particle } from '../Particle';
import type { IParticleModule } from './IParticleModule';
/**
* 物理碰撞行为
* Physics collision behavior
*/
export enum Physics2DCollisionBehavior {
/** 销毁粒子 | Kill particle */
Kill = 'kill',
/** 反弹 | Bounce */
Bounce = 'bounce',
/** 停止运动 | Stop movement */
Stop = 'stop'
}
/**
* 物理查询接口
* Physics query interface
*
* 抽象物理服务查询,避免直接依赖 physics-rapier2d 包
* Abstract physics service query to avoid direct dependency on physics-rapier2d
*/
export interface IPhysics2DQuery {
/**
* 圆形重叠检测
* Circle overlap detection
*
* @param center - 圆心位置 | Center position
* @param radius - 半径 | Radius
* @param collisionMask - 碰撞掩码 | Collision mask
* @returns 重叠结果 | Overlap result
*/
overlapCircle(
center: { x: number; y: number },
radius: number,
collisionMask?: number
): { entityIds: number[]; colliderHandles: number[] };
/**
* 射线检测
* Raycast detection
*
* @param origin - 起点 | Origin
* @param direction - 方向(归一化)| Direction (normalized)
* @param maxDistance - 最大距离 | Max distance
* @param collisionMask - 碰撞掩码 | Collision mask
* @returns 射线结果或 null | Raycast result or null
*/
raycast(
origin: { x: number; y: number },
direction: { x: number; y: number },
maxDistance: number,
collisionMask?: number
): {
entityId: number;
point: { x: number; y: number };
normal: { x: number; y: number };
distance: number;
colliderHandle: number;
} | null;
}
/**
* 碰撞回调数据
* 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);
}
}
}

View File

@@ -20,3 +20,9 @@ export {
createDefaultForceField,
type ForceField
} from './ForceFieldModule';
export {
Physics2DCollisionModule,
Physics2DCollisionBehavior,
type IPhysics2DQuery,
type ParticleCollisionInfo
} from './Physics2DCollisionModule';

View File

@@ -2,6 +2,7 @@ import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-fr
import { ParticleSystemComponent } from '../ParticleSystemComponent';
import { ParticleRenderDataProvider } from '../rendering/ParticleRenderDataProvider';
import type { IEngineIntegration, IEngineBridge } from '../ParticleRuntimeModule';
import { Physics2DCollisionModule, type IPhysics2DQuery } from '../modules/Physics2DCollisionModule';
/**
* 默认粒子纹理 ID
@@ -69,12 +70,15 @@ export class ParticleUpdateSystem extends EntitySystem {
private _renderDataProvider: ParticleRenderDataProvider;
private _engineIntegration: IEngineIntegration | null = null;
private _engineBridge: IEngineBridge | null = null;
private _physics2DQuery: IPhysics2DQuery | 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));
@@ -107,6 +111,19 @@ export class ParticleUpdateSystem extends EntitySystem {
this._engineBridge = bridge;
}
/**
* 设置 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;
}
/**
* 获取渲染数据提供者
* Get render data provider
@@ -164,6 +181,11 @@ export class ParticleUpdateSystem extends EntitySystem {
// 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);
@@ -366,9 +388,27 @@ export class ParticleUpdateSystem extends EntitySystem {
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

View File

@@ -21,6 +21,34 @@ import { Physics2DService } from './services/Physics2DService';
// 注册 Rapier2D 加载器
import './loaders';
/**
* 2D 物理查询接口
* 2D Physics query interface
*
* 用于粒子系统等模块查询物理世界
* Used by particle system and other modules to query physics world
*/
export interface IPhysics2DQuery {
overlapCircle(
center: { x: number; y: number },
radius: number,
collisionMask?: number
): { entityIds: number[]; colliderHandles: number[] };
raycast(
origin: { x: number; y: number },
direction: { x: number; y: number },
maxDistance: number,
collisionMask?: number
): {
entityId: number;
point: { x: number; y: number };
normal: { x: number; y: number };
distance: number;
colliderHandle: number;
} | null;
}
/**
* 物理系统上下文扩展
*/
@@ -39,6 +67,18 @@ export interface PhysicsSystemContext extends SystemContext {
* 物理配置
*/
physicsConfig?: any;
/**
* 2D 物理查询接口
* 2D Physics query interface
*
* 供粒子系统等模块使用,用于检测粒子与物理碰撞体的碰撞。
* 实际上是 Physics2DSystem 的引用,因为它实现了 IPhysics2DQuery 接口。
*
* For particle system and other modules to detect collision with physics colliders.
* Actually a reference to Physics2DSystem which implements IPhysics2DQuery interface.
*/
physics2DQuery?: IPhysics2DQuery;
}
/**
@@ -143,6 +183,9 @@ class PhysicsRuntimeModule implements IRuntimeModule {
physicsContext.physicsSystem = physicsSystem;
physicsContext.physics2DWorld = physicsSystem.world;
// 同时暴露 physics2DQuery供粒子系统等模块使用
// Also expose physics2DQuery for particle system and other modules
physicsContext.physics2DQuery = physicsSystem;
}
/**

View File

@@ -27,3 +27,6 @@ export { Physics2DPlugin } from './PhysicsEditorPlugin';
// Runtime plugin (for game builds)
export { PhysicsPlugin } from './PhysicsRuntimeModule';
// Physics query interface (for particle system integration)
export type { IPhysics2DQuery, PhysicsSystemContext } from './PhysicsRuntimeModule';

3
pnpm-lock.yaml generated
View File

@@ -1067,6 +1067,9 @@ importers:
'@esengine/engine-core':
specifier: workspace:*
version: link:../engine-core
'@esengine/physics-rapier2d':
specifier: workspace:*
version: link:../physics-rapier2d
rimraf:
specifier: ^5.0.5
version: 5.0.10