Use Blob URLs for external glTF resources

This commit is contained in:
flash555588 2026-06-27 07:23:27 +08:00
parent 763273a90f
commit 0d9a38d244
4 changed files with 764 additions and 627 deletions

View file

@ -14,6 +14,7 @@
- Performance: serialize Live Preview and reading-mode model loads through an inline preview queue and defer `3dgrid` model preparation until the grid enters the viewport, reducing startup and multi-model I/O spikes.
- Performance: defer Live Preview runtime imports for preview backends, annotation managers, conversion preparation, and load feedback until a visible embed actually starts loading.
- Performance: defer reading-mode annotation runtime setup until after inline model canvases are visible, reducing first-frame delay for annotated previews.
- Performance: load external `.gltf` buffers and textures through temporary Blob URLs instead of base64-rewriting the JSON, reducing large-model memory spikes and parse overhead.
- Performance: register Live Preview embeds through a lightweight lazy widget so workspace startup no longer imports the full embed runtime before a model embed approaches the viewport.
- Performance: defer Live Preview editor-extension registration until the workspace layout is ready, keeping CodeMirror model-embed setup off the plugin startup critical path.
- Performance: schedule Live Preview extension setup after the initial layout settles and lazy-load heading-pin observer runtime only when heading-linked annotations exist.

1196
main.js

File diff suppressed because one or more lines are too long

View file

@ -8,10 +8,24 @@ vi.mock("obsidian", () => ({
let loadThreePLY: typeof import("./loaders").loadThreePLY;
let loadThreeSTL: typeof import("./loaders").loadThreeSTL;
let loadThreeGLTF: typeof import("./loaders").loadThreeGLTF;
beforeAll(async () => {
vi.stubGlobal("activeWindow", {});
vi.stubGlobal("ProgressEvent", class ProgressEvent extends Event {
readonly lengthComputable: boolean;
readonly loaded: number;
readonly total: number;
constructor(type: string, eventInitDict: ProgressEventInit = {}) {
super(type, eventInitDict);
this.lengthComputable = eventInitDict.lengthComputable ?? false;
this.loaded = eventInitDict.loaded ?? 0;
this.total = eventInitDict.total ?? 0;
}
});
const loaders = await import("./loaders");
loadThreeGLTF = loaders.loadThreeGLTF;
loadThreePLY = loaders.loadThreePLY;
loadThreeSTL = loaders.loadThreeSTL;
});
@ -45,7 +59,67 @@ function createColoredBinaryStl(): ArrayBuffer {
return buffer;
}
function createExternalBufferGltf(): { gltf: ArrayBuffer; bin: ArrayBuffer } {
const positions = new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
]);
const gltf = {
asset: { version: "2.0" },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0, name: "external-buffer-triangle" }],
meshes: [{
primitives: [{
attributes: { POSITION: 0 },
mode: 4,
}],
}],
buffers: [{ uri: "Geometry%20Data.BIN", byteLength: positions.byteLength }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: positions.byteLength, target: 34962 }],
accessors: [{
bufferView: 0,
componentType: 5126,
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [1, 1, 0],
}],
};
return {
gltf: encodeAscii(JSON.stringify(gltf)),
bin: positions.buffer.slice(positions.byteOffset, positions.byteOffset + positions.byteLength) as ArrayBuffer,
};
}
describe("Three loaders", () => {
it("loads GLTF external buffers through Blob URLs without rewriting the JSON", async () => {
const fixture = createExternalBufferGltf();
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
const createSpy = vi.spyOn(URL, "createObjectURL").mockImplementation((blob) => createObjectURL(blob));
const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation((url) => revokeObjectURL(url));
const readFile = vi.fn(async (path: string) => {
if (path === "fixtures/Geometry Data.BIN") {
return fixture.bin;
}
throw new Error(`Unexpected path: ${path}`);
});
try {
const result = await loadThreeGLTF(fixture.gltf, "gltf", readFile, "fixtures/model.gltf");
expect(readFile).toHaveBeenCalledWith("fixtures/Geometry Data.BIN");
expect(createSpy).toHaveBeenCalledTimes(1);
expect(revokeSpy).toHaveBeenCalledTimes(1);
expect(result.scene.getObjectByName("external-buffer-triangle")).toBeTruthy();
} finally {
createSpy.mockRestore();
revokeSpy.mockRestore();
}
});
it("enables vertex colors for colored STL", async () => {
const object = await loadThreeSTL(createColoredBinaryStl());

View file

@ -4,7 +4,7 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { BufferGeometry, Material, Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
import { BufferGeometry, LoadingManager, Material, Mesh, MeshStandardMaterial, PointsMaterial, Points } from "three";
import { getPortableBasename, getPortableDirname, getPortableStem, joinPortablePath } from "../../utils/resolve-path";
import { arrayBufferToBase64 } from "../../utils/base64";
import {
@ -30,11 +30,88 @@ interface GltfJson {
images?: GltfExternalResource[];
}
interface GltfBlobResourceResolver {
manager: LoadingManager;
dispose: () => void;
}
function guessMime(path: string): string {
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
return IMAGE_MIME[ext] ?? `image/${ext}`;
}
function stripUriSuffix(uri: string): string {
return uri.split(/[?#]/, 1)[0] ?? uri;
}
function normalizeResourceLookupKey(uri: string): string {
return stripUriSuffix(uri).replace(/\\/g, "/").replace(/^\.\//, "");
}
function addResourceLookupKey(
lookup: Map<string, string>,
key: string | undefined,
objectUrl: string,
): void {
if (!key) return;
lookup.set(normalizeResourceLookupKey(key), objectUrl);
}
function addGltfResourceLookupKeys(
lookup: Map<string, string>,
modelDir: string,
uri: string,
resolvedPath: string,
objectUrl: string,
): void {
const rawUri = stripUriSuffix(uri);
addResourceLookupKey(lookup, rawUri, objectUrl);
addResourceLookupKey(lookup, joinPortablePath("", rawUri), objectUrl);
addResourceLookupKey(lookup, resolvedPath, objectUrl);
if (modelDir) {
addResourceLookupKey(lookup, `${modelDir}/${rawUri}`, objectUrl);
}
}
async function createGltfBlobResourceResolver(
readFile: (path: string) => Promise<ArrayBuffer>,
modelDir: string,
gltfJson: GltfJson,
): Promise<GltfBlobResourceResolver> {
const lookup = new Map<string, string>();
const objectUrls: string[] = [];
const manager = new LoadingManager();
const register = async (uri: string | undefined, mimeType?: string): Promise<void> => {
if (!uri || uri.startsWith("data:")) {
return;
}
const resource = await readRelativeResource(readFile, modelDir, uri);
const objectUrl = URL.createObjectURL(new Blob([resource.data], { type: mimeType ?? guessMime(resource.path) }));
objectUrls.push(objectUrl);
addGltfResourceLookupKeys(lookup, modelDir, uri, resource.path, objectUrl);
};
await Promise.all([
...(gltfJson.buffers ?? []).map((buffer) => register(buffer.uri, "application/octet-stream")),
...(gltfJson.images ?? []).map((image) => register(image.uri)),
]);
manager.setURLModifier((url) => {
const key = normalizeResourceLookupKey(url);
return lookup.get(key) ?? lookup.get(joinPortablePath("", key)) ?? url;
});
return {
manager,
dispose: () => {
for (const objectUrl of objectUrls) {
URL.revokeObjectURL(objectUrl);
}
},
};
}
function firstMtlPath(value: string): string {
const trimmed = value.replace(/\s+#.*$/, "").trim();
if (trimmed.startsWith("\"")) {
@ -95,44 +172,29 @@ export async function loadThreeGLTF(
readFile?: (path: string) => Promise<ArrayBuffer>,
modelPath?: string,
): Promise<{ scene: Object3D; animations: AnimationClip[]; warnings: string[] }> {
const loader = new GLTFLoader();
const warnings: string[] = [];
if (ext === "gltf" && readFile && modelPath) {
// GLTF JSON may reference external .bin and textures.
// Use a custom loader manager that resolves from the vault.
// GLTF JSON may reference external .bin and textures. Keep the original JSON
// intact and resolve vault resources through temporary Blob URLs to avoid
// base64-expanding large buffers/textures during model load.
const gltfText = new TextDecoder().decode(new Uint8Array(data));
const gltfJson = JSON.parse(gltfText) as GltfJson;
const modelDir = getPortableDirname(modelPath);
// Pre-load all external buffers and images as data URLs
if (gltfJson.buffers) {
for (const buf of gltfJson.buffers) {
if (buf.uri && !buf.uri.startsWith("data:")) {
const resource = await readRelativeResource(readFile, modelDir, buf.uri);
buf.uri = `data:application/octet-stream;base64,${arrayBufferToBase64(resource.data)}`;
}
}
const resolver = await createGltfBlobResourceResolver(readFile, modelDir, gltfJson);
const loader = new GLTFLoader(resolver.manager);
try {
const gltf = await loader.parseAsync(data, modelDir ? `${modelDir}/` : "");
const root = gltf.scene || gltf.scenes?.[0];
if (!root) throw new Error("GLTF did not contain a scene");
return { scene: root, animations: gltf.animations, warnings };
} finally {
resolver.dispose();
}
if (gltfJson.images) {
for (const img of gltfJson.images) {
if (img.uri && !img.uri.startsWith("data:")) {
const resource = await readRelativeResource(readFile, modelDir, img.uri);
img.uri = `data:${guessMime(resource.path)};base64,${arrayBufferToBase64(resource.data)}`;
}
}
}
const resolvedText = JSON.stringify(gltfJson);
const resolvedBuffer = new TextEncoder().encode(resolvedText);
const gltf = await loader.parseAsync(resolvedBuffer.buffer, modelDir ? `${modelDir}/` : "");
const root = gltf.scene || gltf.scenes?.[0];
if (!root) throw new Error("GLTF did not contain a scene");
return { scene: root, animations: gltf.animations, warnings };
}
// .glb binary path
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(data, "");
const root = gltf.scene || gltf.scenes?.[0];
if (!root) throw new Error("GLB did not contain a scene");