From 6291edd1df4d9380bbf98814c97ba7409b8c6330 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 28 Jul 2026 17:54:08 +0800 Subject: [PATCH] fix(loader): preserve virtual resource identity --- packages/core/src/Utils.ts | 14 ++- packages/core/src/asset/LoadItem.ts | 3 +- packages/core/src/asset/ResourceManager.ts | 15 +-- packages/loader/src/TextureLoader.ts | 91 +++++++++---------- tests/src/core/Utils.test.ts | 20 ++-- .../src/core/resource/ResourceManager.test.ts | 36 +++++++- tests/src/loader/SpriteAtlasLoader.test.ts | 65 +++++++++++++ tests/src/loader/TextureLoader.test.ts | 49 ++++++++++ 8 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 tests/src/loader/SpriteAtlasLoader.test.ts create mode 100644 tests/src/loader/TextureLoader.test.ts diff --git a/packages/core/src/Utils.ts b/packages/core/src/Utils.ts index 893cfa9b58..133712a1e7 100644 --- a/packages/core/src/Utils.ts +++ b/packages/core/src/Utils.ts @@ -82,10 +82,16 @@ export class Utils { return relativeUrl ? new URL(relativeUrl, baseUrl).href : baseUrl; } - const head = "file://"; - const encodedBaseUrl = head + this._encodePathComponents(baseUrl); + // A relative virtual asset path (for example `SpriteAtlas/...`) must be + // treated as a path, not as the host portion of a file URL. The latter + // lowercases the first segment and breaks case-sensitive virtual-resource + // lookup in Editor previews + const resolvedHasLeadingSlash = baseUrl.startsWith("/") || relativeUrl.startsWith("/"); + const head = "file:///"; + const encodedBaseUrl = head + this._encodePathComponents(baseUrl.replace(/^\/+/, "")); const encodedRelativeUrl = this._encodePathComponents(relativeUrl); - return decodeURIComponent(new URL(encodedRelativeUrl, encodedBaseUrl).href.slice(head.length)); + const resolvedPath = decodeURIComponent(new URL(encodedRelativeUrl, encodedBaseUrl).href.slice(head.length)); + return resolvedHasLeadingSlash ? `/${resolvedPath}` : resolvedPath; } /** @@ -134,7 +140,7 @@ export class Utils { * @param path - The path of the property to get. * @returns Returns the resolved value. */ - static _reflectGet(target: Object, path: string) { + static _reflectGet(target: object, path: string) { const pathArr = this._stringToPath(path); let object = target; diff --git a/packages/core/src/asset/LoadItem.ts b/packages/core/src/asset/LoadItem.ts index 7897f2bd61..2090219e75 100644 --- a/packages/core/src/asset/LoadItem.ts +++ b/packages/core/src/asset/LoadItem.ts @@ -28,7 +28,8 @@ export type LoadItem = { params?: Record; } & PickOnlyOne<{ /** - * Loading url. + * Requested resource identity. Virtual resources keep their virtual path so + * loaders can resolve dependent resources through ResourceManager. */ url: string; /** diff --git a/packages/core/src/asset/ResourceManager.ts b/packages/core/src/asset/ResourceManager.ts index edb66f696a..f1e0c5be8c 100644 --- a/packages/core/src/asset/ResourceManager.ts +++ b/packages/core/src/asset/ResourceManager.ts @@ -346,12 +346,15 @@ export class ResourceManager { const virtualResourceEntry = this._virtualPathResourceMap[assetBaseURL]; this._resolveLoadItemOptions(item, virtualResourceEntry); - // Not absolute and base url is set - item.url = - !Utils.isAbsoluteUrl(assetBaseURL) && this.baseUrl - ? Utils.resolveAbsoluteUrl(this.baseUrl, assetBaseURL) - : assetBaseURL; - const remoteAssetBaseURL = virtualResourceEntry?.path ?? item.url; + // Keep a virtual resource's logical identity so loaders can resolve its + // sibling resources through the virtual-resource map. `baseUrl` only + // resolves ordinary relative transport URLs + const loadItemUrl = + virtualResourceEntry !== undefined || Utils.isAbsoluteUrl(assetBaseURL) || !this.baseUrl + ? assetBaseURL + : Utils.resolveAbsoluteUrl(this.baseUrl, assetBaseURL); + item.url = loadItemUrl; + const remoteAssetBaseURL = virtualResourceEntry?.path ?? loadItemUrl; // Check cache const cacheObject = this._assetUrlPool[remoteAssetBaseURL]; diff --git a/packages/loader/src/TextureLoader.ts b/packages/loader/src/TextureLoader.ts index ff669d6c59..c29835784f 100644 --- a/packages/loader/src/TextureLoader.ts +++ b/packages/loader/src/TextureLoader.ts @@ -72,39 +72,29 @@ class TextureLoader extends Loader { } private _decodeImage(buffer: ArrayBuffer, item: LoadItem, resourceManager: ResourceManager): AssetPromise { - return new AssetPromise((resolve, reject) => { - const blob = new Blob([buffer]); - const img = new Image(); - img.onload = () => { - URL.revokeObjectURL(img.src); - const { - format = TextureFormat.R8G8B8A8, - isSRGBColorSpace = true, - mipmap = true - } = (item.params as Partial) ?? {}; - - const engine = resourceManager.engine; - const { width, height } = img; - const generateMipmap = TextureUtils.supportGenerateMipmapsWithCorrection( - engine, - width, - height, - format, - mipmap, - isSRGBColorSpace - ); - - const texture = new Texture2D(engine, width, height, format, generateMipmap, isSRGBColorSpace); - texture.setImageSource(img); - generateMipmap && texture.generateMipmaps(); - this._applyParams(texture, item); - resolve(texture); - }; - img.onerror = (e) => { - URL.revokeObjectURL(img.src); - reject(e); - }; - img.src = URL.createObjectURL(blob); + return decodeImage(buffer, item.url!).then((img) => { + const { + format = TextureFormat.R8G8B8A8, + isSRGBColorSpace = true, + mipmap = true + } = (item.params as Partial) ?? {}; + + const engine = resourceManager.engine; + const { width, height } = img; + const generateMipmap = TextureUtils.supportGenerateMipmapsWithCorrection( + engine, + width, + height, + format, + mipmap, + isSRGBColorSpace + ); + + const texture = new Texture2D(engine, width, height, format, generateMipmap, isSRGBColorSpace); + texture.setImageSource(img); + generateMipmap && texture.generateMipmaps(); + this._applyParams(texture, item); + return texture; }); } @@ -151,26 +141,33 @@ class TextureContentRestorer extends ContentRestorer { return texture; } - return new AssetPromise((resolve, reject) => { - const blob = new Blob([buffer]); - const img = new Image(); - img.onload = () => { - URL.revokeObjectURL(img.src); - texture.setImageSource(img); - texture.mipmapCount > 1 && texture.generateMipmaps(); - resolve(texture); - }; - img.onerror = (e) => { - URL.revokeObjectURL(img.src); - reject(e); - }; - img.src = URL.createObjectURL(blob); + return decodeImage(buffer, this.url).then((img) => { + texture.setImageSource(img); + texture.mipmapCount > 1 && texture.generateMipmaps(); + return texture; }); }) ); } } +function decodeImage(buffer: ArrayBuffer, url: string): AssetPromise { + return new AssetPromise((resolve, reject) => { + const objectUrl = URL.createObjectURL(new Blob([buffer])); + const image = new Image(); + const releaseObjectUrl = () => URL.revokeObjectURL(objectUrl); + image.onload = () => { + releaseObjectUrl(); + resolve(image); + }; + image.onerror = () => { + releaseObjectUrl(); + reject(new Error(`TextureLoader: failed to decode texture "${url}" (${buffer.byteLength} bytes).`)); + }; + image.src = objectUrl; + }); +} + /** * Texture loader params interface. */ diff --git a/tests/src/core/Utils.test.ts b/tests/src/core/Utils.test.ts index 0565fd4b50..626b76e8e5 100644 --- a/tests/src/core/Utils.test.ts +++ b/tests/src/core/Utils.test.ts @@ -28,22 +28,24 @@ describe("Utils test", function () { "https://www.galacean.com/texture.png" ); - expect(Utils.resolveAbsoluteUrl("/path/to/dir", "file.html")).to.equal( - "/path/to/file.html" - ); + expect(Utils.resolveAbsoluteUrl("/path/to/dir", "file.html")).to.equal("/path/to/file.html"); - expect(Utils.resolveAbsoluteUrl("/path/to/dir", "../file.html")).to.equal( - "/path/file.html" - ); + expect(Utils.resolveAbsoluteUrl("/path/to/dir", "../file.html")).to.equal("/path/file.html"); - expect(Utils.resolveAbsoluteUrl("/a/b", "./空 格")).to.equal( - "/a/空 格" - ); + expect(Utils.resolveAbsoluteUrl("/a/b", "./空 格")).to.equal("/a/空 格"); expect(Utils.resolveAbsoluteUrl("/a c%/中%20文/test1/test2", "../空 格/测%试.json")).to.equal( "/a c%/中%20文/空 格/测%试.json" ); + expect(Utils.resolveAbsoluteUrl("SpriteAtlas/Art/UI/auto-atlas.atlas", "./auto-atlas_image_0.tex")).to.equal( + "SpriteAtlas/Art/UI/auto-atlas_image_0.tex" + ); + + expect(Utils.resolveAbsoluteUrl("SpriteAtlas/Art/UI/auto-atlas.atlas", "/Shared/page.tex")).to.equal( + "/Shared/page.tex" + ); + const base64Url = "data:application/octet-stream;base64,AAAAAImICD2JiIg9zczMPYmICD6rqio"; expect(Utils.resolveAbsoluteUrl("https://www.galacean.com", base64Url)).to.equal(base64Url); }); diff --git a/tests/src/core/resource/ResourceManager.test.ts b/tests/src/core/resource/ResourceManager.test.ts index 35d3b901a7..7e6e07e5ff 100644 --- a/tests/src/core/resource/ResourceManager.test.ts +++ b/tests/src/core/resource/ResourceManager.test.ts @@ -183,6 +183,28 @@ describe("ResourceManager", () => { } }); + it("preserves the virtual resource identity for loaders", () => { + const resourceManager = engine.resourceManager; + const virtualPath = "Assets/precompiled-shader"; + const physicalPath = "/Assets/precompiled-shader.shaderc"; + resourceManager.registerVirtualResources([{ virtualPath, path: physicalPath, type: AssetType.Shader }]); + // @ts-ignore + const loaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.Shader], "load") + .mockReturnValue(new AssetPromise(() => {})); + + try { + resourceManager.load({ url: virtualPath }); + + expect(loaderSpy).toHaveBeenCalled(); + const [loadItem] = loaderSpy.mock.calls[0]; + expect(loadItem.url).equal(virtualPath); + expect(loadItem).not.toHaveProperty("resolvedUrl"); + } finally { + loaderSpy.mockRestore(); + } + }); + it("fills params from virtualPathResourceMap when params is omitted", () => { const resourceManager = engine.resourceManager; resourceManager.registerVirtualResources([ @@ -335,9 +357,9 @@ describe("ResourceManager", () => { it("resolves virtualPath via map even when baseUrl is set", () => { const resourceManager = engine.resourceManager; - resourceManager.registerVirtualResources([ - { virtualPath: "Assets/withBaseUrl", path: "https://cdn.ali.com/real.json", type: AssetType.Texture } - ]); + const virtualPath = "Assets/withBaseUrl"; + const physicalPath = "https://cdn.ali.com/real.json"; + resourceManager.registerVirtualResources([{ virtualPath, path: physicalPath, type: AssetType.Texture }]); // @ts-ignore const loaderSpy = vi .spyOn(ResourceManager._loaders[AssetType.Texture], "load") @@ -345,9 +367,13 @@ describe("ResourceManager", () => { resourceManager.baseUrl = "https://base.com/app/"; try { - resourceManager.load({ url: "Assets/withBaseUrl" }); + resourceManager.load({ url: virtualPath }); expect(loaderSpy).toHaveBeenCalled(); - expect(loaderSpy.mock.calls[0][0].type).equal(AssetType.Texture); + expect(loaderSpy.mock.calls[0][0]).toMatchObject({ + type: AssetType.Texture, + url: virtualPath + }); + expect(loaderSpy.mock.calls[0][0]).not.toHaveProperty("resolvedUrl"); } finally { resourceManager.baseUrl = null; loaderSpy.mockRestore(); diff --git a/tests/src/loader/SpriteAtlasLoader.test.ts b/tests/src/loader/SpriteAtlasLoader.test.ts new file mode 100644 index 0000000000..7863a66c77 --- /dev/null +++ b/tests/src/loader/SpriteAtlasLoader.test.ts @@ -0,0 +1,65 @@ +import { AssetPromise, AssetType, ResourceManager, Texture2D, WebGLEngine } from "@galacean/engine"; +import "@galacean/engine-loader"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +let engine: WebGLEngine; + +beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); +}); + +afterAll(() => { + engine.destroy(); +}); + +describe("SpriteAtlasLoader", () => { + it("keeps virtual atlas page paths resolvable when a base URL is configured", async () => { + const resourceManager = engine.resourceManager; + const atlasVirtualPath = "SpriteAtlas/Migrated/BaseUrl/ui.atlas"; + const pageVirtualPath = "SpriteAtlas/Migrated/BaseUrl/ui_image_0.tex"; + const atlasPhysicalPath = "blob:https://local.alipay.net/atlas"; + const pagePhysicalPath = "blob:https://local.alipay.net/atlas-page"; + resourceManager.registerVirtualResources([ + { virtualPath: atlasVirtualPath, path: atlasPhysicalPath, type: AssetType.SpriteAtlas }, + { virtualPath: pageVirtualPath, path: pagePhysicalPath, type: AssetType.Texture } + ]); + resourceManager.baseUrl = "https://base.example.com/project/"; + + const requestSpy = vi.spyOn(resourceManager as any, "_requestByRemoteUrl").mockImplementation((url: string) => { + return new AssetPromise((resolve, reject) => { + if (url === atlasPhysicalPath) { + resolve({ atlasItems: [{ img: "./ui_image_0.tex", sprites: [] }] }); + } else if (url === pagePhysicalPath) { + resolve(new ArrayBuffer(0)); + } else { + reject(new Error(`Unexpected transport URL: ${url}`)); + } + }); + }); + // @ts-ignore - loaders are registered in ResourceManager's internal registry + const textureLoaderSpy = vi + .spyOn(ResourceManager._loaders[AssetType.Texture], "load") + .mockImplementation((item, manager) => { + // @ts-ignore - exercise ResourceManager's virtual-to-transport mapping boundary + return manager._request(item.url, { ...item, type: "arraybuffer" }).then(() => new Texture2D(engine, 1, 1)); + }); + + try { + await resourceManager.load({ url: atlasVirtualPath }); + + expect(requestSpy).toHaveBeenCalledWith(atlasPhysicalPath, expect.objectContaining({ type: "json" })); + expect(requestSpy).toHaveBeenCalledWith(pagePhysicalPath, expect.objectContaining({ type: "arraybuffer" })); + expect(textureLoaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: pageVirtualPath, + type: AssetType.Texture + }), + resourceManager + ); + } finally { + resourceManager.baseUrl = null; + requestSpy.mockRestore(); + textureLoaderSpy.mockRestore(); + } + }); +}); diff --git a/tests/src/loader/TextureLoader.test.ts b/tests/src/loader/TextureLoader.test.ts new file mode 100644 index 0000000000..51b26d4389 --- /dev/null +++ b/tests/src/loader/TextureLoader.test.ts @@ -0,0 +1,49 @@ +import { AssetPromise, AssetType, Texture2D, WebGLEngine } from "@galacean/engine"; +import "@galacean/engine-loader"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +let engine: WebGLEngine; + +beforeAll(async () => { + engine = await WebGLEngine.create({ canvas: document.createElement("canvas") }); +}); + +afterAll(() => { + engine.destroy(); +}); + +describe("TextureLoader", () => { + it("keeps the resource identity when image decoding fails during content restoration", async () => { + const resourceManager = engine.resourceManager; + const virtualPath = "Texture/Migrated/page.tex"; + const physicalPath = "blob:https://local.alipay.net/page"; + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const pngBlob = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png")); + const pngBytes = await pngBlob.arrayBuffer(); + const invalidBytes = new Uint8Array([1, 2, 3]).buffer; + resourceManager.registerVirtualResources([{ virtualPath, path: physicalPath, type: AssetType.Texture }]); + const requestSpy = vi + .spyOn(resourceManager as any, "_requestByRemoteUrl") + .mockReturnValue(AssetPromise.resolve(pngBytes)); + + try { + const texture = await resourceManager.load({ url: virtualPath }); + const restorers = Object.values((resourceManager as any)._contentRestorerPool) as Array<{ + resource: Texture2D; + restoreContent(): AssetPromise; + }>; + const restorer = restorers.find((candidate) => candidate.resource === texture); + expect(restorer).toBeDefined(); + + requestSpy.mockReturnValue(AssetPromise.resolve(invalidBytes)); + + await expect(restorer!.restoreContent()).rejects.toThrow( + `TextureLoader: failed to decode texture "${virtualPath}" (${invalidBytes.byteLength} bytes).` + ); + } finally { + requestSpy.mockRestore(); + } + }); +});