Browse Source

feat(assets): add asset graph/session with owner tracking

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
d1a52e8816
  1. 102
      src/assets/AssetGraph.ts
  2. 22
      src/assets/AssetInspectorSnapshot.ts
  3. 32
      src/assets/AssetSession.ts
  4. 156
      src/core/AssetManager.ts
  5. 68
      tests/assets/asset-session.test.ts

102
src/assets/AssetGraph.ts

@ -0,0 +1,102 @@
import type { AssetInspectorSnapshot } from "./AssetInspectorSnapshot";
interface SessionEntry {
owner: string;
bundles: Set<string>;
}
export class AssetGraph {
private sessions: Map<string, SessionEntry> = new Map();
private ownerToSessions: Map<string, Set<string>> = new Map();
registerSession(sessionId: string, owner: string): void {
this.sessions.set(sessionId, {
owner,
bundles: new Set(),
});
const sessions = this.ownerToSessions.get(owner) ?? new Set<string>();
sessions.add(sessionId);
this.ownerToSessions.set(owner, sessions);
}
trackBundle(sessionId: string, bundleName: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.bundles.add(bundleName);
}
untrackBundle(sessionId: string, bundleName: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.bundles.delete(bundleName);
}
getSessionBundles(sessionId: string): string[] {
const session = this.sessions.get(sessionId);
if (!session) {
return [];
}
return [...session.bundles];
}
collectSnapshot(
bundleRefCounts: Map<string, number>,
sessionRefCounts: Map<string, Map<string, number>>
): AssetInspectorSnapshot {
const bundles: AssetInspectorSnapshot["bundles"] = {};
for (const [bundleName, refCount] of bundleRefCounts.entries()) {
if (refCount <= 0) {
continue;
}
const owners = new Set<string>();
const sessions = new Set<string>();
for (const [sessionId, refs] of sessionRefCounts.entries()) {
const count = refs.get(bundleName) ?? 0;
if (count <= 0) {
continue;
}
sessions.add(sessionId);
owners.add(this.sessions.get(sessionId)?.owner ?? "unknown");
}
bundles[bundleName] = {
refCount,
owners: [...owners].sort(),
sessions: [...sessions].sort(),
};
}
const sessionSnapshot: AssetInspectorSnapshot["sessions"] = {};
for (const [sessionId, session] of this.sessions.entries()) {
sessionSnapshot[sessionId] = {
owner: session.owner,
bundles: [...session.bundles].sort(),
};
}
const ownerSnapshot: AssetInspectorSnapshot["owners"] = {};
for (const [owner, sessions] of this.ownerToSessions.entries()) {
const bundlesByOwner = new Set<string>();
for (const sessionId of sessions) {
for (const bundle of this.sessions.get(sessionId)?.bundles ?? []) {
bundlesByOwner.add(bundle);
}
}
ownerSnapshot[owner] = {
sessions: [...sessions].sort(),
bundles: [...bundlesByOwner].sort(),
};
}
return {
activeBundles: Object.keys(bundles).sort(),
bundles,
sessions: sessionSnapshot,
owners: ownerSnapshot,
};
}
}

22
src/assets/AssetInspectorSnapshot.ts

@ -0,0 +1,22 @@
export interface AssetBundleSnapshot {
refCount: number;
owners: string[];
sessions: string[];
}
export interface AssetSessionSnapshot {
owner: string;
bundles: string[];
}
export interface AssetOwnerSnapshot {
sessions: string[];
bundles: string[];
}
export interface AssetInspectorSnapshot {
activeBundles: string[];
bundles: Record<string, AssetBundleSnapshot>;
sessions: Record<string, AssetSessionSnapshot>;
owners: Record<string, AssetOwnerSnapshot>;
}

32
src/assets/AssetSession.ts

@ -0,0 +1,32 @@
import type { AssetsBundle } from "pixi.js";
import type { AssetManager } from "@/core/AssetManager";
export class AssetSession {
readonly id: string;
readonly owner: string;
private manager: AssetManager;
constructor(manager: AssetManager, id: string, owner: string) {
this.manager = manager;
this.id = id;
this.owner = owner;
}
loadBundle(
name: string,
onProgress?: (progress: number) => void
): Promise<AssetsBundle | null> {
return this.manager.acquireBySession(this.id, name, onProgress);
}
releaseBundle(name: string): Promise<void> {
return this.manager.releaseBySession(this.id, name);
}
async releaseAll(): Promise<void> {
const bundles = this.manager.getSessionBundles(this.id);
for (const bundle of bundles) {
await this.releaseBundle(bundle);
}
}
}

156
src/core/AssetManager.ts

@ -1,16 +1,75 @@
import { Assets, type AssetsBundle } from "pixi.js";
import type { AssetsBundle } from "pixi.js";
import { AssetGraph } from "@/assets/AssetGraph";
import { AssetSession } from "@/assets/AssetSession";
import type { AssetInspectorSnapshot } from "@/assets/AssetInspectorSnapshot";
import { logger } from "./Logger";
class AssetManager {
private referenceCounts: Map<string, number> = new Map();
export interface AssetRuntimeAdapter {
init: () => Promise<void>;
loadBundle: (
name: string,
onProgress?: (progress: number) => void
) => Promise<AssetsBundle>;
unloadBundle: (name: string) => Promise<void>;
}
async init(): Promise<void> {
// 硬编码的 /manifest.json 路径是本应用的固定配置
const createPixiAdapter = (): AssetRuntimeAdapter => ({
init: async () => {
const { Assets } = await import("pixi.js");
await Assets.init({ manifest: "/manifest.json" });
},
loadBundle: async (name, onProgress) => {
const { Assets } = await import("pixi.js");
return Assets.loadBundle(name, onProgress);
},
unloadBundle: async (name) => {
const { Assets } = await import("pixi.js");
await Assets.unloadBundle(name);
},
});
export class AssetManager {
private adapter: AssetRuntimeAdapter;
private bundleRefCounts: Map<string, number> = new Map();
private bundleCache: Map<string, AssetsBundle> = new Map();
private sessionBundleRefs: Map<string, Map<string, number>> = new Map();
private graph: AssetGraph = new AssetGraph();
private sessionIdSeed = 0;
private defaultSession: AssetSession;
constructor(adapter?: AssetRuntimeAdapter) {
this.adapter = adapter ?? createPixiAdapter();
this.defaultSession = this.createSession("global");
}
async init(): Promise<void> {
await this.adapter.init();
logger.debug("AssetManager initialized");
}
async loadBundle(
createSession(owner: string): AssetSession {
const normalizedOwner = owner.trim();
if (normalizedOwner === "") {
throw new Error("AssetManager: Owner name cannot be empty string");
}
this.sessionIdSeed += 1;
const sessionId = `session-${this.sessionIdSeed}`;
this.graph.registerSession(sessionId, normalizedOwner);
this.sessionBundleRefs.set(sessionId, new Map());
return new AssetSession(this, sessionId, normalizedOwner);
}
getInspectorSnapshot(): AssetInspectorSnapshot {
return this.graph.collectSnapshot(this.bundleRefCounts, this.sessionBundleRefs);
}
getSessionBundles(sessionId: string): string[] {
return this.graph.getSessionBundles(sessionId);
}
async acquireBySession(
sessionId: string,
name: string,
onProgress?: (progress: number) => void
): Promise<AssetsBundle | null> {
@ -19,20 +78,32 @@ class AssetManager {
return null;
}
// 已经加载过,增加引用计数
if (this.referenceCounts.has(name)) {
const count = this.referenceCounts.get(name)! + 1;
this.referenceCounts.set(name, count);
logger.debug(`AssetManager: reuse bundle "${name}", refCount = ${count}`);
const sessionRefs = this.sessionBundleRefs.get(sessionId);
if (!sessionRefs) {
logger.warn(`AssetManager: unknown session "${sessionId}"`);
return null;
}
const totalRefCount = this.bundleRefCounts.get(name) ?? 0;
const sessionRefCount = sessionRefs.get(name) ?? 0;
if (totalRefCount > 0) {
this.bundleRefCounts.set(name, totalRefCount + 1);
sessionRefs.set(name, sessionRefCount + 1);
this.graph.trackBundle(sessionId, name);
logger.debug(
`AssetManager: reuse bundle "${name}", refCount = ${totalRefCount + 1}`
);
onProgress?.(1);
return Assets.loadBundle(name);
return this.bundleCache.get(name) ?? this.adapter.loadBundle(name);
}
// 首次加载
logger.debug(`AssetManager: loading bundle "${name}"`);
try {
const bundle = await Assets.loadBundle(name, onProgress);
this.referenceCounts.set(name, 1);
const bundle = await this.adapter.loadBundle(name, onProgress);
this.bundleRefCounts.set(name, 1);
this.bundleCache.set(name, bundle);
sessionRefs.set(name, 1);
this.graph.trackBundle(sessionId, name);
return bundle;
} catch (error) {
logger.error(`AssetManager: Failed to load bundle "${name}":`, error);
@ -40,36 +111,62 @@ class AssetManager {
}
}
async unloadBundle(name: string): Promise<void> {
async releaseBySession(sessionId: string, name: string): Promise<void> {
if (name === "") {
logger.warn("AssetManager: Bundle name cannot be empty string");
return;
}
if (!this.referenceCounts.has(name)) {
const sessionRefs = this.sessionBundleRefs.get(sessionId);
if (!sessionRefs) {
logger.warn(`AssetManager: unknown session "${sessionId}"`);
return;
}
const sessionRefCount = sessionRefs.get(name) ?? 0;
if (sessionRefCount <= 0) {
logger.warn(`AssetManager: unloading unloaded bundle "${name}"`);
return;
}
const count = this.referenceCounts.get(name)! - 1;
this.referenceCounts.set(name, count);
if (sessionRefCount === 1) {
sessionRefs.delete(name);
this.graph.untrackBundle(sessionId, name);
} else {
sessionRefs.set(name, sessionRefCount - 1);
}
if (count <= 0) {
const totalRefCount = (this.bundleRefCounts.get(name) ?? 0) - 1;
this.bundleRefCounts.set(name, totalRefCount);
if (totalRefCount <= 0) {
logger.debug(`AssetManager: unloading bundle "${name}"`);
await Assets.unloadBundle(name);
this.referenceCounts.delete(name);
await this.adapter.unloadBundle(name);
this.bundleRefCounts.delete(name);
this.bundleCache.delete(name);
} else {
logger.debug(`AssetManager: decrease refCount for "${name}" to ${count}`);
logger.debug(`AssetManager: decrease refCount for "${name}" to ${totalRefCount}`);
}
}
async loadBundle(
name: string,
onProgress?: (progress: number) => void
): Promise<AssetsBundle | null> {
return this.defaultSession.loadBundle(name, onProgress);
}
async unloadBundle(name: string): Promise<void> {
return this.defaultSession.releaseBundle(name);
}
isLoaded(name: string): boolean {
if (name === "") {
logger.warn("AssetManager: Bundle name cannot be empty string");
return false;
}
return this.referenceCounts.has(name) && this.referenceCounts.get(name)! > 0;
return this.bundleRefCounts.has(name) && this.bundleRefCounts.get(name)! > 0;
}
getRefCount(name: string): number {
@ -78,13 +175,16 @@ class AssetManager {
return 0;
}
return this.referenceCounts.get(name) ?? 0;
return this.bundleRefCounts.get(name) ?? 0;
}
clearAll(): void {
// 注意:此方法只会清除本地引用计数,不会从 Pixi 中卸载资源
// 因为完全卸载资源在实际应用中很少需要,且可能导致正在使用的资源失效
this.referenceCounts.clear();
this.bundleRefCounts.clear();
this.bundleCache.clear();
this.sessionBundleRefs.clear();
this.sessionIdSeed = 0;
this.graph = new AssetGraph();
this.defaultSession = this.createSession("global");
logger.debug("AssetManager: Reference count map cleared");
}
}

68
tests/assets/asset-session.test.ts

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { AssetManager } from "@/core/AssetManager";
type FakeBundle = Record<string, unknown>;
describe("AssetSession", () => {
it("tracks owner/session relationships in inspector snapshot", async () => {
const loadCalls: string[] = [];
const unloadCalls: string[] = [];
const manager = new AssetManager({
init: async () => undefined,
loadBundle: async (name: string) => {
loadCalls.push(name);
return { name } as FakeBundle;
},
unloadBundle: async (name: string) => {
unloadCalls.push(name);
},
});
const sceneSessionA = manager.createSession("scene");
const sceneSessionB = manager.createSession("scene");
const uiSession = manager.createSession("ui");
await sceneSessionA.loadBundle("characters");
await sceneSessionB.loadBundle("background");
await uiSession.loadBundle("characters");
const snapshot = manager.getInspectorSnapshot();
expect(snapshot.sessions[sceneSessionA.id]?.owner).toBe("scene");
expect(snapshot.sessions[sceneSessionB.id]?.owner).toBe("scene");
expect(snapshot.sessions[uiSession.id]?.owner).toBe("ui");
expect(snapshot.owners.scene?.sessions).toContain(sceneSessionA.id);
expect(snapshot.owners.scene?.sessions).toContain(sceneSessionB.id);
expect(snapshot.owners.ui?.sessions).toContain(uiSession.id);
expect(snapshot.activeBundles.sort()).toEqual(["background", "characters"]);
expect(loadCalls).toEqual(["characters", "background"]);
expect(unloadCalls).toEqual([]);
});
it("clears active bundles after releaseAll", async () => {
const unloadCalls: string[] = [];
const manager = new AssetManager({
init: async () => undefined,
loadBundle: async (name: string) => ({ name }) as FakeBundle,
unloadBundle: async (name: string) => {
unloadCalls.push(name);
},
});
const session = manager.createSession("battle");
await session.loadBundle("fx");
await session.loadBundle("music");
expect(manager.getInspectorSnapshot().activeBundles.sort()).toEqual([
"fx",
"music",
]);
await session.releaseAll();
const snapshot = manager.getInspectorSnapshot();
expect(snapshot.activeBundles).toEqual([]);
expect(snapshot.bundles).toEqual({});
expect(unloadCalls.sort()).toEqual(["fx", "music"]);
});
});
Loading…
Cancel
Save