diff --git a/packages/particle/src/ParticleRuntimeModule.ts b/packages/particle/src/ParticleRuntimeModule.ts index 1f493667..17995b45 100644 --- a/packages/particle/src/ParticleRuntimeModule.ts +++ b/packages/particle/src/ParticleRuntimeModule.ts @@ -1,4 +1,4 @@ -import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; +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'; @@ -20,7 +20,7 @@ class ParticleRuntimeModule implements IRuntimeModule { private _updateSystem: ParticleUpdateSystem | null = null; private _loaderRegistered = false; - registerComponents(registry: typeof ComponentRegistryType): void { + registerComponents(registry: IComponentRegistry): void { registry.register(ParticleSystemComponent); registry.register(ClickFxComponent); } @@ -73,13 +73,10 @@ class ParticleRuntimeModule implements IRuntimeModule { 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 asset manager - if (assetManager) { - clickFxSystem.setAssetManager(assetManager); - } - // 设置 EngineBridge(用于屏幕坐标转世界坐标) // Set EngineBridge (for screen to world coordinate conversion) if (engineBridge) { diff --git a/packages/particle/src/ParticleSystemComponent.ts b/packages/particle/src/ParticleSystemComponent.ts index 77f87bb6..8e6f3f52 100644 --- a/packages/particle/src/ParticleSystemComponent.ts +++ b/packages/particle/src/ParticleSystemComponent.ts @@ -9,6 +9,7 @@ import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule'; import { CollisionModule, BoundaryType, CollisionBehavior } from './modules/CollisionModule'; import { ForceFieldModule, ForceFieldType, type ForceField } from './modules/ForceFieldModule'; import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule'; +import { TextureSheetAnimationModule, AnimationPlayMode, AnimationLoopMode } from './modules/TextureSheetAnimationModule'; import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader'; // Re-export for backward compatibility @@ -828,6 +829,42 @@ export class ParticleSystemComponent extends Component implements ISortable { this._modules.push(forceModule); break; } + case 'TextureSheetAnimation': { + // 纹理图集动画模块 | Texture sheet animation module + const textureModule = new TextureSheetAnimationModule(); + // moduleConfig 直接包含属性(非 params 嵌套) + // moduleConfig contains properties directly (not nested in params) + const cfg = moduleConfig as unknown as Record; + textureModule.enabled = true; + if (cfg.tilesX !== undefined) textureModule.tilesX = cfg.tilesX as number; + if (cfg.tilesY !== undefined) textureModule.tilesY = cfg.tilesY as number; + if (cfg.totalFrames !== undefined) textureModule.totalFrames = cfg.totalFrames as number; + if (cfg.startFrame !== undefined) textureModule.startFrame = cfg.startFrame as number; + if (cfg.frameRate !== undefined) textureModule.frameRate = cfg.frameRate as number; + if (cfg.speedMultiplier !== undefined) textureModule.speedMultiplier = cfg.speedMultiplier as number; + if (cfg.cycleCount !== undefined) textureModule.cycleCount = cfg.cycleCount as number; + // 播放模式 | Play mode + if (cfg.playMode !== undefined) { + const playModeMap: Record = { + 'lifetimeLoop': AnimationPlayMode.LifetimeLoop, + 'fixedFps': AnimationPlayMode.FixedFPS, + 'random': AnimationPlayMode.Random, + 'speedBased': AnimationPlayMode.SpeedBased, + }; + textureModule.playMode = playModeMap[cfg.playMode as string] ?? AnimationPlayMode.LifetimeLoop; + } + // 循环模式 | Loop mode + if (cfg.loopMode !== undefined) { + const loopModeMap: Record = { + 'once': AnimationLoopMode.Once, + 'loop': AnimationLoopMode.Loop, + 'pingPong': AnimationLoopMode.PingPong, + }; + textureModule.loopMode = loopModeMap[cfg.loopMode as string] ?? AnimationLoopMode.Once; + } + this._modules.push(textureModule); + break; + } // 可扩展其他模块类型 | Extensible for other module types default: console.warn(`[ParticleSystem] Unknown module type: ${moduleConfig.type}`); diff --git a/packages/particle/src/__tests__/particle-e2e-test.html b/packages/particle/src/__tests__/particle-e2e-test.html new file mode 100644 index 00000000..f2da57a2 --- /dev/null +++ b/packages/particle/src/__tests__/particle-e2e-test.html @@ -0,0 +1,401 @@ + + + + Particle System End-to-End Test + + + +

Particle System End-to-End Test

+

This test simulates the COMPLETE particle rendering pipeline.

+ +
+

Step 1: Test Texture

+
+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
+└───────────┴───────────┘
+        
+ +
+ +
+

Step 2: TextureSheetAnimationModule._setParticleUV()

+

+    
+ +
+

Step 3: ParticleRenderDataProvider._updateRenderData()

+

+    
+ +
+

Step 4: EngineRenderSystem.convertProviderDataToSprites()

+

+    
+ +
+

Step 5: sprite_batch.rs add_sprite_vertices_to_batch()

+

+    
+ +
+

Step 6: Final Rendering Result

+ +
+
+ +
+

Test Results

+ + + +
FrameExpectedGotStatus
+
+ +
+

Conclusion

+

+    
+ + + + diff --git a/packages/particle/src/__tests__/sprite-batch-test.html b/packages/particle/src/__tests__/sprite-batch-test.html new file mode 100644 index 00000000..4c9c541f --- /dev/null +++ b/packages/particle/src/__tests__/sprite-batch-test.html @@ -0,0 +1,328 @@ + + + + Sprite Batch Rendering Test (模拟 sprite_batch.rs) + + + +

Sprite Batch Rendering Test

+

This test simulates exactly how sprite_batch.rs renders sprites with UV coordinates.

+ +

Test Texture (2x2 spritesheet)

+
+┌─────────┬─────────┐
+│ RED (0) │ GREEN(1)│  v: 0.0 - 0.5
+├─────────┼─────────┤
+│ BLUE(2) │ YELLOW(3)│  v: 0.5 - 1.0
+└─────────┴─────────┘
+    
+ +

Rendering Test (same as sprite_batch.rs)

+
+
+ +
Main rendering canvas
+
+
+

sprite_batch.rs vertex mapping:

+
+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];
+            
+
+
+ +

Frame-by-Frame Test Results

+
+ +

Conclusion

+

+
+    
+
+
diff --git a/packages/particle/src/__tests__/uv-calculation.test.ts b/packages/particle/src/__tests__/uv-calculation.test.ts
new file mode 100644
index 00000000..cb7d075b
--- /dev/null
+++ b/packages/particle/src/__tests__/uv-calculation.test.ts
@@ -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 };
diff --git a/packages/particle/src/__tests__/webgl-uv-test.html b/packages/particle/src/__tests__/webgl-uv-test.html
new file mode 100644
index 00000000..21ddd587
--- /dev/null
+++ b/packages/particle/src/__tests__/webgl-uv-test.html
@@ -0,0 +1,278 @@
+
+
+
+    WebGL UV Coordinate Test
+    
+
+
+    

WebGL UV Coordinate System Test

+ +

1. Test Texture (2x2 grid)

+
+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)
+└─────────┴─────────┘
+    
+ + ← This is the source texture + +

2. UV Sampling Test

+

Each square below samples a different UV region. We test what color appears.

+ +
+
UV [0, 0, 0.5, 0.5] (Frame 0):
+ +
+
+ +
+
UV [0.5, 0, 1, 0.5] (Frame 1):
+ +
+
+ +
+
UV [0, 0.5, 0.5, 1] (Frame 2):
+ +
+
+ +
+
UV [0.5, 0.5, 1, 1] (Frame 3):
+ +
+
+ +

3. Conclusion

+

+
+    
+
+
diff --git a/packages/particle/src/rendering/ParticleRenderDataProvider.ts b/packages/particle/src/rendering/ParticleRenderDataProvider.ts
index ba997fc1..6ae9c8a0 100644
--- a/packages/particle/src/rendering/ParticleRenderDataProvider.ts
+++ b/packages/particle/src/rendering/ParticleRenderDataProvider.ts
@@ -194,17 +194,40 @@ export class ParticleRenderDataProvider implements IRenderDataProvider {
                     this._transforms[tOffset + 5] = 0.5; // originX
                     this._transforms[tOffset + 6] = 0.5; // originY
 
-                    // Texture ID: 设置为 0,让 EngineRenderSystem 通过 textureGuid 解析
-                    // Set to 0, let EngineRenderSystem resolve via textureGuid
-                    // 这样可以避免场景恢复后 textureId 过期导致的纹理混乱问题
-                    // This avoids texture confusion when textureId becomes stale after scene restore
-                    this._textureIds[particleIndex] = 0;
+                    // Texture ID: 优先使用组件上预加载的 textureId,否则让 EngineRenderSystem 通过 textureGuid 解析
+                    // Prefer using pre-loaded textureId from component, otherwise let EngineRenderSystem resolve via textureGuid
+                    this._textureIds[particleIndex] = component.textureId;
 
-                    // UV (full texture)
-                    this._uvs[uvOffset] = 0;
-                    this._uvs[uvOffset + 1] = 0;
-                    this._uvs[uvOffset + 2] = 1;
-                    this._uvs[uvOffset + 3] = 1;
+                    // 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(
diff --git a/packages/particle/src/systems/ClickFxSystem.ts b/packages/particle/src/systems/ClickFxSystem.ts
index 38b0ab98..795f9bc0 100644
--- a/packages/particle/src/systems/ClickFxSystem.ts
+++ b/packages/particle/src/systems/ClickFxSystem.ts
@@ -8,10 +8,8 @@
 
 import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
 import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
-import type { IAssetManager } from '@esengine/asset-system';
 import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
 import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
-import type { IParticleAsset } from '../loaders/ParticleLoader';
 
 // ============================================================================
 // 本地服务令牌定义 | Local Service Token Definitions
@@ -66,7 +64,6 @@ const RenderSystemToken = createServiceToken('renderSystem'
 export class ClickFxSystem extends EntitySystem {
     private _engineBridge: IEngineBridge | null = null;
     private _renderSystem: IEngineRenderSystem | null = null;
-    private _assetManager: IAssetManager | null = null;
     private _entitiesToDestroy: Entity[] = [];
     private _canvas: HTMLCanvasElement | null = null;
 
@@ -74,14 +71,6 @@ export class ClickFxSystem extends EntitySystem {
         super(Matcher.empty().all(ClickFxComponent));
     }
 
-    /**
-     * 设置资产管理器
-     * Set asset manager
-     */
-    setAssetManager(assetManager: IAssetManager | null): void {
-        this._assetManager = assetManager;
-    }
-
     /**
      * 设置服务注册表(用于获取 EngineBridge 和 RenderSystem)
      * Set service registry (for getting EngineBridge and RenderSystem)
@@ -339,8 +328,11 @@ export class ClickFxSystem extends EntitySystem {
         const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
         transform.setScale(clickFx.scale, clickFx.scale, 1);
 
-        // 添加 ParticleSystem | Add ParticleSystem
-        const particleSystem = effectEntity.addComponent(new ParticleSystemComponent());
+        // 创建 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 层和屏幕空间渲染
@@ -349,31 +341,12 @@ export class ClickFxSystem extends EntitySystem {
         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);
-
-        // 异步加载并播放 | Async load and play
-        if (this._assetManager) {
-            this._assetManager.loadAsset(particleGuid).then(result => {
-                if (result?.asset) {
-                    particleSystem.setAssetData(result.asset);
-                    // 应用资产的排序属性 | Apply sorting properties from asset
-                    if (result.asset.sortingLayer) {
-                        particleSystem.sortingLayer = result.asset.sortingLayer;
-                    }
-                    if (result.asset.orderInLayer !== undefined) {
-                        particleSystem.orderInLayer = result.asset.orderInLayer;
-                    }
-                    particleSystem.play();
-                } else {
-                    console.warn(`[ClickFxSystem] Failed to load particle asset: ${particleGuid}`);
-                }
-            }).catch(error => {
-                console.error(`[ClickFxSystem] Error loading particle asset ${particleGuid}:`, error);
-            });
-        } else {
-            console.warn('[ClickFxSystem] AssetManager not set, cannot load particle asset');
-        }
     }
 
     /**
diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts
index df23a125..0cf2aa97 100644
--- a/packages/particle/src/systems/ParticleSystem.ts
+++ b/packages/particle/src/systems/ParticleSystem.ts
@@ -185,6 +185,11 @@ export class ParticleUpdateSystem extends EntitySystem {
                 }
             }
 
+            // 如果正在初始化中,跳过处理 | 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
@@ -205,8 +210,9 @@ export class ParticleUpdateSystem extends EntitySystem {
                 particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
             }
 
-            // 尝试加载纹理(如果还没有加载)| Try to load texture if not loaded yet
-            if (particle.textureId === 0) {
+            // 尝试加载纹理(如果还没有加载且不在初始化中)
+            // Try to load texture if not loaded yet and not initializing
+            if (particle.textureId === 0 && !this._loadingComponents.has(particle)) {
                 this.loadParticleTexture(particle);
             }
 
@@ -262,56 +268,65 @@ export class ParticleUpdateSystem extends EntitySystem {
      * Async initialize particle system
      */
     private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise {
-        // 如果有资产 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;
+        // 标记为正在初始化,防止 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();
+            // 初始化粒子系统(不自动播放,由下面的逻辑控制)
+            // Initialize particle system (don't auto play, controlled by logic below)
+            particle.ensureBuilt();
 
-        // 加载纹理 | Load texture
-        await this.loadParticleTexture(particle);
+            // 加载纹理 | 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();
+            // 注册到渲染数据提供者 | 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);
         }
     }
 
@@ -329,9 +344,25 @@ export class ParticleUpdateSystem extends EntitySystem {
         const currentGuid = particle.particleAssetGuid;
         const lastGuid = this._lastLoadedGuids.get(particle);
 
-        // 如果 GUID 没有变化,或者正在加载中,跳过
-        // Skip if GUID hasn't changed or already loading
-        if (currentGuid === lastGuid || this._loadingComponents.has(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;
         }
 
@@ -410,35 +441,70 @@ export class ParticleUpdateSystem extends EntitySystem {
             } catch (error) {
                 console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
                 // 加载失败时使用默认纹理 | Use default texture on load failure
-                await this._ensureDefaultTexture();
-                particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+                const loaded = await this._ensureDefaultTexture();
+                if (loaded) {
+                    particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+                }
             }
         } else {
             // 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
-            await this._ensureDefaultTexture();
-            particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+            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 {
-        if (this._defaultTextureLoaded || this._defaultTextureLoading) return;
-        if (!this._engineBridge) return;
+    private async _ensureDefaultTexture(): Promise {
+        // 已加载过 | 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._engineBridge) {
+            console.warn('[ParticleUpdateSystem] EngineBridge not set, cannot load default texture');
+            return false;
+        }
 
         this._defaultTextureLoading = true;
         try {
             const dataUrl = generateDefaultParticleTextureDataURL();
             if (dataUrl) {
-                await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
+                // 优先使用 loadTextureAsync(等待纹理就绪)
+                // Prefer loadTextureAsync (waits for texture ready)
+                if (this._engineBridge.loadTextureAsync) {
+                    await this._engineBridge.loadTextureAsync(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
+                } else {
+                    // 回退到旧 API(可能显示灰色占位符)
+                    // Fallback to old API (may show gray placeholder)
+                    await this._engineBridge.loadTexture(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 {