Improve GLTF/GLB loading to support KTX2 textures
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
||||
type ModelAssetMetadata,
|
||||
type ModelAssetRecord
|
||||
} from "./project-assets";
|
||||
import {
|
||||
ensureSharedKtx2LoaderInitialized,
|
||||
getSharedKtx2LoaderIfInitialized
|
||||
} from "./ktx2-texture-support";
|
||||
import { createOpaqueId } from "../core/ids";
|
||||
import type {
|
||||
ProjectAssetStorage,
|
||||
@@ -30,9 +34,14 @@ interface ImportedModelFileSet {
|
||||
}
|
||||
|
||||
const DRACO_DECODER_PATH = "/draco/gltf/";
|
||||
const GLTF_KTX2_TEXTURE_EXTENSION = "KHR_texture_basisu";
|
||||
const GLB_MAGIC = 0x46546c67;
|
||||
const GLB_JSON_CHUNK_TYPE = 0x4e4f534a;
|
||||
|
||||
let sharedDracoLoader: DRACOLoader | null = null;
|
||||
|
||||
type GltfKtx2Mode = "if-initialized" | "required";
|
||||
|
||||
export interface LoadedModelAsset {
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
@@ -175,6 +184,89 @@ function getImportedFilePath(file: File): string {
|
||||
return normalizeRelativePath(relativePath.length > 0 ? relativePath : file.name.trim());
|
||||
}
|
||||
|
||||
function stringArrayIncludes(value: unknown, item: string): boolean {
|
||||
return Array.isArray(value) && value.some((entry) => entry === item);
|
||||
}
|
||||
|
||||
function recordHasKtx2TextureExtension(value: unknown): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
GLTF_KTX2_TEXTURE_EXTENSION in value
|
||||
);
|
||||
}
|
||||
|
||||
function gltfJsonUsesKtx2Textures(gltfJson: Record<string, unknown>): boolean {
|
||||
if (
|
||||
stringArrayIncludes(gltfJson.extensionsUsed, GLTF_KTX2_TEXTURE_EXTENSION) ||
|
||||
stringArrayIncludes(gltfJson.extensionsRequired, GLTF_KTX2_TEXTURE_EXTENSION)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textures = Array.isArray(gltfJson.textures) ? gltfJson.textures : [];
|
||||
|
||||
return textures.some((texture) => {
|
||||
if (texture === null || typeof texture !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return recordHasKtx2TextureExtension((texture as Record<string, unknown>).extensions);
|
||||
});
|
||||
}
|
||||
|
||||
function parseGlbJsonChunk(arrayBuffer: ArrayBuffer): Record<string, unknown> | null {
|
||||
if (arrayBuffer.byteLength < 20) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
if (dataView.getUint32(0, true) !== GLB_MAGIC) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = dataView.getUint32(4, true);
|
||||
|
||||
if (version !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const declaredLength = dataView.getUint32(8, true);
|
||||
const byteLength = Math.min(declaredLength, arrayBuffer.byteLength);
|
||||
let offset = 12;
|
||||
|
||||
while (offset + 8 <= byteLength) {
|
||||
const chunkLength = dataView.getUint32(offset, true);
|
||||
const chunkType = dataView.getUint32(offset + 4, true);
|
||||
const chunkStart = offset + 8;
|
||||
const chunkEnd = chunkStart + chunkLength;
|
||||
|
||||
if (chunkEnd > byteLength) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chunkType === GLB_JSON_CHUNK_TYPE) {
|
||||
const jsonText = new TextDecoder().decode(arrayBuffer.slice(chunkStart, chunkEnd)).trim();
|
||||
const parsedJson = JSON.parse(jsonText) as unknown;
|
||||
return parsedJson !== null && typeof parsedJson === "object" ? (parsedJson as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
offset = chunkEnd + ((4 - (chunkLength % 4)) % 4);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function glbUsesKtx2Textures(arrayBuffer: ArrayBuffer): boolean {
|
||||
try {
|
||||
const gltfJson = parseGlbJsonChunk(arrayBuffer);
|
||||
return gltfJson === null ? false : gltfJsonUsesKtx2Textures(gltfJson);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createBoundingBoxFromObject(object: Object3D): ModelAssetMetadata["boundingBox"] {
|
||||
const box = new Box3().setFromObject(object);
|
||||
|
||||
@@ -333,13 +425,26 @@ function createModelAssetRecord(
|
||||
}
|
||||
|
||||
async function loadGltfFromArrayBuffer(arrayBuffer: ArrayBuffer): Promise<GLTF> {
|
||||
const loader = createConfiguredGltfLoader();
|
||||
const loader = createConfiguredGltfLoader({
|
||||
ktx2: glbUsesKtx2Textures(arrayBuffer) ? "required" : "if-initialized"
|
||||
});
|
||||
return loader.parseAsync(arrayBuffer, "");
|
||||
}
|
||||
|
||||
export function createConfiguredGltfLoader(): GLTFLoader {
|
||||
export function createConfiguredGltfLoader(options: { ktx2?: GltfKtx2Mode } = {}): GLTFLoader {
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(getSharedDracoLoader());
|
||||
|
||||
const ktx2Mode = options.ktx2 ?? "if-initialized";
|
||||
const ktx2Loader =
|
||||
ktx2Mode === "required"
|
||||
? ensureSharedKtx2LoaderInitialized()
|
||||
: getSharedKtx2LoaderIfInitialized();
|
||||
|
||||
if (ktx2Loader !== null) {
|
||||
loader.setKTX2Loader(ktx2Loader);
|
||||
}
|
||||
|
||||
return loader;
|
||||
}
|
||||
|
||||
@@ -655,7 +760,9 @@ async function loadGltfFromImportedModelFileSet(fileSet: ImportedModelFileSet):
|
||||
throw new Error(`Missing external model resource(s): ${missingUris.join(", ")}.`);
|
||||
}
|
||||
|
||||
const loader = createConfiguredGltfLoader();
|
||||
const loader = createConfiguredGltfLoader({
|
||||
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
|
||||
});
|
||||
|
||||
try {
|
||||
return await loader.parseAsync(JSON.stringify(gltfJson), "");
|
||||
@@ -790,7 +897,9 @@ export async function loadModelAssetFromStorage(
|
||||
throw new Error(`Missing stored external model resource(s): ${missingUris.join(", ")}.`);
|
||||
}
|
||||
|
||||
const loader = createConfiguredGltfLoader();
|
||||
const loader = createConfiguredGltfLoader({
|
||||
ktx2: gltfJsonUsesKtx2Textures(gltfJson) ? "required" : "if-initialized"
|
||||
});
|
||||
|
||||
try {
|
||||
const gltf = await loader.parseAsync(JSON.stringify(gltfJson), "");
|
||||
|
||||
Reference in New Issue
Block a user