Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/core/src/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/asset/LoadItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type LoadItem = {
params?: Record<string, any>;
} & PickOnlyOne<{
/**
* Loading url.
* Requested resource identity. Virtual resources keep their virtual path so
* loaders can resolve dependent resources through ResourceManager.
*/
url: string;
/**
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/asset/ResourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
91 changes: 44 additions & 47 deletions packages/loader/src/TextureLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,39 +72,29 @@ class TextureLoader extends Loader<Texture> {
}

private _decodeImage(buffer: ArrayBuffer, item: LoadItem, resourceManager: ResourceManager): AssetPromise<Texture2D> {
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<TextureParams>) ?? {};

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<TextureParams>) ?? {};

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;
});
}

Expand Down Expand Up @@ -151,26 +141,33 @@ class TextureContentRestorer extends ContentRestorer<Texture> {
return texture;
}

return new AssetPromise<Texture>((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<HTMLImageElement> {
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.
*/
Expand Down
20 changes: 11 additions & 9 deletions tests/src/core/Utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
36 changes: 31 additions & 5 deletions tests/src/core/resource/ResourceManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -335,19 +357,23 @@ 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")
.mockReturnValue(new AssetPromise(() => {}));
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();
Expand Down
65 changes: 65 additions & 0 deletions tests/src/loader/SpriteAtlasLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
49 changes: 49 additions & 0 deletions tests/src/loader/TextureLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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<Blob>((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<Texture2D>({ url: virtualPath });
const restorers = Object.values((resourceManager as any)._contentRestorerPool) as Array<{
resource: Texture2D;
restoreContent(): AssetPromise<Texture2D>;
}>;
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();
}
});
});
Loading