Budget GLTF external resources before parse

This commit is contained in:
flash555588 2026-06-27 10:00:40 +08:00
parent 5cfb554e19
commit b87d5c96bb
4 changed files with 841 additions and 583 deletions

View file

@ -2,6 +2,7 @@
## Unreleased
- Performance: include external `.gltf` buffers and textures in pre-parse render budgeting so large resource-backed scenes start with safer quality settings.
- Performance: defer reading-mode `3d` and `3dgrid` code block runtime imports until the rendered block approaches the viewport, reducing workspace restore work for long notes with offscreen model blocks.
- Performance: deduplicate and limit concurrent Three.js `.gltf` external buffer/texture reads, reducing I/O and memory spikes for large resource-heavy models.
- Performance: reuse cached Three.js renderable indexes while disposing switched or closed models, reducing repeated scene-tree walks for large assemblies.

1164
main.js

File diff suppressed because one or more lines are too long

View file

@ -40,9 +40,26 @@ function summary(tier: ModelPreviewSummary["performanceTier"]): ModelPreviewSumm
}
function createAppWithVaultFileSize(path: string, size: number): App {
return createAppWithVaultFiles({ [path]: { size } });
}
function createAppWithVaultFiles(files: Record<string, { size: number; text?: string }>): App {
const fileMap = new Map(
Object.entries(files).map(([path, file]) => [path, { path, stat: { size: file.size }, text: file.text }]),
);
return {
vault: {
getAbstractFileByPath: (candidate: string) => candidate === path ? { stat: { size } } : null,
getAbstractFileByPath: (candidate: string) => fileMap.get(candidate) ?? null,
read: async (file: { path?: string; text?: string }) => {
if (typeof file.text === "string") {
return file.text;
}
const matched = file.path ? fileMap.get(file.path) : null;
if (typeof matched?.text === "string") {
return matched.text;
}
throw new Error("missing vault text fixture");
},
},
} as unknown as App;
}
@ -97,6 +114,82 @@ describe("model render budget", () => {
expect(nodeShimMocks.stat).not.toHaveBeenCalled();
});
it("adds relative glTF external resource sizes without loading Node shims", async () => {
nodeShimMocks.moduleLoadCount.value = 0;
nodeShimMocks.stat.mockReset();
const gltfText = JSON.stringify({
buffers: [{ uri: "assembly.bin", byteLength: 128 }],
images: [{ uri: "textures/diffuse%20map.png" }],
});
const app = createAppWithVaultFiles({
"models/assembly.gltf": { size: 1024, text: gltfText },
"models/assembly.bin": { size: 70 * 1024 * 1024 },
"models/textures/diffuse map.png": { size: 2 * 1024 * 1024 },
});
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
.resolves.toBe(1024 + 72 * 1024 * 1024);
expect(nodeShimMocks.moduleLoadCount.value).toBe(0);
expect(nodeShimMocks.stat).not.toHaveBeenCalled();
});
it("deduplicates glTF resources after URI suffix normalization", async () => {
const gltfText = JSON.stringify({
buffers: [{ uri: "shared.bin" }],
images: [{ uri: "shared.bin?cache=1" }],
});
const app = createAppWithVaultFiles({
"models/assembly.gltf": { size: 1024, text: gltfText },
"models/shared.bin": { size: 10 * 1024 * 1024 },
});
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
.resolves.toBe(1024 + 10 * 1024 * 1024);
});
it("resolves parent-directory glTF external resource paths from the model folder", async () => {
const gltfText = JSON.stringify({
images: [{ uri: "../textures/panel.png" }],
});
const app = createAppWithVaultFiles({
"models/nested/assembly.gltf": { size: 1024, text: gltfText },
"models/textures/panel.png": { size: 4 * 1024 * 1024 },
});
await expect(getModelPathByteSize(app, "models/nested/assembly.gltf"))
.resolves.toBe(1024 + 4 * 1024 * 1024);
});
it("falls back to declared glTF buffer byteLength when external stat is missing", async () => {
const gltfText = JSON.stringify({
buffers: [{ uri: "missing.bin", byteLength: 68 * 1024 * 1024 }],
});
const app = createAppWithVaultFiles({
"models/assembly.gltf": { size: 1024, text: gltfText },
});
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
.resolves.toBe(1024 + 68 * 1024 * 1024);
});
it("skips remote and data glTF resource URIs when estimating local byte size", async () => {
const gltfText = JSON.stringify({
buffers: [
{ uri: "https://cdn.example.com/huge.bin", byteLength: 512 * 1024 * 1024 },
{ uri: "data:application/octet-stream;base64,AA==", byteLength: 512 * 1024 * 1024 },
],
images: [{ uri: "data:image/png;base64,AA==" }],
});
const app = createAppWithVaultFiles({
"models/assembly.gltf": { size: 2048, text: gltfText },
});
await expect(getModelPathByteSize(app, "models/assembly.gltf"))
.resolves.toBe(2048);
});
it("uses Node stat only for absolute filesystem paths", async () => {
nodeShimMocks.moduleLoadCount.value = 0;
nodeShimMocks.stat.mockResolvedValueOnce({ size: 654321 });

View file

@ -5,6 +5,17 @@ export type RenderQualityBudget = Pick<PluginSettings, "renderQuality" | "render
const MEDIUM_FILE_SIZE_BYTES = 64 * 1024 * 1024;
const HEAVY_FILE_SIZE_BYTES = 192 * 1024 * 1024;
const REMOTE_URI_RE = /^[a-z][a-z0-9+.-]*:/i;
interface GltfExternalResource {
uri?: string;
byteLength?: number;
}
interface GltfSizeManifest {
buffers?: GltfExternalResource[];
images?: GltfExternalResource[];
}
function settingsBudget(settings: RenderQualityBudget): RenderQualityBudget {
return {
@ -67,7 +78,70 @@ export function looksLikeAbsoluteFilesystemPath(path: string): boolean {
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
}
function stripUriSuffix(uri: string): string {
return uri.split(/[?#]/, 1)[0] ?? uri;
}
function decodePortableUri(uri: string): string {
try {
return decodeURIComponent(uri);
} catch {
return uri;
}
}
function normalizePortableRelativePath(path: string): string {
const decoded = decodePortableUri(path).replace(/\\/g, "/");
const parts: string[] = [];
for (const part of decoded.split("/")) {
if (!part || part === ".") {
continue;
}
if (part === "..") {
parts.pop();
continue;
}
parts.push(part);
}
return parts.join("/");
}
function joinPortablePath(basePath: string, relativePath: string): string {
if (!basePath) {
return normalizePortableRelativePath(stripUriSuffix(relativePath));
}
return normalizePortableRelativePath(`${basePath}/${stripUriSuffix(relativePath)}`);
}
function getPortableDirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const sepIdx = normalized.lastIndexOf("/");
return sepIdx > 0 ? normalized.slice(0, sepIdx) : "";
}
function shouldSkipExternalResourceUri(uri: string | undefined): boolean {
if (!uri || uri.startsWith("data:")) {
return true;
}
return REMOTE_URI_RE.test(uri) && !looksLikeAbsoluteFilesystemPath(uri);
}
function normalizedResourceKey(uri: string): string {
return normalizePortableRelativePath(stripUriSuffix(uri));
}
export async function getModelPathByteSize(app: App, path: string): Promise<number | null> {
const baseSize = await getSinglePathByteSize(app, path);
if (baseSize === null) {
return null;
}
if (!path.toLowerCase().split(/[?#]/, 1)[0].endsWith(".gltf")) {
return baseSize;
}
return estimateGltfAggregateByteSize(app, path, baseSize);
}
async function getSinglePathByteSize(app: App, path: string): Promise<number | null> {
if (looksLikeAbsoluteFilesystemPath(path)) {
try {
const { stat } = await import("../utils/node-shim");
@ -82,6 +156,96 @@ export async function getModelPathByteSize(app: App, path: string): Promise<numb
return isFileWithSize(file) ? file.stat.size : null;
}
async function readGltfText(app: App, path: string): Promise<string | null> {
if (looksLikeAbsoluteFilesystemPath(path)) {
try {
const { readFile } = await import("../utils/node-shim");
return new TextDecoder().decode(await readFile(path));
} catch {
return null;
}
}
const file = app.vault.getAbstractFileByPath(path);
const vault = app.vault as typeof app.vault & { read?: (file: TFile) => Promise<string> };
if (!file || typeof vault.read !== "function") {
return null;
}
try {
return await vault.read(file as TFile);
} catch {
return null;
}
}
async function getExternalResourceByteSize(app: App, modelPath: string, uri: string): Promise<number | null> {
if (looksLikeAbsoluteFilesystemPath(modelPath)) {
try {
const { pathJoin, pathNormalize, stat } = await import("../utils/node-shim");
const cleanUri = stripUriSuffix(decodePortableUri(uri));
const modelDir = pathNormalize(modelPath.replace(/[\\/][^\\/]*$/, ""));
const resourcePath = looksLikeAbsoluteFilesystemPath(cleanUri)
? pathNormalize(cleanUri)
: pathNormalize(pathJoin(modelDir, cleanUri));
const stats = await stat(resourcePath);
return stats.size;
} catch {
return null;
}
}
const modelDir = getPortableDirname(modelPath);
const resourcePath = joinPortablePath(modelDir, uri);
const file = app.vault.getAbstractFileByPath(resourcePath);
return isFileWithSize(file) ? file.stat.size : null;
}
async function estimateGltfAggregateByteSize(app: App, path: string, baseSize: number): Promise<number> {
const text = await readGltfText(app, path);
if (!text) {
return baseSize;
}
let manifest: GltfSizeManifest;
try {
manifest = JSON.parse(text) as GltfSizeManifest;
} catch {
return baseSize;
}
let total = baseSize;
const seen = new Set<string>();
const addResource = async (resource: GltfExternalResource | undefined, fallbackByteLength?: number): Promise<void> => {
const uri = resource?.uri;
if (!uri || shouldSkipExternalResourceUri(uri)) {
return;
}
const key = normalizedResourceKey(uri);
if (!key || seen.has(key)) {
return;
}
seen.add(key);
const size = await getExternalResourceByteSize(app, path, uri);
if (size !== null && Number.isFinite(size) && size > 0) {
total += size;
return;
}
const declaredSize = resource?.byteLength ?? fallbackByteLength;
if (Number.isFinite(declaredSize) && (declaredSize ?? 0) > 0) {
total += declaredSize ?? 0;
}
};
for (const buffer of manifest.buffers ?? []) {
await addResource(buffer, buffer.byteLength);
}
for (const image of manifest.images ?? []) {
await addResource(image);
}
return total;
}
function isFileWithSize(value: unknown): value is TFile {
const file = value as Partial<TFile> | null | undefined;
return !!file &&