Skip to content

feat(loader): v2 scene/prefab format parsing#2959

Open
luzhuang wants to merge 43 commits intogalacean:dev/2.0from
luzhuang:refactor/scene-format-v2
Open

feat(loader): v2 scene/prefab format parsing#2959
luzhuang wants to merge 43 commits intogalacean:dev/2.0from
luzhuang:refactor/scene-format-v2

Conversation

@luzhuang
Copy link
Copy Markdown
Contributor

@luzhuang luzhuang commented Apr 8, 2026

Summary

  • Implement native v2 scene/prefab format parsing in HierarchyParser/ReflectionParser, supporting $ref, $type, $entity, $component, $signal value resolution
  • Support prefab instance loading with full override system (entityProps, componentProps, addedComponents, addedEntities, removedEntities, removedComponents)
  • Add assetRefToEngine() utility for v2 { $ref } → engine { url } conversion

Test plan

  • 16 unit tests covering ReflectionParser props resolution, SceneParser entity tree construction, component pipeline (Stage 3-4), and $type polymorphic resolution
  • Manual testing with editor-exported v2 scene/prefab files

Summary by CodeRabbit

  • New Features

    • v2 scene & prefab format with $ref asset references, tuple-style vectors, and new exported scene/prefab type definitions.
  • Improvements

    • Reworked loader/parser to index-based hierarchies, robust prefab instancing and override application.
    • New props resolution supporting $ref, $type, $entity, $component, and $signal.
    • Scene parsing: improved background/ambient/shadow/fog/AO handling and unified dependency discovery via $ref.
    • Resource lookup switched to $ref-based references.
  • Tests

    • Added comprehensive tests covering v2 parsing, props, signals, scenes, and prefabs.
  • Chores

    • .gitignore updated to ignore docs/plans.

luzhuang added 3 commits April 7, 2026 19:00
Rewrite the loader parsers to directly consume v2 scene/prefab format
with $ prefix value types, eliminating the v1 conversion layer.

- ReflectionParser: handle $ref, $type, $entity, $component, $signal
- HierarchyParser: flat entity array with integer index references
- ParserContext: entityMap as Map<number, Entity> for flat index lookup
- SceneParser/PrefabParser: v2 $ref asset references
- BasicSchema: strip v1-only types, keep IAssetRef for material loaders
- SceneSchema: IScene extends IHierarchyFile with Vec4Tuple support
- Fix getResourceByRef param: refId → url across all call sites
- Fix $signal resolution: call Signal.on() instead of replacing instance
- Restore missing Font import in SceneLoader
- Change customParseComponentHandles from Map to Record (bracket access)
- Remove dead _prefabPromiseMap code in HierarchyParser
- Extract assetRefToEngine() to types.ts, reuse everywhere
- Remove unused entityIndex param from _loadPrefabInstance
- Revert IAssetRef type from { refId } back to { url } to match v1 runtime data
- Remove unused generic parameter I from ParserContext
- Fix constructor.name to Loader.getClassName for minification safety
- Add component pipeline tests (Stage 3-4) and $type resolution tests
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Reworks loader/parser stack from v1 to v2 scene/prefab format: introduces flat numeric entity indices, AssetRef $ref markers, new scene-format types, index-based parsing pipeline, a new reflection prop resolver, prefab override application, and accompanying tests and exports. (30 words)

Changes

Cohort / File(s) Summary
Scene-format types & exports
packages/loader/src/scene-format/types.ts, packages/loader/src/index.ts
Added v2 scene/prefab type declarations (SceneFile, PrefabFile, AssetRef, tuple types, enums) and re-exported them from loader entry.
Parser core & context
packages/loader/src/resource-deserialize/resources/parser/ParserContext.ts, packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts
Removed generic context typing; switched to numeric entityMap and componentPairs; refactored parsing pipeline to staged index-based flow (_parseEntities → _organizeEntities → _parseComponents → _parseComponentsProps → _parsePrefabOverrides → clearAndResolve); added PrefabInstanceContext for override resolution.
Reflection / property resolution
packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts
Replaced legacy reflection API with v2 parseProps and recursive _resolveValue supporting $ref, $type, $entity, $component, $signal; in-place mutation semantics and asset registration changes.
Scene parsing & loader updates
packages/loader/src/SceneLoader.ts, packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts
Scene loader now requests SceneFile, delegates scene application to exported applySceneData; SceneParser uses index-based roots, collects dependencies via $ref shape, and updated background/ambient/shadow/fog/AO handling.
Prefab parsing & loader
packages/loader/src/PrefabLoader.ts, packages/loader/src/prefab/PrefabParser.ts
PrefabLoader and PrefabParser switched to PrefabFile/v2 typing; PrefabParser updated to index-based root resolution (_getRootIndices / _handleRootEntity(index)) and returns parser.promise directly.
Schema cleanup & material/gltf updates
packages/loader/src/resource-deserialize/resources/schema/BasicSchema.ts, packages/loader/src/resource-deserialize/resources/schema/SceneSchema.ts, packages/loader/src/resource-deserialize/resources/schema/MaterialSchema.ts, packages/loader/src/gltf/extensions/GLTFExtensionSchema.ts
Removed legacy v1 schema types and SceneSchema; replaced IAssetRef usages with new AssetRef; GLTF material remap now extends AssetRef.
ResourceManager & loaders
packages/core/src/asset/ResourceManager.ts, packages/loader/src/AnimationClipLoader.ts, packages/loader/src/MaterialLoader.ts
Resource references now use { $ref, key?, isClone? }; ResourceManager.getResourceByRef expects $ref; loaders updated to detect $ref shapes and use new types for casts.
Tests & .gitignore
tests/src/loader/SceneFormatV2.test.ts, tests/src/loader/PrefabResource.test.ts, .gitignore
Added comprehensive v2 tests for ReflectionParser/SceneParser/Prefab behaviors and prefab overrides; migrated prefab tests to v2 format; added docs/plans to .gitignore.

Sequence Diagram(s)

sequenceDiagram
    participant Client as SceneLoader
    participant Parser as HierarchyParser
    participant Reflect as ReflectionParser
    participant Prefab as PrefabParser
    participant Resource as ResourceManager
    participant Engine as Engine

    Client->>Resource: _request(url) -> SceneFile (with $ref, entities[])
    Client->>Parser: new ParserContext(data)
    Parser->>Parser: _parseEntities() (iterate numeric indices)
    Parser->>Prefab: detect instance entity -> load prefab via Resource
    Prefab->>Resource: _request(prefabUrl) -> PrefabFile
    Prefab-->>Parser: instantiated entities mapped by index
    Parser->>Parser: _organizeEntities() (link children by index)
    Parser->>Parser: _parseComponents() (create components)
    Parser->>Parser: _parseComponentsProps() (queue props)
    loop apply props / overrides
        Parser->>Reflect: parseProps(target, props)
        Reflect->>Resource: resolve `{ $ref }` -> asset
        Reflect->>Parser: resolve `{ $entity }` / `{ $component }` by index
        Reflect->>Engine: set resolved values on instances
    end
    Parser->>Parser: _parsePrefabOverrides() (apply instance overrides)
    Parser->>Client: _clearAndResolve() -> built Scene / PrefabResource
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 I hopped through arrays of indices, nibbling old string vines,
Found $ref berries, stitched prefab spines,
I parsed props, bound signals, and joined each tiny part,
With tuples, overrides, and a reflector heart—
A rabbit’s clap for v2, quick and smart! 🌿✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(loader): v2 scene/prefab format parsing' clearly and concisely describes the main change: implementing native v2 format parsing for scenes and prefabs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/src/loader/SceneFormatV2.test.ts (1)

39-131: Good coverage for ReflectionParser prop resolution.

Tests cover the key v2 resolution paths: primitives, nested objects, in-place mutation, arrays, $entity, $component, and $ref detection.

Consider adding tests for:

  • $signal binding resolution
  • Error cases (e.g., invalid $entity index returns undefined, unregistered $type throws)

Would you like me to draft additional test cases for $signal resolution and error handling?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/loader/SceneFormatV2.test.ts` around lines 39 - 131, Add tests
exercising ReflectionParser.parseProps for $signal resolution and error cases:
create a scene/context and entities, register a signal (via the same context
structure used for entity/component resolution) then call parseProps with a
property like { sig: { $signal: { entity: 0, name: "signalName" } } } and assert
the resolved value is the expected bound signal; add a test where an invalid
$entity index (no entry in context.entityMap) is used and assert the result is
undefined; add a test where a $component refers to a non-registered type (e.g.,
{ $component: { entity: 0, type: "NonExistent", index: 0 } }) and assert
parseProps throws/rejects. Reference ReflectionParser, parseProps, and
context.entityMap/context handling for locating where to add these cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts`:
- Around line 41-56: The code assumes scene.background is always present and
reads background.mode unguarded; update SceneParser.ts to first check that
scene.background is defined (e.g., if (!background) return/skip dependency
collection for background) before accessing background.mode, then retain the
existing branches for BackgroundMode.Texture and BackgroundMode.Sky that call
context._addDependentAsset with
resourceManager.getResourceByRef(assetRefToEngine(...)); ensure both the Texture
and Sky branches are only executed when background is non-null and their
respective fields (texture, skyMesh, skyMaterial) are present to avoid runtime
errors.
- Around line 78-95: The loop in SceneParser that handles entity.instance only
enqueues instance.asset and skips nested refs in instance.overrides; update the
logic inside the entities loop (the block that checks entity.instance) to also
traverse instance.overrides and any nested data for asset refs the same way
components do: call this._searchDependentAssets on instance.overrides (or
otherwise walk its structure) and continue using context._addDependentAsset with
assetRefToEngine for any discovered $ref values so override-added
scripts/components are preloaded before Loader.getClass is called; keep existing
usage of context._addDependentAsset, assetRefToEngine, and
this._searchDependentAssets to locate and register those refs.

In `@packages/loader/src/SceneLoader.ts`:
- Line 52: Remove the invalid property assignment to
scene.ambientLight.specularTextureDecodeRGBM — the AmbientLight class does not
have that property; instead remove the entire line and, if RGBM decode is
required, configure it on the TextureCube referenced by
AmbientLight.specularTexture (e.g., adjust decode settings on the texture
object, not on AmbientLight). Ensure no other references to
specularTextureDecodeRGBM remain and keep AmbientLight.specularTexture usage
unchanged.

---

Nitpick comments:
In `@tests/src/loader/SceneFormatV2.test.ts`:
- Around line 39-131: Add tests exercising ReflectionParser.parseProps for
$signal resolution and error cases: create a scene/context and entities,
register a signal (via the same context structure used for entity/component
resolution) then call parseProps with a property like { sig: { $signal: {
entity: 0, name: "signalName" } } } and assert the resolved value is the
expected bound signal; add a test where an invalid $entity index (no entry in
context.entityMap) is used and assert the result is undefined; add a test where
a $component refers to a non-registered type (e.g., { $component: { entity: 0,
type: "NonExistent", index: 0 } }) and assert parseProps throws/rejects.
Reference ReflectionParser, parseProps, and context.entityMap/context handling
for locating where to add these cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 03a0c311-9bab-416b-8cfe-2f7a56c9cc32

📥 Commits

Reviewing files that changed from the base of the PR and between 931f254 and 247a8ee.

📒 Files selected for processing (12)
  • packages/loader/src/PrefabLoader.ts
  • packages/loader/src/SceneLoader.ts
  • packages/loader/src/prefab/PrefabParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/ParserContext.ts
  • packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts
  • packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts
  • packages/loader/src/resource-deserialize/resources/schema/BasicSchema.ts
  • packages/loader/src/resource-deserialize/resources/schema/SceneSchema.ts
  • packages/loader/src/resource-deserialize/resources/schema/index.ts
  • packages/loader/src/scene-format/types.ts
  • tests/src/loader/SceneFormatV2.test.ts

Comment thread packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts Outdated
Comment thread packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts Outdated
Comment thread packages/loader/src/SceneLoader.ts Outdated
GuoLei1990

This comment was marked as outdated.

HierarchyParser:
- 合并 _applyEntityData/_applyEntityOverrides/_createInlineEntity 中重复的
  entity props 赋值为统一的 _applyEntityProps 静态方法
- _overrideKeyToPath 用 slice+replace 替代 JSON.parse

ReflectionParser:
- customParseComponentHandles 类型从 Function 收窄为具体签名
- $ref 解析添加 resource null 守卫,防止 null 加入 dependence assets

SceneParser:
- _parseDependentAssets 补充扫描 prefab instance overrides 中的 $ref
- 移除 _isAssetRef 调用前多余的 @ts-ignore

PrefabParser:
- 移除 parse() 中多余的 .then(() => prefabResource)

types.ts:
- _entityIds/_componentIds 添加 JSDoc 说明编辑器专用字段

测试:
- PrefabResource 测试迁移到 v2 格式,新增 $ref null 守卫和 instance overrides 测试
- SceneFormatV2 新增 $signal 绑定和 $type 多态解析测试
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts (1)

41-57: ⚠️ Potential issue | 🟠 Major

Guard the optional background block.

The code still accesses background.mode without checking if background exists. If scene.background is undefined, this will throw.

🛡️ Proposed fix
     const background = scene.background;
-    const backgroundMode = background.mode;
-    if (backgroundMode === BackgroundMode.Texture) {
+    if (background) {
+      const backgroundMode = background.mode;
+      if (backgroundMode === BackgroundMode.Texture) {
         const texture = background.texture;
         if (texture) {
           // `@ts-ignore`
           context._addDependentAsset(texture.$ref, resourceManager.getResourceByRef(assetRefToEngine(texture)));
         }
-    } else if (backgroundMode === BackgroundMode.Sky) {
+      } else if (backgroundMode === BackgroundMode.Sky) {
         const { skyMesh, skyMaterial } = background;
         if (skyMesh && skyMaterial) {
           // `@ts-ignore`
           context._addDependentAsset(skyMesh.$ref, resourceManager.getResourceByRef(assetRefToEngine(skyMesh)));
           // `@ts-ignore`
           context._addDependentAsset(skyMaterial.$ref, resourceManager.getResourceByRef(assetRefToEngine(skyMaterial)));
         }
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts`
around lines 41 - 57, The code reads background.mode without ensuring
scene.background exists, so add a guard for the optional background before
accessing its properties: check that scene.background (the local const
background) is defined (e.g., if (!background) { /* skip */ } or wrap the
existing logic in if (background) { ... }) before using backgroundMode,
background.texture, skyMesh or skyMaterial; keep using the same calls to
context._addDependentAsset, resourceManager.getResourceByRef and
assetRefToEngine for the dependent-asset handling.
🧹 Nitpick comments (4)
packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts (2)

85-94: Guard against unregistered component type in $component resolution.

Similar to $type, if Loader.getClass(comp.type) returns undefined, the getComponents call will fail. Add a guard for robustness.

🛡️ Proposed defensive check
     // $component — component reference: { entity, type, index }
     if ("$component" in obj) {
       const comp = obj.$component as { entity: number; type: string; index: number };
       const entity = this._context.entityMap.get(comp.entity);
       if (entity) {
-        const components = entity.getComponents(Loader.getClass(comp.type), []);
+        const ComponentClass = Loader.getClass(comp.type);
+        if (!ComponentClass) {
+          console.warn(`ReflectionParser: Unknown component type "${comp.type}"`);
+          return Promise.resolve(null);
+        }
+        const components = entity.getComponents(ComponentClass, []);
         return Promise.resolve(components[comp.index] ?? null);
       }
       return Promise.resolve(null);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 85 - 94, The $component branch in ReflectionParser.ts uses
Loader.getClass(comp.type) without checking for undefined, which can cause
entity.getComponents to throw; update the block that reads comp (the
"$component" in obj branch) to first store the result of
Loader.getClass(comp.type) (or call a variable like compClass) and if it's
undefined return Promise.resolve(null) (or otherwise short-circuit) before
calling entity.getComponents(compClass, []); ensure you reference comp,
Loader.getClass, and entity.getComponents when applying the guard.

69-77: Guard against unregistered class in $type resolution.

If Loader.getClass($type) returns undefined for an unregistered type name, new Class() will throw a runtime error. Consider adding a guard with a meaningful error message.

🛡️ Proposed defensive check
     // $type — polymorphic type: construct instance and apply remaining props
     if ("$type" in obj) {
       const { $type, ...rest } = obj;
       const Class = Loader.getClass($type as string);
+      if (!Class) {
+        console.warn(`ReflectionParser: Unknown type "${$type}"`);
+        return Promise.resolve(null);
+      }
       const instance = new Class();
       if (Object.keys(rest).length > 0) {
         return this.parseProps(instance, rest);
       }
       return Promise.resolve(instance);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 69 - 77, The code in ReflectionParser that handles objects with a
"$type" needs to guard against Loader.getClass($type) returning undefined;
update the block where you call Loader.getClass($type) and construct instance
(in ReflectionParser) to check whether Class is truthy, and if not throw or
return a rejected Promise with a clear error mentioning the unresolved type name
(e.g., include $type and context like "unregistered class type in
ReflectionParser"); ensure parsing flow (parseProps and
Promise.resolve(instance)) remains unchanged when Class is valid.
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts (2)

238-245: Consider order of operations for removedEntities.

Destroying entities during iteration over removedEntities should be safe since each path is independent. However, if a parent entity is destroyed before its children are processed, attempting to destroy the child may fail silently or cause unexpected behavior. Consider processing in reverse depth order (children first) or validating that the entity still exists.

💡 Optional: Add existence check before destroy
       // removedEntities — destroy entities from prefab tree
       if (overrides.removedEntities) {
         for (let j = 0, m = overrides.removedEntities.length; j < m; j++) {
           const path = overrides.removedEntities[j].join("/");
           const target = ctx.entityMap.get(path);
-          if (target) target.destroy();
+          if (target && !target.destroyed) target.destroy();
         }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 238 - 245, When destroying entities from overrides.removedEntities
in HierarchyParser (the loop that looks up paths via ctx.entityMap and calls
target.destroy()), ensure children are destroyed before parents by sorting or
iterating the paths in reverse-depth order (e.g., longest path first) so parent
removals don't invalidate child destruction; additionally guard each destroy
call with an existence check on ctx.entityMap.get(path) (or verify
target.isDestroyed is false) before calling target.destroy() to avoid silent
failures.

137-141: Guard against unregistered class when adding components.

If Loader.getClass(key) returns undefined (e.g., script not loaded or type not registered), entity.addComponent(undefined) will fail at runtime. Consider adding validation.

🛡️ Proposed defensive check
       for (let j = 0, m = componentIndices.length; j < m; j++) {
         const config = allComponents[componentIndices[j]];
         const key = config.script ? config.script.$ref : config.type;
-        const component = entity.addComponent(Loader.getClass(key));
+        const ComponentClass = Loader.getClass(key);
+        if (!ComponentClass) {
+          console.warn(`HierarchyParser: Unknown component type "${key}"`);
+          continue;
+        }
+        const component = entity.addComponent(ComponentClass);
         componentPairs.push({ component, config });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 137 - 141, The code calls Loader.getClass(key) and immediately
passes its result to entity.addComponent, which can throw if the class is
undefined; update the loop in HierarchyParser to validate the returned class
before adding: call Loader.getClass(key), check for falsy/undefined, log or
throw a clear error (including key and config info) and skip pushing into
componentPairs when undefined, otherwise proceed to create the component with
entity.addComponent; reference Loader.getClass, entity.addComponent,
componentPairs, config, and key when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts`:
- Around line 41-57: The code reads background.mode without ensuring
scene.background exists, so add a guard for the optional background before
accessing its properties: check that scene.background (the local const
background) is defined (e.g., if (!background) { /* skip */ } or wrap the
existing logic in if (background) { ... }) before using backgroundMode,
background.texture, skyMesh or skyMaterial; keep using the same calls to
context._addDependentAsset, resourceManager.getResourceByRef and
assetRefToEngine for the dependent-asset handling.

---

Nitpick comments:
In
`@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`:
- Around line 238-245: When destroying entities from overrides.removedEntities
in HierarchyParser (the loop that looks up paths via ctx.entityMap and calls
target.destroy()), ensure children are destroyed before parents by sorting or
iterating the paths in reverse-depth order (e.g., longest path first) so parent
removals don't invalidate child destruction; additionally guard each destroy
call with an existence check on ctx.entityMap.get(path) (or verify
target.isDestroyed is false) before calling target.destroy() to avoid silent
failures.
- Around line 137-141: The code calls Loader.getClass(key) and immediately
passes its result to entity.addComponent, which can throw if the class is
undefined; update the loop in HierarchyParser to validate the returned class
before adding: call Loader.getClass(key), check for falsy/undefined, log or
throw a clear error (including key and config info) and skip pushing into
componentPairs when undefined, otherwise proceed to create the component with
entity.addComponent; reference Loader.getClass, entity.addComponent,
componentPairs, config, and key when making the change.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`:
- Around line 85-94: The $component branch in ReflectionParser.ts uses
Loader.getClass(comp.type) without checking for undefined, which can cause
entity.getComponents to throw; update the block that reads comp (the
"$component" in obj branch) to first store the result of
Loader.getClass(comp.type) (or call a variable like compClass) and if it's
undefined return Promise.resolve(null) (or otherwise short-circuit) before
calling entity.getComponents(compClass, []); ensure you reference comp,
Loader.getClass, and entity.getComponents when applying the guard.
- Around line 69-77: The code in ReflectionParser that handles objects with a
"$type" needs to guard against Loader.getClass($type) returning undefined;
update the block where you call Loader.getClass($type) and construct instance
(in ReflectionParser) to check whether Class is truthy, and if not throw or
return a rejected Promise with a clear error mentioning the unresolved type name
(e.g., include $type and context like "unregistered class type in
ReflectionParser"); ensure parsing flow (parseProps and
Promise.resolve(instance)) remains unchanged when Class is valid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 029afca3-d2dd-42c7-b05c-a04d13c1e20a

📥 Commits

Reviewing files that changed from the base of the PR and between 247a8ee and 2030582.

📒 Files selected for processing (9)
  • .gitignore
  • packages/loader/src/SceneLoader.ts
  • packages/loader/src/prefab/PrefabParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts
  • packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts
  • packages/loader/src/scene-format/types.ts
  • tests/src/loader/PrefabResource.test.ts
  • tests/src/loader/SceneFormatV2.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .gitignore
  • packages/loader/src/scene-format/types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/loader/src/prefab/PrefabParser.ts

prettier 拆行后 @ts-ignore 只覆盖了 resourceManager 而非下一行的
.getResourceByRef,将 @ts-ignore 移到 getResourceByRef 正上方。
同时格式化所有 changed 文件通过 prettier 检查。
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 89.48718% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.64%. Comparing base (931f254) to head (9391566).
⚠️ Report is 12 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
packages/loader/src/SceneLoader.ts 81.01% 30 Missing ⚠️
...ce-deserialize/resources/parser/HierarchyParser.ts 88.53% 29 Missing ⚠️
...e-deserialize/resources/parser/ReflectionParser.ts 88.96% 17 Missing ⚠️
packages/loader/src/MaterialLoader.ts 50.00% 2 Missing ⚠️
packages/loader/src/SpriteLoader.ts 0.00% 2 Missing ⚠️
packages/loader/src/PrefabLoader.ts 50.00% 1 Missing ⚠️
packages/loader/src/schema/refs.ts 88.88% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2959      +/-   ##
===========================================
+ Coverage    77.41%   77.64%   +0.23%     
===========================================
  Files          898      905       +7     
  Lines        98314    98714     +400     
  Branches      9775     9959     +184     
===========================================
+ Hits         76105    76644     +539     
+ Misses       22043    21901     -142     
- Partials       166      169       +3     
Flag Coverage Δ
unittests 77.64% <89.48%> (+0.23%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts (1)

44-60: ⚠️ Potential issue | 🟠 Major

Guard the optional background block.

The code reads background.mode without checking if background is defined. If the scene file lacks a background section, this will throw a runtime error during dependency collection.

🛡️ Suggested fix
     const background = scene.background;
-    const backgroundMode = background.mode;
-    if (backgroundMode === BackgroundMode.Texture) {
+    if (background) {
+      const backgroundMode = background.mode;
+      if (backgroundMode === BackgroundMode.Texture) {
+        const texture = background.texture;
+        if (texture) {
+          // `@ts-ignore`
+          context._addDependentAsset(texture.$ref, resourceManager.getResourceByRef(assetRefToEngine(texture)));
+        }
+      } else if (backgroundMode === BackgroundMode.Sky) {
+        // ...existing sky handling...
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts`
around lines 44 - 60, SceneParser accesses background.mode without checking
background; wrap the existing background handling in a guard that checks
background is defined (e.g., if (background) { ... }) before reading
background.mode and its fields so dependency collection
(context._addDependentAsset, resourceManager.getResourceByRef, assetRefToEngine)
never runs on undefined; ensure both Texture and Sky branches stay inside that
guard and keep existing null checks for texture, skyMesh, and skyMaterial.
🧹 Nitpick comments (4)
packages/loader/src/SceneLoader.ts (2)

190-200: Consider simplifying the TextRenderer handler to a synchronous function.

The handler is declared async but doesn't perform any asynchronous operations. Since Font.createFromOS appears to be synchronous and no await is used, the handler could be a regular function returning the instance directly, improving clarity.

♻️ Suggested simplification
 ReflectionParser.registerCustomParseComponent(
   "TextRenderer",
-  async (instance: any, item: { props?: Record<string, unknown> }) => {
+  (instance: any, item: { props?: Record<string, unknown> }) => {
     const { props } = item;
     if (!props?.font) {
       // `@ts-ignore`
       instance.font = Font.createFromOS(instance.engine, (props?.fontFamily as string) || "Arial");
     }
     return instance;
   }
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/SceneLoader.ts` around lines 190 - 200, The TextRenderer
custom parse handler is declared async but performs no asynchronous work; change
the handler passed to ReflectionParser.registerCustomParseComponent for
"TextRenderer" to be a synchronous function (remove the async keyword) that sets
instance.font via Font.createFromOS when props.font is absent and then returns
the instance directly; keep the same parameter names (instance, item) and logic
around props?.font and props?.fontFamily so only the function signature changes.

44-51: Consider extracting the array/object color handling pattern to a helper.

The same pattern for handling colors as either arrays or objects is repeated three times (diffuseSolidColor, background.solidColor, fogColor). A small helper function would reduce duplication and improve maintainability.

♻️ Proposed helper function
function applyColor(target: { set: (r: number, g: number, b: number, a: number) => void; copyFrom: (c: any) => void }, color: any): void {
  if (Array.isArray(color)) {
    target.set(color[0], color[1], color[2], color[3]);
  } else {
    target.copyFrom(color);
  }
}

// Usage:
if (solidColor) applyColor(scene.ambientLight.diffuseSolidColor, solidColor);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/SceneLoader.ts` around lines 44 - 51, Extract the
repeated array/object color handling into a helper (e.g., applyColor) and use it
from SceneLoader wherever colors are handled: replace the inline checks around
scene.ambientLight.diffuseSolidColor, background.solidColor, and fogColor with
calls to applyColor(target, color). The helper should accept a target with
set(r,g,b,a) and copyFrom(color) methods and perform Array.isArray(color) ?
target.set(...) : target.copyFrom(color) so the three duplicated blocks are
removed and the logic centralized.
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts (2)

272-279: Misleading type annotation on getResourceByRef.

The generic <Entity> suggests the resource is an Entity, but the actual return type is PrefabResource | GLTFResource. Consider using a more accurate type to improve code clarity.

♻️ Suggested type fix
     return (
       engine.resourceManager
         // `@ts-ignore`
-        .getResourceByRef<Entity>(assetRefToEngine(instance.asset))
+        .getResourceByRef<PrefabResource | GLTFResource>(assetRefToEngine(instance.asset))
         .then((prefabResource: PrefabResource | GLTFResource) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 272 - 279, The type parameter on
engine.resourceManager.getResourceByRef is misleading (currently <Entity>) —
update the call in HierarchyParser to use the correct resource union type
(PrefabResource | GLTFResource) or remove the generic so TypeScript infers the
return as PrefabResource | GLTFResource; replace the incorrect generic and
remove the accompanying // `@ts-ignore` so the subsequent logic that calls
prefabResource.instantiate() or prefabResource.instantiateSceneRoot() is typed
accurately.

226-245: Consider override application order for edge cases.

addedEntities is processed before removedEntities. If an override adds a child entity to a parent that is subsequently removed, the newly added entity becomes orphaned (destroyed along with its parent). This may be intentional behavior, but consider documenting the override application order or reordering to process removals first if orphaned entities are unexpected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 226 - 245, Summary: addedEntities are applied before
removedEntities which can orphan newly added children; either process removals
first or document the order. In HierarchyParser, locate the block handling
overrides.addedEntities and overrides.removedEntities and either swap the two
loops so removedEntities are processed before addedEntities (use
overrides.removedEntities loop first, call ctx.entityMap.get(path) and
target.destroy() as currently done) or add a clear comment/docstring near
HierarchyParser explaining that additions happening before removals may be
intentionally orphaned; ensure references to _createInlineEntity, ctx.entityMap,
overrides.addedEntities, overrides.removedEntities, and target.destroy are
updated accordingly so future readers understand the chosen behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`:
- Around line 71-80: When handling polymorphic objects in ReflectionParser (the
"$type" branch), guard the Loader.getClass($type) result before calling new
Class(): check if Class is falsy/undefined and throw or return a rejected
Promise with a clear error that includes the unresolved $type string and context
(e.g., "Unregistered type: { $type } in ReflectionParser"), otherwise proceed to
instantiate and call this.parseProps(instance, rest) as before; update uses of
Class, instance, parseProps to follow this new guard so the error is descriptive
instead of a cryptic runtime crash.
- Around line 99-109: In ReflectionParser, when handling the "$signal" branch
avoid calling _resolveSignal if the existing signal instance (originValue) is
undefined/null; add a guard before invoking _resolveSignal that checks
originValue (the resolved target instance) and either return early (e.g., skip
registration/return originValue) or throw a clear error/log message so
_resolveSignal is never called on null; update the "$signal" handling code to
validate originValue and reference the same parameter names (originValue and
_resolveSignal) to locate the fix.

---

Duplicate comments:
In `@packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts`:
- Around line 44-60: SceneParser accesses background.mode without checking
background; wrap the existing background handling in a guard that checks
background is defined (e.g., if (background) { ... }) before reading
background.mode and its fields so dependency collection
(context._addDependentAsset, resourceManager.getResourceByRef, assetRefToEngine)
never runs on undefined; ensure both Texture and Sky branches stay inside that
guard and keep existing null checks for texture, skyMesh, and skyMaterial.

---

Nitpick comments:
In
`@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`:
- Around line 272-279: The type parameter on
engine.resourceManager.getResourceByRef is misleading (currently <Entity>) —
update the call in HierarchyParser to use the correct resource union type
(PrefabResource | GLTFResource) or remove the generic so TypeScript infers the
return as PrefabResource | GLTFResource; replace the incorrect generic and
remove the accompanying // `@ts-ignore` so the subsequent logic that calls
prefabResource.instantiate() or prefabResource.instantiateSceneRoot() is typed
accurately.
- Around line 226-245: Summary: addedEntities are applied before removedEntities
which can orphan newly added children; either process removals first or document
the order. In HierarchyParser, locate the block handling overrides.addedEntities
and overrides.removedEntities and either swap the two loops so removedEntities
are processed before addedEntities (use overrides.removedEntities loop first,
call ctx.entityMap.get(path) and target.destroy() as currently done) or add a
clear comment/docstring near HierarchyParser explaining that additions happening
before removals may be intentionally orphaned; ensure references to
_createInlineEntity, ctx.entityMap, overrides.addedEntities,
overrides.removedEntities, and target.destroy are updated accordingly so future
readers understand the chosen behavior.

In `@packages/loader/src/SceneLoader.ts`:
- Around line 190-200: The TextRenderer custom parse handler is declared async
but performs no asynchronous work; change the handler passed to
ReflectionParser.registerCustomParseComponent for "TextRenderer" to be a
synchronous function (remove the async keyword) that sets instance.font via
Font.createFromOS when props.font is absent and then returns the instance
directly; keep the same parameter names (instance, item) and logic around
props?.font and props?.fontFamily so only the function signature changes.
- Around line 44-51: Extract the repeated array/object color handling into a
helper (e.g., applyColor) and use it from SceneLoader wherever colors are
handled: replace the inline checks around scene.ambientLight.diffuseSolidColor,
background.solidColor, and fogColor with calls to applyColor(target, color). The
helper should accept a target with set(r,g,b,a) and copyFrom(color) methods and
perform Array.isArray(color) ? target.set(...) : target.copyFrom(color) so the
three duplicated blocks are removed and the logic centralized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c4d49ffd-87a7-4d84-9ef2-7fd19720d90a

📥 Commits

Reviewing files that changed from the base of the PR and between 2030582 and 35715fa.

📒 Files selected for processing (5)
  • packages/loader/src/SceneLoader.ts
  • packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/ParserContext.ts
  • packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts
  • packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts

- SceneParser: 提取 _searchComponentDependentAssets/_searchInlineEntityDependentAssets,
  补充 addedEntities 递归扫描
- ReflectionParser: parseProps 接受可选 props 参数,$type 分支加 getClass guard
- HierarchyParser: 3 处 Loader.getClass 加未注册类型 guard,消除调用侧冗余 props 判空
- 测试: 补充 prefab override 全部 6 种类型测试,加 getClass 错误路径测试
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts (1)

103-113: ⚠️ Potential issue | 🟡 Minor

Guard against missing signal instance.

If the target property doesn't have an existing Signal instance (originValue is undefined), _resolveSignal will attempt to call methods on null/undefined, causing a runtime error. Consider adding a null check before invoking _resolveSignal.

🛡️ Proposed guard
     // $signal — signal binding: register listeners on the existing Signal instance
     if ("$signal" in obj) {
+      if (!originValue) {
+        console.warn("ReflectionParser: $signal target does not have an existing Signal instance");
+        return Promise.resolve(originValue);
+      }
       return this._resolveSignal(
         originValue,
         obj.$signal as Array<{
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 103 - 113, The $signal handling block in ReflectionParser.ts should
guard against a missing Signal instance: before calling this._resolveSignal with
originValue, check that originValue is not null/undefined (the resolved target
Signal instance); if it is missing, either skip registration or log/throw a
clear error. Update the code path that handles "$signal" to validate originValue
and only call _resolveSignal(originValue, obj.$signal) when originValue is
present, referencing the "$signal" branch and the _resolveSignal method to
locate the change.
🧹 Nitpick comments (3)
packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts (2)

92-101: Consider guarding against unregistered component types in $component resolution.

If Loader.getClass(comp.type) returns undefined for an unregistered type, entity.getComponents(undefined, []) may produce unexpected behavior. For consistency with the $type guard at line 78, consider adding a similar check here.

🛡️ Proposed guard
     // $component — component reference: { entity, type, index }
     if ("$component" in obj) {
       const comp = obj.$component as { entity: number; type: string; index: number };
       const entity = this._context.entityMap.get(comp.entity);
       if (entity) {
-        const components = entity.getComponents(Loader.getClass(comp.type), []);
+        const CompClass = Loader.getClass(comp.type);
+        if (!CompClass) {
+          console.warn(`ReflectionParser: $component type "${comp.type}" is not registered`);
+          return Promise.resolve(null);
+        }
+        const components = entity.getComponents(CompClass, []);
         return Promise.resolve(components[comp.index] ?? null);
       }
       return Promise.resolve(null);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 92 - 101, In the $component branch of ReflectionParser.ts guard
against unregistered component types by calling Loader.getClass(comp.type) into
a variable (e.g., compClass) and if it's undefined return Promise.resolve(null);
otherwise pass compClass to entity.getComponents and return
components[comp.index] ?? null — this mirrors the $type guard and prevents
calling entity.getComponents(undefined, []) when comp.type is unregistered.

141-148: Same unguarded Loader.getClass pattern in signal resolution.

Similar to the $component branch, Loader.getClass(comp.type) at line 146 could return undefined for unregistered types, leading to entity.getComponents(undefined, []).

🛡️ Proposed guard
     const promises = listeners.map((listener) => {
       const comp = listener.target.$component;
       const entity = this._context.entityMap.get(comp.entity);
       let targetComponent = null;
       if (entity) {
-        const components = entity.getComponents(Loader.getClass(comp.type), []);
+        const CompClass = Loader.getClass(comp.type);
+        if (!CompClass) {
+          console.warn(`ReflectionParser: $signal target type "${comp.type}" is not registered`);
+          return Promise.resolve();
+        }
+        const components = entity.getComponents(CompClass, []);
         targetComponent = components[comp.index] ?? null;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`
around lines 141 - 148, The code maps listeners and calls
Loader.getClass(comp.type) without guarding for undefined which can pass
undefined into entity.getComponents; update the listeners mapping in
ReflectionParser (the block that sets comp = listener.target.$component and
calls entity.getComponents(Loader.getClass(comp.type), [])) to first store the
result of Loader.getClass(comp.type) in a variable (e.g., compClass), check if
compClass is truthy, and only call entity.getComponents(compClass, []) when
compClass is defined; if compClass is undefined, set targetComponent to null or
skip that listener so you do not call getComponents(undefined, ...).
packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts (1)

106-110: Consider defensive check for missing child entities.

If children[j] references an index not in entityMap (malformed data), parent.addChild(undefined) could cause unexpected behavior. This is likely low risk if data is validated upstream, but a guard could improve debugging experience.

🛡️ Proposed defensive check
       const parent = entityMap.get(i);
       for (let j = 0, m = children.length; j < m; j++) {
-        parent.addChild(entityMap.get(children[j]));
+        const child = entityMap.get(children[j]);
+        if (child) {
+          parent.addChild(child);
+        } else {
+          console.warn(`HierarchyParser: child index ${children[j]} not found for entity ${i}`);
+        }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`
around lines 106 - 110, Guard against missing child entries when iterating
children in HierarchyParser: before calling
parent.addChild(entityMap.get(children[j])), check that
entityMap.has(children[j]) (or that the retrieved value is not undefined); if
missing, either skip adding and emit a warning/error including the parent
identifier and missing child index (or throw a descriptive error) to aid
debugging. Update the loop that uses parent.addChild, referencing entityMap,
children and parent.addChild to perform this defensive existence check and
handle the malformed case consistently with project logging/error patterns.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`:
- Around line 103-113: The $signal handling block in ReflectionParser.ts should
guard against a missing Signal instance: before calling this._resolveSignal with
originValue, check that originValue is not null/undefined (the resolved target
Signal instance); if it is missing, either skip registration or log/throw a
clear error. Update the code path that handles "$signal" to validate originValue
and only call _resolveSignal(originValue, obj.$signal) when originValue is
present, referencing the "$signal" branch and the _resolveSignal method to
locate the change.

---

Nitpick comments:
In
`@packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts`:
- Around line 106-110: Guard against missing child entries when iterating
children in HierarchyParser: before calling
parent.addChild(entityMap.get(children[j])), check that
entityMap.has(children[j]) (or that the retrieved value is not undefined); if
missing, either skip adding and emit a warning/error including the parent
identifier and missing child index (or throw a descriptive error) to aid
debugging. Update the loop that uses parent.addChild, referencing entityMap,
children and parent.addChild to perform this defensive existence check and
handle the malformed case consistently with project logging/error patterns.

In
`@packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts`:
- Around line 92-101: In the $component branch of ReflectionParser.ts guard
against unregistered component types by calling Loader.getClass(comp.type) into
a variable (e.g., compClass) and if it's undefined return Promise.resolve(null);
otherwise pass compClass to entity.getComponents and return
components[comp.index] ?? null — this mirrors the $type guard and prevents
calling entity.getComponents(undefined, []) when comp.type is unregistered.
- Around line 141-148: The code maps listeners and calls
Loader.getClass(comp.type) without guarding for undefined which can pass
undefined into entity.getComponents; update the listeners mapping in
ReflectionParser (the block that sets comp = listener.target.$component and
calls entity.getComponents(Loader.getClass(comp.type), [])) to first store the
result of Loader.getClass(comp.type) in a variable (e.g., compClass), check if
compClass is truthy, and only call entity.getComponents(compClass, []) when
compClass is defined; if compClass is undefined, set targetComponent to null or
skip that listener so you do not call getComponents(undefined, ...).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 06914411-9712-4f8e-9401-49d38798bee8

📥 Commits

Reviewing files that changed from the base of the PR and between 35715fa and 8f20981.

📒 Files selected for processing (5)
  • packages/loader/src/resource-deserialize/resources/parser/HierarchyParser.ts
  • packages/loader/src/resource-deserialize/resources/parser/ReflectionParser.ts
  • packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts
  • tests/src/loader/PrefabResource.test.ts
  • tests/src/loader/SceneFormatV2.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/loader/src/resource-deserialize/resources/scene/SceneParser.ts

@luzhuang luzhuang marked this pull request as draft April 11, 2026 05:43
- ResourceManager.getResourceByRef 签名从 { url } 改为 { $ref }
- 删除 assetRefToEngine() 适配函数,所有调用方直传 AssetRef
- 删除旧类型 IAssetRef,统一使用 AssetRef { $ref, key? }
- GLTFExtensionSchema IGalaceanMaterialRemap.url → $ref
- AnimationClipLoader 资产引用检测从 .url 改为 .$ref
- 删除 GalaceanAssetInfo 接口,version 提升到 IHierarchyFile 顶层
- 删除三个接口中的 isLocked 编辑器专用字段
- 删除 IHierarchyFile 中的 _entityIds/_componentIds 编辑器专用字段
@luzhuang luzhuang changed the title feat(loader): native v2 scene/prefab format parsing [WIP]feat(loader): native v2 scene/prefab format parsing Apr 11, 2026
- SceneSchema: color/solidColor/fogColor 类型从 Vec4Tuple | IColor 改为 Vec4Tuple
- SceneSchema: shadowFourCascadeSplits 类型从 IVector3 改为 Vec3Tuple
- SceneSchema: 删除对 BasicSchema 中 IColor/IVector3 的 import
- SceneLoader: 删除 3 处 Array.isArray/copyFrom 双分支,只保留数组路径
- SceneLoader: shadowFourCascadeSplits 从 copyFrom(IVector3) 改为 set(tuple)
- types.ts: 去掉 Galacean* 前缀(EntitySchema/ComponentSchema/SceneFile 等),
  IHierarchyFile → HierarchyFile,合并 SceneSchema 的 IScene 到 SceneFile,
  删除死字段 customSpecularTexture 和 bakerResolution,SpecularMode 移入 types.ts
- 删除 SceneSchema.ts(IScene/SpecularMode 已合并到 types.ts)
- schema/index.ts: 删除对 scene-format/types 的 re-export,
  改由 loader/index.ts 直接 export * from "./scene-format/types"
- SceneLoader.ts: 提取 applySceneData 为独立导出函数,消除测试 false green
- ParserContext.ts: 删除未使用的 phantom type T
- GLTFExtensionSchema.ts: IGalaceanMaterialRemap extends AssetRef 复用类型
- 所有 parser/test 文件同步更新类型引用
- 测试: scene property 测试改为调用 applySceneData 生产代码,
  新增 PrefabResource destroy-then-re-instantiate 生命周期测试
TextRenderer 构造时已通过 engine._textDefaultFont 设置默认字体,
不需要额外的 fontFamily 回退逻辑。该 handler 引入了不存在于 v2
格式规范中的 fontFamily 字段,属于超出需求范围的发明。
- types.ts: EntitySchema 和 InlineEntitySchema 改为 extends EntityOverrideProps,
  消除 12 行重复字段声明
- ReflectionParser: 合并 _resolveValue 末尾 plain object 两个分支为一个循环,
  提取 _resolveComponent helper 消除重复,简化 signal arguments 处理
- HierarchyParser: 删除构造函数 5 行 .bind(this),start() 改用 inline arrow
- _resolveComponent: 恢复 v1 的 Loader.getClass null guard,未注册类型返回 null
- _resolveSignal: 添加 signal 实例存在性检查,要求组件 Signal 字段必须已初始化
- 补充对应错误路径测试
@luzhuang
Copy link
Copy Markdown
Contributor Author

@GuoLei1990 对照当前 PR head(fda6c7d)重新过了一遍你这条 review,逐项同步一下处理结果和现在的判断,避免把已经被后续提交消化掉的意见和仍然有效的点混在一起。

先说结论:你这条 review 的整体方向是对的,尤其是从“这是一次结构性迁移,不只是局部修补”这个角度看问题很准确;但对照当前 head 来看,大部分代码层面的 P2/P3 已经在后续提交里处理掉了,现阶段我不打算继续为了这条 review 再改实现代码,剩下主要是 PR 表达和契约说明层面的事情。

已在后续提交中处理的点

  1. _overrideKeyToPath 的实现

你提到这里不应该用 JSON.parse 去解析 "[0,1]" 这种固定格式的 key,这个我认同,后续已经改成了纯字符串处理:

private static _overrideKeyToPath(key: string): string {
  return key.slice(1, -1).replace(/,/g, "/");
}

也就是已经从“通用 JSON 解析”收敛到了“按已知格式做最小转换”。

  1. transform 赋值重复

你指出 _applyEntityData / _applyEntityOverrides / _createInlineEntity 三处 transform tuple 赋值逻辑重复,这个后续也已经收敛了。现在统一落在 HierarchyParser._applyEntityProps() 里,name / isActive / layer / position / rotation / scale 的 entity-level 应用逻辑已经合并,不再保留之前那种三处各写一份的状态。

  1. TextRenderer registerCustomParseComponent 的模块级 side effect

你提这个点时是成立的,但这部分后续已经删掉了。当前 SceneLoader 里不再有这个顶层注册逻辑,所以这条顾虑在当前 head 上已经不存在了。

  1. customParseComponentHandles 的宽类型问题

这部分后续已经从宽泛的 Function 收窄成显式的 handler 类型:

export type CustomParseComponentHandle = (
  instance: any,
  item: { props?: Record<string, unknown> }
) => Promise<any> | any;

对应的静态字段也已经用 Record<string, CustomParseComponentHandle> 约束了。

  1. _entityIds / _componentIds 调试字段暴露到公共类型

这两个字段后续已经去掉,当前 scene-format/types.ts 里没有再暴露这类调试字段,所以这条在当前 head 上也已经不是问题。

我保留但不继续改代码的点

  1. ReflectionParser 的 plain object 分支

你提到 _resolveValueoriginValue 不存在时会构造普通对象,而不是业务类型实例,这个分析本身是对的。

我现在的理解是:

  • 这不是这次 PR 新引入的 bug,v1 的 parseBasicType 里就存在相同前提。
  • v2 这里的契约是更显式的:
    • 如果属性本身已经有默认实例,就走 in-place 修改;
    • 如果需要在反序列化阶段显式构造类型实例,就用 $type
    • 如果两者都没有,就只能得到 plain object。

所以我倾向于把它视为“格式契约需要说清楚”,而不是继续在 parser 里增加隐式推断逻辑。也就是说,这条我认同它是一个需要让使用方知道的边界,但不认为还需要在当前实现上继续打补丁。

  1. Promise.all(promises).then(() => {})

这个建议我理解,但我这边没有继续调整。原因不是它错,而是它更偏向风格/局部表达:这里的 .then(() => {}) 目的就是把 Promise<any[]> 收束成 Promise<void>,对于当前这类 staged parser 流程来说可读性是足够的。继续抽 helper 或改返回类型,对行为没有实质收益,所以我没有把它当成需要继续处理的项。

我认同的问题,但它已经不再是“继续改 parser 代码”

BasicSchema.ts / SceneSchema.ts 中大量 v1 类型被删掉、兼容性策略没有在 PR 描述里写清楚,这个点我认同,而且我觉得这是你这条 review 里当前仍然最有价值的一条。

不过对照当前实现,我认为它的落点已经不是“代码遗漏”,而是“PR 需要明确表达这次变更的真实性质”:

  • 当前 loader pipeline 已经是有意切到 v2-only 了;
  • SceneLoader / PrefabLoader / HierarchyParser 都直接按 v2 schema 工作;
  • @galacean/engine-loader 对外导出的 v1 schema 类型也确实收缩了;
  • 所以这不是一个“保留 v1 兼容、顺便支持 v2”的 PR,而是一次明确的 cutover。

这也意味着这里更需要补的是 PR 标题/描述/兼容性说明,而不是再往 parser 里补一层回退逻辑。否则代码和 PR 叙述会继续不一致,reviewer 会天然按“新增 v2 支持但不该破坏 v1”来理解这次改动。

当前判断

综合下来,我现在的处理思路是:

  • 代码层面:你这条 review 里大部分可执行项已经在后续提交中处理完了,当前 head 上没有我准备继续补的实现项。
  • 契约/表达层面:
    • 会把这次改动明确表述成 v2 pipeline cutover,而不是 backward-compatible feature;
    • 会在说明里补充 plain object / $type 的约束前提,避免使用方误解 parser 会自动推断业务类型。

如果你觉得还有哪一条在当前 head 上仍然是“必须继续改代码”的,我们可以再基于最新 commit 具体看,不按旧 diff 状态讨论。

…romConfig helper

- ReflectionParser: 删除 CustomParseComponentHandle 类型、静态字段、注册方法及 parseProps 中的
  handle 调用 — TextRenderer handler 在 091649e 中已删除,整套机制无消费者
- ReflectionParser: $entity 空值返回 null 而非 undefined
- HierarchyParser: 提取 _addComponentFromConfig 静态方法,消除 3 处重复的 getClass+guard+addComponent
- 测试: 删除 customParseComponentHandles 测试,新增 $entity null、scene 属性解析覆盖
mock 对象缺少 _addReferCount 方法和 SphericalHarmonics3.coefficients,
导致引擎 setter 内部调用崩溃。
…核心职责

_isAssetRef 仅被 SceneParser._searchDependentAssets 一处调用,外层 else if 已保证
value 是非空 object,内联后只需 "$ref" in value 一个条件。
编辑器侧 shadow/fog/ambientOcclusion 字段全部 optional,
引擎类型定义同步改为 optional,applySceneData 统一使用 != undefined guard。
字段名保持与引擎 Scene 属性一致(fogMode 等),编辑器侧后续对齐。
@luzhuang luzhuang marked this pull request as ready for review April 13, 2026 05:36
@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

$entity / $component 无法引用 prefab instance 内部深层子节点

问题

当前 v2 格式中,$entity$component 通过 flat index 从 context.entityMap 查找目标:

// ReflectionParser
if ("$entity" in obj) {
  const entity = this._context.entityMap.get(obj.$entity as number);
}

entityMap 中,prefab instance 只注册了根节点:

// HierarchyParser._parseEntities
if (HierarchyParser._isPrefabInstanceEntity(entityConfig)) {
  this._loadPrefabInstance(entityConfig, engine).then((entity) => {
    entityMap.set(i, entity);  // 只注册 prefab 根节点
  });
}

这意味着:

  • 可以引用 prefab instance 的根节点及其上的 component
  • 无法引用 prefab instance 内部更深层的子节点/子 component

实际场景

这在项目中很常见:

Scene
  ├── Camera (CameraFollow.target → Player/Body)
  └── Player (prefab instance, entities[1])
        ├── Body     ← 需要被引用,但无法通过 $entity 寻址
        └── Weapon

相机跟随角色骨骼、UI 绑定角色头顶锚点、动画系统驱动子节点等,都需要跨 prefab 边界引用内部子节点。如果格式不支持,用户只能在脚本中运行时 findByName,无法在编辑器中可视化拖拽绑定。

v1 的 entityPath: number[] 设计可以沿 children 逐层走入 prefab 子树,v2 改成 flat index 后丢失了这个能力。

建议

后续考虑扩展 $entity / $component 的寻址方式,支持 path 形式,例如:

{ "$entity": { "index": 1, "path": [0] } }
// index 1 → prefab instance root, path [0] → 第 0 个 child (Body)

不阻塞当前 PR 合入,作为后续格式演进。


Comment from code review

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

removedEntities / removedComponents 正序删除导致 children 索引错位

问题

HierarchyParser._parsePrefabOverridesremovedEntitiesremovedComponents 按正序遍历并逐个 destroy:

// HierarchyParser.ts:222-227
if (overrides.removedEntities) {
    for (let j = 0, m = overrides.removedEntities.length; j < m; j++) {
      HierarchyParser._resolveEntity(rootEntity, overrides.removedEntities[j]).destroy();
    }
}

_resolveEntity 按 path 中的 child index 逐层查找 entity.children[path[i]]。而 Entity.destroy() 会立即调用 _removeFromParent()_removeFromChildren(),从 parent 的 children 数组中移除自己并重排后续 sibling 的索引。

这意味着:第一个 entity 被 destroy 后,后续 sibling 的 children index 会前移,导致后续 path 指向错误的 entity 或越界。

触发场景

prefab 模板:

root
  ├── A  (child index 0)
  ├── B  (child index 1)
  └── C  (child index 2)

编辑器中用户删除 A 和 C,导出:

"removedEntities": [[0], [2]]

运行时执行:

  1. path [0]root.children[0] = A → A.destroy() → children 变成 [B, C]
  2. path [2]root.children[2] = undefined → throw Error,整个场景加载失败

编辑器侧验证

顺着编辑器代码也确认了这不是纯理论 case:

  • editor/packages/model/src/domain/commands/prefab/OverrideCommands.tsRemoveSourceEntityOverrideCommandRemoveSourceComponentOverrideCommand 确实会生成 removedEntities / removedComponents
  • 编辑器内部用 Record<string, true> 存储,导出时转为数组,顺序取决于 Object.keys() 枚举顺序,没有排序或归并逻辑
  • scene-parser.ts 导出时直接透传,不做任何倒序化处理

所以 loader 侧正序 resolve + destroy,不只是健壮性不足,是真的会被编辑器正常导出的 override 数据触发。

removedComponents 同样受影响

// HierarchyParser.ts:230-238
const selectors = override.selectors;
for (let k = 0, p = selectors.length; k < p; k++) {
    HierarchyParser._resolveComponent(entity, selectors[k]).destroy();
}

_resolveComponentgetComponents(type, buffer) 按类型收集后取 buffer[selector.index]。如果 selectors 里有同类型多个 component(如删第 0 个和第 2 个 MeshRenderer),第一个被 destroy 后 getComponents 返回的数组少一个元素,第二个的 index 就错位了。

建议修复

倒序删除,确保先删 index 大的,不影响 index 小的:

// removedEntities
if (overrides.removedEntities) {
    const sorted = [...overrides.removedEntities].sort(
      (a, b) => b[b.length - 1] - a[a.length - 1]
    );
    for (let j = 0, m = sorted.length; j < m; j++) {
      HierarchyParser._resolveEntity(rootEntity, sorted[j]).destroy();
    }
}

// removedComponents — 按 index 倒序
for (let k = selectors.length - 1; k >= 0; k--) {
    HierarchyParser._resolveComponent(entity, selectors[k]).destroy();
}

或者在编辑器导出时保证倒序,但防御性地在 loader 侧也做倒序更稳妥。


Comment from code review

removedEntities and removedComponents destroyed targets one-by-one via
resolveEntity/resolveComponent, but destroy() synchronously mutates the
parent children array (Entity.destroy -> _setParent(null) -> removeFromParent)
and shifts subsequent sibling/component indices, so later paths in the
same override batch either go out of range or delete the wrong target.

Pre-resolve all targets before destroying any.
Extend $entity and $component.entity to accept a path array instead of a
single flat index. First element is the flat index into top-level entities[];
subsequent elements descend into children arrays.

This enables scene-level scripts to reference entities/components inside a
prefab instance's subtree (e.g. CameraFollow.target -> Player prefab's Head
bone), which the single-index form could not express.

Also document the distinction vs override path fields (path/parent/target),
which are instance-local and not affected.
@luzhuang
Copy link
Copy Markdown
Contributor Author

@cptbtptpbcptdtptp @GuoLei1990 逐条回应:

已修复

1. removedEntities / removedComponents 正序删除导致索引偏移 (@cptbtptpbcptdtptp)

确认是 bug。Entity.destroy() 通过 _setParent(null) 同步从父 _children 数组移除,多路径按顺序 destroy 时后续索引会错位/越界(如 [[0], [2]] 删第一个后 [2] 越界,[[0], [1]] 删第一个后 [1] 指向原 [2])。

已改为先预解析所有目标再批量 destroy:a66db9fef

覆盖测试:

  • should remove multiple sibling entities via removedEntities
  • should remove multiple components of same type via removedComponents

2. \$entity / \$component 无法引用 prefab instance 深层子节点 (@cptbtptpbcptdtptp)

确认是能力收缩。已扩展 \$entity 值和 ComponentRef.entitynumbernumber[](path 形式):353e92b6d

  • path[0] = 顶层 entities[] 的 flat index
  • path[1..] = 沿 children 下降
  • 空数组非法;[5] 仍表示顶层第 5 个 entity

覆盖测试:

  • should resolve \$entity deep child by path 单测
  • should resolve \$entity into prefab instance deep child E2E
  • should resolve \$component into prefab instance deep child component E2E

设计选择,不改

3. parseMutationBlock props/calls 并发 (@cptbtptpbcptdtptp @GuoLei1990)

这是刻意的格式契约,不是 race。v2 约定:同一 MutationBlock 内 props 和 calls 独立——props 设置声明式属性,calls 执行命令式 setup 方法,依赖通过 call 自身参数传入($ref / $entity / $component)而非从组件其他 prop 读。典型 setup 方法(setMaterial(i, mat) / addBurst(burst) / addShape(shape))都符合这个模式。

若 builder 产出了"call 依赖同 block 内异步 prop"的组合,属于 builder 生成不当。真正需要顺序依赖的场景,格式层有 result 嵌套机制。

4. addedEntitiesremovedEntities 前执行会打乱 children 索引 (@GuoLei1990)

这个机制论据不成立。parent.addChild(child)(Entity.ts:320)默认走 _addToChildren(Entity.ts:74),当 index === undefined 时是 children[childCount] = entity——纯追加到末尾,不改已有 siblings 索引

反之若按建议先 remove 后 add,removedEntities 的 destroy 会先改动 children 数组,后续 addedEntities_resolveEntity(root, parentPath) 反而可能解析到错误 parent。当前顺序更安全。

架构方向,本 PR 外

5. 把 refs[] 收归 project.json (@cptbtptpbcptdtptp)

方向认可——全局 manifest 是业界共识(Unity Addressables / Cocos config.json)。但需要 runtime 从自描述 scene 切到强依赖全局 manifest,涉及依赖图构建、加载生命周期调整、增量切场景、按需卸载等,远超本 PR scope。当前 refs[] 已从 v1 的递归扫描优化到 O(n) 线性,作为中间方案有效。建议独立 issue 跟进。

GuoLei1990

This comment was marked as outdated.

@luzhuang
Copy link
Copy Markdown
Contributor Author

@cptbtptpbcptdtptp 认可。两处 _resolveComponent 的 buffer 使用完全同语义(length=0 → getComponents(type, buffer) → buffer[index] → length=0),全同步、无 await 插入、不可能交错。已合并到 ReflectionParser._componentBuffer 单例:8d35f0ea7

@luzhuang
Copy link
Copy Markdown
Contributor Author

回复一下最后这个 P2:我重新按当前实现核了一遍,这条在现状里应该不成立。

addedEntities 这里最终走的是 parent.addChild(entity),而 Entity.addChild(child) 在未传 index 时是 append 到 children 末尾,不会改已有子节点的 sibling index。也就是说,当前实现下 addedEntities -> removedEntities 不会因为“新增子节点”把原有 sibling 的 path 解析错。

这次真正需要处理的是 destroy 过程中的索引漂移,这部分已经通过 pre-resolve targets 修掉了。

如果后续 addedEntities 支持显式插入位置,或者 editor 侧明确约定 removed path 的求值语义基于另一棵树,再一起统一这部分规则会更合适。现阶段我倾向于把这个点当作 spec 讨论,不作为当前 PR 的待修问题。

GuoLei1990

This comment was marked as outdated.

@GuoLei1990
Copy link
Copy Markdown
Member

小建议:结构位置的 refs 索引统一用裸 number

原则

结构位置裸 number,值位置用 { $ref }

  • 结构位置:schema 里类型固定的字段,parser 按字段名直读
    例:instance.assetbackground.textureambient.ambientLight
  • 值位置props / args 里的值,类型 unknown,parser 走反射通用解析
    例:props.materialargs[0]

理由

  • 结构位置类型已知,无歧义,加 { $ref } 是冗余
  • 值位置类型未知,{ hp: 100 }{ material: 3 } 都是 number,必须靠 $ 前缀区分普通数值和资源索引

小 fix

协议里绝大多数结构位置的 refs 索引字段(instance.assetbackground.texture/skyMesh/skyMaterialambient.ambientLight/customAmbientLight)都是裸 number,唯一例外是 ComponentSchema.script 用了 AssetRef。建议改为裸 number 对齐其他结构字段:

 interface ComponentSchema extends MutationBlock {
   type: string;
-  script?: AssetRef;
+  script?: number;
 }
-"script": { "$ref": 2 }
+"script": 2

解析侧同步改一行:

 const key = config.script
-  ? resolveRefItem(refs, config.script.$ref, "HierarchyParser", "component.script").url
+  ? resolveRefItem(refs, config.script, "HierarchyParser", "component.script").url
   : config.type;

Schema:
- ComponentSchema.script: AssetRef -> number, align with other
  structural refs index fields (instance.asset, background.texture)
- Drop NormalEntitySchema.instance?: undefined, JSON never carries it
- SceneSchema declares postProcess?: unknown, drops (as any) in loader

Parser:
- Extract _applyOverrides from _parsePrefabOverrides for clearer
  responsibility split
- Cache refs = this.data.refs outside loops in _parseComponents and
  _parsePrefabOverrides
- Replace 3x Promise.all(promises) as any with Promise<unknown>
  return type, zero runtime cost
- Promise<any>[] -> Promise<unknown>[] for push-only buffers

SceneLoader:
- Introduce loadRef helper, removes 5 duplicated resolveRefItem +
  getResourceByRef + @ts-ignore blocks
- ResourceManager.getResourceByRef param field $ref -> url
- AssetRef type removed, unified to RefItem { url, key? }
- ParserContext.entityMap: Map -> Entity[] (array index access)
- entityMap -> entityInstances, componentPairs -> pendingComponents
- pendingComponents element field component -> instance
- All call sites and tests updated
_addDependentAsset is only called once per refs[] entry in
SceneParser._collectDependentAssets. refs[] is deduplicated by the
editor, so tasks.has(url) never returns true — it was dead code.

Signature simplified: _addDependentAsset(promise) without url param.
…indices

Previously addedEntities.entity and addedComponents.component were inline
objects (InlineEntitySchema / ComponentSchema), preventing other objects
from referencing them via $entity / $component.

Now they are indices into the top-level entities[] / components[] arrays,
same as regular entities and components. This enables:
- Scripts can reference added entities via $entity: [index]
- Components on added entities can be referenced via $component
- Unified serialization path in the editor (no special inline case)
- Schema simplification: InlineEntitySchema type removed
- Runtime simplification: _createInlineEntity recursion removed

Added entities/components are still only attached to the prefab instance
subtree via addedEntities.parent / addedComponents.target paths. They are
created in Stage 1/3 like normal objects but not auto-attached as scene
roots (not in scene.entities) or child nodes (not referenced by any
entity.children), only by the addedEntities/addedComponents override.

Protocol change (breaking):
  addedEntities: [{ parent, entity: InlineEntitySchema }]
  -> addedEntities: [{ parent, entity: number }]

  addedComponents: [{ target, component: ComponentSchema }]
  -> addedComponents: [{ target, component: number }]
@GuoLei1990
Copy link
Copy Markdown
Member

GuoLei1990 commented Apr 19, 2026

关于 refs[] 收归 project.json 的建议,这个架构选择值得展开谈一下。

cptbtptpbcptdtptp 列举的四个引擎(Unity Addressables / Unreal AssetRegistry / Cocos config.json / PlayCanvas)都是发布型引擎——交付物是完整应用,scene 文件永远在自己的 registry 体系里使用,用户下载整个项目才能玩。registry 模式对它们合理。

但 Galacean 走的是另一条路——生态型引擎。这两种引擎的本质区别:

发布型(Unity/Unreal/Cocos/PlayCanvas)

  • 交付物 = 完整应用
  • scene 文件是私有格式,永远在自己的 registry 体系内使用
  • registry 模式合理(反正整个项目一起下载)

生态型(Galacean)

  • 交付物 = 单个资产(.scene / .prefab)
  • 资产跨项目流通,服务于资产库场景
  • 必须自描述(否则资产脱离项目就废了)

资产库场景:registry 路线的根本冲突

对 Galacean 来说,资产库场景是核心——不像 Unity 需要加载 bundle,Galacean 应该能纯粹地加载单个 scene 或 prefab。运行时真正的跨项目复用,不依赖编辑器项目重新导入导出。

资产库的本质就是"每个资产独立可消费":设计师上传一个 prefab,消费方(任何项目)拿到 URL 就能用。registry 模式在这里存在根本冲突——如果每个资产都必须挂在某个 project.json 下才能加载,资产库就退化成了"预打包项目的分发渠道",而不是真正的资产库。这对资产库建设是致命的:

  • 资产生产方必须维护一份完整的 "meta project" 来让资产可用
  • 资产消费方必须拉取源项目的 registry 才能使用单个资产
  • 资产更新要同步更新 registry,多个消费方的缓存一致性变复杂
  • 跨团队 / 跨 BU 复用资产从"复制 URL"变成"集成项目"

自描述不只是"生态",也是开发者体验的全面优势

除了跨项目复用和生态友好,自描述对单个项目内的开发体验同样有决定性优势:

加载 API 简洁

// 自描述:一步
const prefab = await resourceManager.load("Player.prefab");

// registry:两步 + 全局初始化
await resourceManager.load("project.json");
const prefab = await resourceManager.load("Player.prefab");

跨项目复用零接入成本

  • 自描述:直接复制 URL 加载
  • registry:必须拉取源项目 project.json → 合并到自己的 → 重新构建

单文件可读可调试

  • 自描述:打开 scene.json,refs[] 就是完整依赖清单
  • registry:scene.json 里是一堆 uuid,要交叉查 project.json 才能理解

CDN 缓存友好

  • 自描述:每个资产独立 URL,缓存策略简单,改一个资产只影响自己
  • registry:改任何资产都可能让 project.json 变,用户每次都得重新拉 registry

嵌入到其他应用

  • 自描述:几行代码嵌入一个 prefab(类似电商商品 3D 预览)
  • registry:嵌入变成"子应用集成",必须带上完整 registry

Web 开发者心智模型

  • 自描述:资产是 URL 可寻址的对象,和 <img> / <video> 一样
  • registry:必须"激活项目"才能用资产,类似原生游戏引擎的项目制

registry 被援引的 4 个收益分析

cptbtptpbcptdtptp 列的四个收益在当前架构下都不成问题:

  • 精确进度: refs[] 可补 size 字段(编辑器导出时填),自描述格式不妨碍进度计算
  • 增量加载: ResourceManager._loadingPromises 已去重实际下载,跨 scene 共享资产在 HTTP 层零额外开销,JSON 体积差异 gzip 后微乎其微
  • 按需卸载: Galacean 的 ReferResource 引用计数系统已经完整实现 _refCount / _superResources / gc(),不依赖全局依赖图
  • Tree shaking: 这是 build 期工具问题,runtime 协议无关

这是策略层选择,不是实现优化

这不是"方向认可但以后再考虑"的问题——是 Galacean 的设计路线选择。自描述与"资产库独立使用 + Web 原生 URL 可寻址 + 跨项目复用 + 易用性"的定位一致,registry 模式和这个定位互斥。走 registry 路线会关闭资产库发展的可能性,不应走那条路。


基于这次 review 的讨论,我提了一个补充的优化 PR: luzhuang#41

refactor/scene-format-v2 基础上针对当前 PR 做了代码质量收尾,包括:

  • 协议一致性: ComponentSchema.script: AssetRef → number 对齐其他结构位置的裸 number;addedEntities / addedComponents 从内联对象改为顶层索引(让追加对象能被 $entity/$component 引用);AssetRef 统一为 RefItem { url, key? };删除 NormalEntitySchema.instance?: undefined 这种 JSON 永不出现的 TS hack
  • ResourceManager API: getResourceByRef 参数字段 $ref → url 对齐值语义,消除所有调用点的 @ts-ignore
  • 性能: entityMapMap<number, Entity> 改为 Entity[](key 本就是 0..n-1 连续整数,数组下标访问更快且内存紧凑);循环外缓存 refs 避免每轮属性查找;3 处 Promise.all() as any 改为 Promise<unknown> 返回类型,零运行时开销消除类型断言
  • 类型诚实化: Promise<any>[]Promise<unknown>[];(sceneData as any).postProcess 配合 schema 声明消除 as any
  • 职责分离: _parsePrefabOverrides 80+ 行拆出 _applyOverrides,主函数只负责"找 prefab instance + 调度";删除死代码 ParserContext._tasks URL 去重
  • 消除重复: applySceneData 引入 loadRef helper 消除 5 处重复的 resolveRefItem + getResourceByRef + @ts-ignore 样板

87 个 loader tests 全部通过。PR #41 描述里详细展开了每条改动,包括自描述设计立场的完整论证。

… number

Align with other structure-position ref fields (instance.asset,
background.texture, etc.) which all use bare number indices into
the top-level refs array.
@luzhuang
Copy link
Copy Markdown
Contributor Author

回复 @cptbtptpbcptdtptp 的三条评论:

1. parseMutationBlock props/calls 并发时序

分析准确,但结论不需要改。在引擎架构中 props 和 calls 是独立的初始化操作——props 设声明式属性值,calls 执行命令式 setup 方法(setMaterialaddBurst 等),这些 setup 方法的依赖通过自身参数传入($ref$entity 等已解析),不会从组件的其他 props 读取。如果需要顺序依赖,格式层已有 result 嵌套机制。并行是刻意的设计选择,注释也明确写了 "without imposing ordering between them"。

已补充测试:props 和 calls 的断言改为独立验证(captureResolvedArgs 代替 appendSuffix),不再隐式依赖执行顺序。

2. refs 收归 project.json

认同 GuoLei 的回复。Galacean 走的是生态型引擎路线,资产自描述(refs[] 内嵌)是架构基石——资产库场景下 prefab/scene 必须独立可消费,不能依赖外部 registry。列举的四个收益(精确进度、增量加载、按需卸载、tree shaking)在当前架构下都不成问题。

3. $entity/$component 无法引用 prefab instance 深层子节点

已修复。$entity$component.entitynumber 扩展为 number | number[][flatIndex, ...childIndices]),恢复了跨 prefab 边界引用深层子节点的能力。

@luzhuang
Copy link
Copy Markdown
Contributor Author

回复 @GuoLei1990

ComponentSchema.script → 裸 number

已改。script?: AssetRefscript?: number,解析侧 config.script.$refconfig.script,与其他结构位置字段(instance.assetbackground.texture 等)对齐。commit 8a557a8ba

P2: Override 应用顺序

确认过代码:_createInlineEntity 调用 parent.addChild(entity) 无 index 参数,走 _addToChildrenList(undefined, this) 纯追加到 children 末尾,不改变已有子节点的 sibling index。所以 removedEntities 的路径(基于原始 prefab 结构)在 addedEntities 之后解析仍然正确。

如果后续 addedEntities 支持显式插入位置(带 siblingIndex),再重新审视顺序。当前不需要调整。

Replace appendSuffix (order-dependent) with captureResolvedArgs
(order-independent) in MutationBlock tests. Props and calls are
verified separately, matching the parallel execution design.
…ization

refactor(loader): protocol consistency + honest types + quality cleanup

- ComponentSchema.script: AssetRef → number (aligned at merge)
- addedEntities/addedComponents: inline → top-level indices
- entityMap: Map → Entity[]
- getResourceByRef param $ref → url (eliminate @ts-ignore)
- rename componentPairs → pendingComponents, component → instance
- delete AssetRef (unified to RefItem)
- delete NormalEntitySchema.instance?: undefined
- delete ParserContext._tasks dead code
- Promise.all() as any → Promise<unknown>
- SceneLoader loadRef helper
- extract _applyOverrides method
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

Only contained a single skipped empty test with a stale TODO.
All v2 scene parsing coverage is in SceneFormatV2.test.ts.
The benchmark test creates 120 PrefabResource instances with unique
URLs (Math.random()) across two test cases, and never destroys the
outer prefab references. With v8 coverage instrumentation and the
CI macos runner memory budget, this caused the Test step to hang
indefinitely (38+ min vs 77s baseline).

Performance observation tests should not gate CI; run locally via
'npx vitest run tests/src/loader/PrefabBenchmark.test.ts' instead.
Replace the previous --exclude workaround with outright deletion.
The benchmark was a performance observation test (weak expect(avg > 0)
assertions) with resource leaks (120 outer PrefabResource never
destroyed) that hung CI coverage for 38+ minutes.

Revert package.json coverage script to baseline.
GuoLei1990

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator

@cptbtptpbcptdtptp cptbtptpbcptdtptp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

重写 loader/parser 子系统以原生消费 v2 scene/prefab 格式。v2 格式使用扁平 entity/component 数组 + 整数索引引用(替代字符串 ID)、$ 前缀值类型($ref/$type/$entity/$component/$signal)、顶层 refs[] 集中资产引用。Prefab 实例支持完整 override 系统。

架构方向正确。ParserContext 从 6 个 Map 简化为 2 个数组,_collectDependentAssets 从递归树遍历简化为 refs[] 线性遍历,5 阶段管线(parseEntities → organize → addComponents → propsAndCalls → prefabOverrides)职责清晰。测试覆盖充分。

问题

  • [P1] parseMutationBlock 并行执行 props 和 calls — 违反调用顺序保证ReflectionParser.parseMutationBlock 通过 Promise.all 同时执行 parsePropsparseCalls。如果一个 call 的方法依赖某个 prop 已经被赋值($ref 类型的 prop 是异步的),就会出现竞态。虽然注释说"不强制 props/calls 顺序",但这是反直觉的——序列化数据中,先设属性再调方法是自然预期。如果 call 的参数不依赖同 block 的 props(编辑器序列化保证),需要在注释中显式声明这个前提条件。否则建议改为顺序执行:先 parsePropsparseCalls,性能代价可忽略。

  • [P2] applySceneData 标记 @internal 但测试通过路径绕过 barrel importSceneLoader.ts 导出 applySceneData 并标 @internalindex.ts 只做 side-effect import。测试直接 import "../../../packages/loader/src/SceneLoader" 绕过 barrel。如果函数确实是 internal,测试应通过内部路径 import(加注释说明原因)。如果未来有 editor 消费需求,考虑 export { applySceneData } from "./SceneLoader" 并改为 @beta

  • [P2] _componentBuffer 静态共享数组跨 parser 耦合ReflectionParser._componentBufferHierarchyParser._resolveComponent 共享使用,契约是"同步 length=0 → getComponents → 读取 → length=0"。当前安全(单线程同步调用),但隐式耦合。如果 getComponents 变为异步或并行加载多个场景,会静默数据污染。建议改为调用点局部数组或实例级 buffer。

  • [P3] applySceneData 中 try-catch 只包裹 ambient + background,shadow/fog/AO 在外面 — 结构不对称。要么统一包裹,要么移除 try-catch(外层 caller 已有 .catch(reject))。

简化建议

  • parseMutationBlock 顺序化后更简单也更安全
  • resolveRefItemschema/refs.ts)只有 4 行逻辑、3 个调用点,可考虑内联为数组越界检查 helper,减少一个文件

整体是优质重构。LGTM,P1 修复后可合入。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants