pex-renderer
Version:
Physically Based Renderer (PBR) and scene graph designed as ECS for PEX: define entities to be rendered as collections of components with their update orchestrated by systems.
1,543 lines (1,372 loc) • 52.8 kB
JavaScript
import { loadJson, loadImage, loadArrayBuffer, loadBlob } from "pex-io";
import { quat, mat4, utils } from "pex-math";
import { loadDraco, loadKtx2 } from "pex-loaders";
import typedArrayInterleave from "typed-array-interleave";
import { getDirname, getFileExtension } from "../utils.js";
import { components, entity, systems } from "../index.js";
const isSafari =
/^((?!chrome|android).)*safari/i.test(globalThis.navigator?.userAgent) ===
true;
// Constants
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#specifying-extensions
const SUPPORTED_EXTENSIONS = [
// 1.0
"KHR_materials_pbrSpecularGlossiness",
// 2.0
"EXT_mesh_gpu_instancing",
// "KHR_animation_pointer",
"KHR_draco_mesh_compression",
"KHR_lights_punctual",
// "KHR_materials_anisotropy",
"KHR_materials_clearcoat",
"KHR_materials_dispersion",
"KHR_materials_emissive_strength",
"KHR_materials_ior",
// "KHR_materials_iridescence",
"KHR_materials_sheen",
"KHR_materials_specular",
"KHR_materials_transmission",
"KHR_materials_diffuse_transmission",
"KHR_materials_unlit",
// "KHR_materials_variants",
"KHR_materials_volume",
"KHR_mesh_quantization",
"KHR_texture_basisu",
"KHR_texture_transform",
// "EXT_texture_webp",
// WIP:
// "KHR_materials_volume_scatter"
// "EXT_lights_image_based"
// "KHR_animation_pointer"
// "KHR_audio"
];
const WEBGL_CONSTANTS = {
// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants#Buffers
ELEMENT_ARRAY_BUFFER: 34963, // 0x8893
ARRAY_BUFFER: 34962, // 0x8892
// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants#Data_types
BYTE: 5120, // 0x1400
UNSIGNED_BYTE: 5121, // 0x1401
SHORT: 5122, // 0x1402
UNSIGNED_SHORT: 5123, // 0x1403
UNSIGNED_INT: 5125, // 0x1405
FLOAT: 5126, // 0x1406
};
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays#Typed_array_views
const WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES = {
[WEBGL_CONSTANTS.BYTE]: Int8Array,
[WEBGL_CONSTANTS.UNSIGNED_BYTE]: Uint8Array,
[WEBGL_CONSTANTS.SHORT]: Int16Array,
[WEBGL_CONSTANTS.UNSIGNED_SHORT]: Uint16Array,
[WEBGL_CONSTANTS.UNSIGNED_INT]: Uint32Array,
[WEBGL_CONSTANTS.FLOAT]: Float32Array,
};
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#accessor-element-size
const GLTF_ACCESSOR_COMPONENT_TYPE_SIZE = {
[WEBGL_CONSTANTS.BYTE]: 1,
[WEBGL_CONSTANTS.UNSIGNED_BYTE]: 1,
[WEBGL_CONSTANTS.SHORT]: 2,
[WEBGL_CONSTANTS.UNSIGNED_SHORT]: 2,
[WEBGL_CONSTANTS.UNSIGNED_INT]: 4,
[WEBGL_CONSTANTS.FLOAT]: 4,
};
const GLTF_ACCESSOR_TYPE_COMPONENTS_NUMBER = {
SCALAR: 1,
VEC2: 2,
VEC3: 3,
VEC4: 4,
MAT2: 4,
MAT3: 9,
MAT4: 16,
};
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#header
const MAGIC = 0x46546c67; // glTF
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#chunks
const CHUNK_TYPE = {
JSON: 0x4e4f534a,
BIN: 0x004e4942,
};
const PEX_ATTRIBUTE_NAME_MAP = {
POSITION: "positions",
NORMAL: "normals",
TANGENT: "tangents",
TEXCOORD_0: "texCoords",
TEXCOORD_1: "texCoords1",
JOINTS_0: "joints",
WEIGHTS_0: "weights",
COLOR_0: "vertexColors",
// instanced
TRANSLATION: "offsets",
ROTATION: "rotations",
SCALE: "scales",
};
function linearToSrgb(color) {
return [
color[0] ** (1.0 / 2.2),
color[1] ** (1.0 / 2.2),
color[2] ** (1.0 / 2.2),
color.length == 4 ? color[3] : 1,
];
}
// https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_mesh_quantization#encoding-quantized-data
const MESH_QUANTIZATION_SCALE = {
[Int8Array]: 1 / 127,
[Uint8Array]: 1 / 255,
[Int16Array]: 1 / 32767,
[Uint16Array]: 1 / 65535,
};
const normalizeData = (data) =>
new Float32Array(data).map(
(v) => v * MESH_QUANTIZATION_SCALE[data.constructor],
);
const loadImageBitmap = async (blob) =>
await createImageBitmap(blob, {
premultiplyAlpha: "none",
colorSpaceConversion: "none",
});
// Build
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/accessor.schema.json
function getAccessor(accessor, bufferViews) {
if (accessor._data) return accessor;
const numberOfComponents =
GLTF_ACCESSOR_TYPE_COMPONENTS_NUMBER[accessor.type];
if (accessor.byteOffset === undefined) accessor.byteOffset = 0;
accessor._bufferView = bufferViews[accessor.bufferView];
const TypedArrayConstructor =
WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES[accessor.componentType];
const byteSize = GLTF_ACCESSOR_COMPONENT_TYPE_SIZE[accessor.componentType];
// Handle bufferView byteStride different from accessor.componentType defined byte size
const itemBytes = byteSize * numberOfComponents;
const byteStride = accessor._bufferView.byteStride;
if (byteStride && byteStride !== itemBytes) {
const ibSlice = Math.floor(accessor.byteOffset / byteStride);
accessor._data = new TypedArrayConstructor(
accessor._bufferView._data,
ibSlice * byteStride,
(accessor.count * byteStride) / byteSize,
);
// TODO: AnimatedMorphCube normals needs byteStride * 4
accessor._byteStride = byteStride;
} else {
// Assign buffer view
accessor._data = new TypedArrayConstructor(
accessor._bufferView._data,
accessor.byteOffset,
accessor.count * numberOfComponents,
);
}
// Sparse accessors
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/accessor.sparse.schema.json
if (accessor.sparse !== undefined) {
const TypedArrayIndicesConstructor =
WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES[
accessor.sparse.indices.componentType
];
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/accessor.sparse.indices.schema.json
const sparseIndices = new TypedArrayIndicesConstructor(
bufferViews[accessor.sparse.indices.bufferView]._data,
accessor.sparse.indices.byteOffset || 0,
accessor.sparse.count,
);
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/accessor.sparse.values.schema.json
const sparseValues = new TypedArrayConstructor(
bufferViews[accessor.sparse.values.bufferView]._data,
accessor.sparse.values.byteOffset || 0,
accessor.sparse.count * numberOfComponents,
);
if (accessor._data !== null) {
accessor._data = accessor._data.slice();
}
let valuesIndex = 0;
for (
let indicesIndex = 0;
indicesIndex < sparseIndices.length;
indicesIndex++
) {
let dataIndex = sparseIndices[indicesIndex] * numberOfComponents;
for (
let componentIndex = 0;
componentIndex < numberOfComponents;
componentIndex++
) {
accessor._data[dataIndex++] = sparseValues[valuesIndex++];
}
}
}
return accessor;
}
function getPexMaterialTexture(
materialTexture,
{ textures, images, samplers },
ctx,
encoding,
) {
// Retrieve glTF root object properties
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/texture.schema.json
const texture = textures[materialTexture.index];
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/image.schema.json
const image =
texture.extensions &&
texture.extensions.KHR_texture_basisu &&
Number.isInteger(texture.extensions.KHR_texture_basisu.source)
? images[texture.extensions.KHR_texture_basisu.source]
: images[texture.source];
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/sampler.schema.json
const sampler =
samplers && samplers[texture.sampler] ? samplers[texture.sampler] : {};
sampler.minFilter = sampler.minFilter || ctx.Filter.LinearMipmapLinear;
sampler.magFilter = sampler.magFilter || ctx.Filter.Linear;
sampler.wrapS = sampler.wrapS || ctx.Wrap.Repeat;
sampler.wrapT = sampler.wrapT || ctx.Wrap.Repeat;
const hasMipMap =
sampler.minFilter !== ctx.Filter.Nearest &&
sampler.minFilter !== ctx.Filter.Linear;
if (!texture._tex) {
let img = image._img;
if (!utils.isPowerOfTwo(img.width) || !utils.isPowerOfTwo(img.height)) {
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#samplers
if (
sampler.wrapS !== ctx.Wrap.ClampToEdge ||
sampler.wrapT !== ctx.Wrap.ClampToEdge ||
hasMipMap
) {
const canvas2d = document.createElement("canvas");
canvas2d.width = utils.nextPowerOfTwo(img.width);
canvas2d.height = utils.nextPowerOfTwo(img.height);
console.warn(
`Resizing NPOT texture ${img.width}x${img.height} to ${canvas2d.width}x${canvas2d.height}. Src: ${img.src}`,
);
const ctx2d = canvas2d.getContext("2d");
ctx2d.drawImage(img, 0, 0, canvas2d.width, canvas2d.height);
img = canvas2d;
}
}
const pexTextureOptions = img.compressed
? img
: { data: img, width: img.width, height: img.height };
if (!img.compressed && hasMipMap) {
pexTextureOptions.mipmap = true;
pexTextureOptions.aniso = 16;
}
texture._tex = ctx.texture2D({
encoding: encoding || ctx.Encoding.Linear,
pixelFormat: ctx.PixelFormat.RGBA8,
wrapS: sampler.wrapS,
wrapT: sampler.wrapT,
min: sampler.minFilter,
mag: sampler.magFilter,
...pexTextureOptions,
});
}
// https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_texture_transform/schema/KHR_texture_transform.textureInfo.schema.json
const textureTransform =
materialTexture.extensions &&
materialTexture.extensions.KHR_texture_transform;
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/textureInfo.schema.json
const texCoord = materialTexture.texCoord;
return !texCoord && !textureTransform
? texture._tex
: {
texture: texture._tex,
// textureInfo
texCoord: texCoord || 0,
// textureTransform.texCoord: Overrides the textureInfo texCoord value if supplied.
...(textureTransform || {}),
};
}
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/material.schema.json
function handleMaterial(material, gltf, ctx) {
let materialProps = {
name: material.name,
baseColor: [1, 1, 1, 1],
roughness: 1,
metallic: 1,
castShadows: true,
receiveShadows: true,
cullFace: !(material.doubleSided || material.alphaMode),
};
// Metallic/Roughness workflow
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/material.pbrMetallicRoughness.schema.json
const pbrMetallicRoughness = material.pbrMetallicRoughness;
if (pbrMetallicRoughness) {
materialProps = {
...materialProps,
baseColor: [1, 1, 1, 1],
roughness: 1,
metallic: 1,
};
if (pbrMetallicRoughness.baseColorFactor) {
materialProps.baseColor = linearToSrgb(
pbrMetallicRoughness.baseColorFactor,
);
}
if (pbrMetallicRoughness.baseColorTexture) {
materialProps.baseColorTexture = getPexMaterialTexture(
pbrMetallicRoughness.baseColorTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
if (pbrMetallicRoughness.metallicFactor !== undefined) {
materialProps.metallic = pbrMetallicRoughness.metallicFactor;
}
if (pbrMetallicRoughness.roughnessFactor !== undefined) {
materialProps.roughness = pbrMetallicRoughness.roughnessFactor;
}
if (pbrMetallicRoughness.metallicRoughnessTexture) {
materialProps.metallicRoughnessTexture = getPexMaterialTexture(
pbrMetallicRoughness.metallicRoughnessTexture,
gltf,
ctx,
);
}
if (material.extensions) {
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_sheen#sheen
if (material.extensions.KHR_materials_sheen) {
const sheenExt = material.extensions.KHR_materials_sheen;
materialProps.sheenColor = [
...(sheenExt.sheenColorFactor || [0, 0, 0]),
1,
];
materialProps.sheenRoughness = sheenExt.sheenRoughnessFactor ?? 0;
materialProps.normalTextureScale = 1; // TODO: why?
if (sheenExt.sheenColorTexture) {
materialProps.sheenColorTexture = getPexMaterialTexture(
sheenExt.sheenColorTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
if (sheenExt.sheenRoughnessTexture) {
if (
sheenExt.sheenColorTexture.index !==
sheenExt.sheenRoughnessTexture.index
) {
materialProps.sheenRoughnessTexture = getPexMaterialTexture(
sheenExt.sheenRoughnessTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_clearcoat#clearcoat
if (material.extensions.KHR_materials_clearcoat) {
const clearcoatExt = material.extensions.KHR_materials_clearcoat;
materialProps.clearCoat = clearcoatExt.clearcoatFactor;
materialProps.clearCoatRoughness =
clearcoatExt.clearcoatRoughnessFactor;
// TODO: could clearcoatTexture and clearcoatRoughnessTexture be same texture as we read r and g components in shader
if (clearcoatExt.clearcoatTexture) {
materialProps.clearCoatTexture = getPexMaterialTexture(
clearcoatExt.clearcoatTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
if (clearcoatExt.clearcoatRoughnessTexture) {
materialProps.clearCoatRoughnessTexture = getPexMaterialTexture(
clearcoatExt.clearcoatRoughnessTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
if (clearcoatExt.clearcoatNormalTexture) {
materialProps.clearCoatNormalTexture = getPexMaterialTexture(
clearcoatExt.clearcoatNormalTexture,
gltf,
ctx,
ctx.Encoding.SRGB, // TODO: shoudln't it be linear?
);
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_transmission
if (material.extensions.KHR_materials_transmission) {
const transmissionExt = material.extensions.KHR_materials_transmission;
materialProps.transmission = transmissionExt.transmissionFactor ?? 0;
if (transmissionExt.transmissionTexture) {
materialProps.transmissionTexture = getPexMaterialTexture(
transmissionExt.transmissionTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_diffuse_transmission
if (material.extensions.KHR_materials_diffuse_transmission) {
const diffuseTransmissionExt =
material.extensions.KHR_materials_diffuse_transmission;
materialProps.diffuseTransmission =
diffuseTransmissionExt.diffuseTransmissionFactor ?? 0;
if (diffuseTransmissionExt.diffuseTransmissionTexture) {
materialProps.diffuseTransmissionTexture = getPexMaterialTexture(
diffuseTransmissionExt.diffuseTransmissionTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
materialProps.diffuseTransmissionColor =
diffuseTransmissionExt.diffuseTransmissionColorFactor || [1, 1, 1];
if (diffuseTransmissionExt.diffuseTransmissionColorTexture) {
materialProps.diffuseTransmissionColorTexture = getPexMaterialTexture(
diffuseTransmissionExt.diffuseTransmissionColorTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_volume
if (material.extensions.KHR_materials_volume) {
const volumeExt = material.extensions.KHR_materials_volume;
materialProps.thickness = volumeExt.thicknessFactor ?? 0;
materialProps.attenuationDistance =
volumeExt.attenuationDistance || Infinity;
materialProps.attenuationColor = volumeExt.attenuationColor || [
1, 1, 1,
];
if (volumeExt.thicknessTexture) {
materialProps.thicknessTexture = getPexMaterialTexture(
volumeExt.thicknessTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_dispersion
if (material.extensions.KHR_materials_dispersion) {
const dispersionExt = material.extensions.KHR_materials_dispersion;
materialProps.dispersion = dispersionExt.dispersion ?? 0;
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_ior
if (material.extensions.KHR_materials_ior) {
const iorExt = material.extensions.KHR_materials_ior;
materialProps.ior = iorExt.ior || 1.5;
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_specular
if (material.extensions.KHR_materials_specular) {
const specularExt = material.extensions.KHR_materials_specular;
materialProps.specular = specularExt.specularFactor ?? 1.0;
if (specularExt.specularTexture) {
materialProps.specularTexture = getPexMaterialTexture(
specularExt.specularTexture,
gltf,
ctx,
ctx.Encoding.Linear,
);
}
materialProps.specularColor = specularExt.specularColorFactor || [
1, 1, 1,
];
if (specularExt.specularColorTexture) {
materialProps.specularColorTexture = getPexMaterialTexture(
specularExt.specularColorTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
}
}
}
// Specular/Glossiness workflow
// https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_pbrSpecularGlossiness/schema/glTF.KHR_materials_pbrSpecularGlossiness.schema.json
const pbrSpecularGlossiness = material.extensions
? material.extensions.KHR_materials_pbrSpecularGlossiness
: null;
if (pbrSpecularGlossiness) {
materialProps = {
...materialProps,
useSpecularGlossinessWorkflow: true,
diffuse: [1, 1, 1, 1],
specular: [1, 1, 1],
glossiness: 1,
};
if (pbrSpecularGlossiness.diffuseFactor) {
materialProps.diffuse = linearToSrgb(pbrSpecularGlossiness.diffuseFactor);
}
if (pbrSpecularGlossiness.specularFactor) {
materialProps.specular = linearToSrgb(
pbrSpecularGlossiness.specularFactor,
).slice(0, 3);
}
if (pbrSpecularGlossiness.glossinessFactor !== undefined) {
materialProps.glossiness = pbrSpecularGlossiness.glossinessFactor;
}
if (pbrSpecularGlossiness.diffuseTexture) {
materialProps.diffuseTexture = getPexMaterialTexture(
pbrSpecularGlossiness.diffuseTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
if (pbrSpecularGlossiness.specularGlossinessTexture) {
materialProps.specularGlossinessTexture = getPexMaterialTexture(
pbrSpecularGlossiness.specularGlossinessTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
}
// Additional Maps
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/material.normalTextureInfo.schema.json
if (material.normalTexture) {
materialProps.normalTexture = getPexMaterialTexture(
material.normalTexture,
gltf,
ctx,
);
materialProps.normalTextureScale = material.normalTexture.scale ?? 1;
}
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/material.occlusionTextureInfo.schema.json
if (material.occlusionTexture) {
materialProps.occlusionTexture = getPexMaterialTexture(
material.occlusionTexture,
gltf,
ctx,
);
}
if (material.emissiveTexture) {
materialProps.emissiveColorTexture = getPexMaterialTexture(
material.emissiveTexture,
gltf,
ctx,
ctx.Encoding.SRGB,
);
}
if (material.emissiveFactor) {
materialProps = {
...materialProps,
emissiveColor: linearToSrgb(material.emissiveFactor),
};
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_materials_emissive_strength
if (material.extensions?.KHR_materials_emissive_strength) {
materialProps.emissiveIntensity =
material.extensions.KHR_materials_emissive_strength.emissiveStrength ?? 1;
}
// Alpha Coverage
if (material.alphaMode === "BLEND") {
materialProps = {
...materialProps,
depthWrite: false,
blend: true,
blendSrcRGBFactor: ctx.BlendFactor.SrcAlpha,
blendSrcAlphaFactor: ctx.BlendFactor.One,
blendDstRGBFactor: ctx.BlendFactor.OneMinusSrcAlpha,
blendDstAlphaFactor: ctx.BlendFactor.One,
};
} else if (material.alphaMode === "MASK") {
materialProps.alphaTest = material.alphaCutoff || 0.5;
}
// KHR_materials_unlit
if (material.extensions && material.extensions.KHR_materials_unlit) {
materialProps = {
...materialProps,
unlit: true,
};
}
return materialProps;
}
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/mesh.primitive.schema.json
async function handlePrimitive(
primitive,
{ bufferViews, accessors },
ctx,
options,
) {
let geometryProps = {};
// Load draco
// https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_draco_mesh_compression
if (primitive.extensions && primitive.extensions.KHR_draco_mesh_compression) {
// The loader must process KHR_draco_mesh_compression first. The loader must get the data from KHR_draco_mesh_compression's bufferView property and decompress the data using a Draco decoder to a Draco geometry.
const bufferView =
bufferViews[primitive.extensions.KHR_draco_mesh_compression.bufferView];
const gltfAttributeMap =
primitive.extensions.KHR_draco_mesh_compression.attributes;
const attributeIDs = {};
const attributeTypes = {};
const normalizedAttributes = [];
for (const name in gltfAttributeMap) {
attributeIDs[PEX_ATTRIBUTE_NAME_MAP[name] || name.toLowerCase()] =
gltfAttributeMap[name];
}
for (const name in primitive.attributes) {
const attributeName = PEX_ATTRIBUTE_NAME_MAP[name] || name.toLowerCase();
if (gltfAttributeMap[name] !== undefined) {
const accessor = accessors[primitive.attributes[name]];
const componentType =
WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES[accessor.componentType];
attributeTypes[attributeName] = componentType.name;
if (accessor.normalized === true) {
normalizedAttributes.push(attributeName);
}
}
}
// If the loader does support the Draco extension, but will not process KHR_draco_mesh_compression, then the loader must load the glTF asset ignoring KHR_draco_mesh_compression in primitive.
try {
geometryProps = await loadDraco(bufferView._data, {
transcodeConfig: {
attributeIDs,
attributeTypes,
useUniqueIDs: true,
},
...options.dracoOptions,
});
normalizedAttributes.forEach((attributeName) => {
if (geometryProps[attributeName]) {
geometryProps[attributeName].normalized = true;
}
});
} catch (error) {
console.warn(
`glTF Loader: Error decoding Draco geometry '${primitive.name}'. Trying to load uncompressed geometry.`,
error,
);
}
// Then the loader must process attributes and indices properties of the primitive.
// If additional attributes are defined in primitive's attributes, but not defined in KHR_draco_mesh_compression's attributes, then the loader must process the additional attributes as usual.
}
// Format attributes for pex-context
const attributes = Object.keys(primitive.attributes).reduce(
(attributes, name) => {
const attributeName = PEX_ATTRIBUTE_NAME_MAP[name];
if (!attributeName)
console.warn(`glTF Loader: Unknown attribute '${name}'`);
// https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_draco_mesh_compression/README.md#conformance
// When loading each accessor, you must ignore the bufferView and byteOffset of the accessor and go to the previously decoded Draco geometry in the primitive to get the data of indices and attributes. A loader must use the decompressed data to fill the accessors or render the decompressed Draco geometry directly (e.g. ThreeJS (non-normative)).
if (geometryProps[attributeName]) {
// TODO: does draco loaded data need to be added as _data to accessor?
return attributes;
}
const accessor = getAccessor(
accessors[primitive.attributes[name]],
bufferViews,
);
if (accessor.sparse) {
attributes[attributeName] = accessor._data;
} else {
let count = accessor.count;
let data = accessor._bufferView._data;
let offset = accessor.byteOffset;
let stride = accessor._bufferView.byteStride;
let buffer = accessor._bufferView._vertexBuffer;
if (attributeName === "vertexColors" && accessor.type === "VEC3") {
data = typedArrayInterleave(
Float32Array,
[3, 1],
new Float32Array(data, offset, count * 3),
new Float32Array(count).fill(1),
);
buffer = ctx.vertexBuffer(data);
stride += 4;
count += count / 3;
offset = 0;
} else {
if (!buffer) {
buffer = accessor._bufferView._vertexBuffer =
ctx.vertexBuffer(data);
}
}
attributes[attributeName] = {
count,
buffer,
offset,
data,
type: accessor.componentType,
stride,
normalized: accessor.normalized,
};
}
return attributes;
},
{},
);
const positionAccessor = accessors[primitive.attributes.POSITION];
const indicesAccessor =
accessors[primitive.indices] &&
!geometryProps.indices &&
getAccessor(accessors[primitive.indices], bufferViews);
// Create geometry
geometryProps = {
...geometryProps,
...attributes,
};
if (positionAccessor) {
// TODO: are bounds calculated for targets?
const scale = positionAccessor.normalized
? MESH_QUANTIZATION_SCALE[
WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES[positionAccessor.componentType]
]
: 1;
geometryProps.bounds = [
positionAccessor.min.map((v) => v * scale),
positionAccessor.max.map((v) => v * scale),
];
}
if (indicesAccessor) {
if (!indicesAccessor._bufferView._indexBuffer) {
indicesAccessor._bufferView._indexBuffer = ctx.indexBuffer(
indicesAccessor._bufferView._data,
);
}
geometryProps = {
...geometryProps,
indices: {
buffer: indicesAccessor._bufferView._indexBuffer,
offset: indicesAccessor.byteOffset,
data: indicesAccessor._bufferView._data,
type: indicesAccessor.componentType,
stride: indicesAccessor._bufferView.byteStride,
normalized: indicesAccessor.normalized,
},
count: indicesAccessor.count,
};
} else if (positionAccessor?._data) {
geometryProps = {
...geometryProps,
count: positionAccessor.count,
};
}
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#primitivemode
if (Number.isInteger(primitive.mode)) {
geometryProps = {
...geometryProps,
primitive: primitive.mode,
};
}
return geometryProps;
}
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/mesh.schema.json
async function handleMesh(
{ primitives, weights },
instancedAttributes,
gltf,
ctx,
options,
) {
return await Promise.all(
primitives.map(async (primitive) => {
const decodedPrimitive = await handlePrimitive(
primitive,
gltf,
ctx,
options,
);
Object.assign(decodedPrimitive, instancedAttributes);
const geometryCmp = components.geometry(decodedPrimitive);
const materialCmp =
primitive.material !== undefined
? components.material(
handleMaterial(gltf.materials[primitive.material], gltf, ctx),
)
: components.material();
const entityComponents = {
geometry: geometryCmp,
material: materialCmp,
};
// Create morph
if (primitive.targets) {
let sources = {};
const targets = primitive.targets.reduce((targets, target) => {
const targetKeys = Object.keys(target);
targetKeys.forEach((targetKey) => {
const targetName = PEX_ATTRIBUTE_NAME_MAP[targetKey] || targetKey;
targets[targetName] = targets[targetName] || [];
const accessor = getAccessor(
gltf.accessors[target[targetKey]],
gltf.bufferViews,
);
targets[targetName].push(
accessor.normalized
? normalizeData(accessor._data)
: accessor._data,
);
if (!sources[targetName]) {
if (
gltf.accessors[primitive.attributes[targetKey]] &&
gltf.accessors[primitive.attributes[targetKey]]._bufferView
) {
const sourceAccessor = getAccessor(
gltf.accessors[primitive.attributes[targetKey]],
gltf.bufferViews,
);
sources[targetName] = sourceAccessor.normalized
? normalizeData(sourceAccessor._data)
: sourceAccessor._data;
} else {
// Draco
sources[targetName] = decodedPrimitive[targetName].data;
}
}
});
return targets;
}, {});
entityComponents.morph = components.morph({
sources,
targets,
weights,
});
}
return entityComponents;
}),
);
}
// eslint-disable-next-line no-unused-vars
const formatLight = ({ type, name, color, ...rest }) => ({
...rest,
color: [...(color || [1, 1, 1]), 1],
});
function getLight(light) {
if (light._light) return light;
switch (light.type) {
case "directional":
light._light = components.directionalLight(formatLight(light));
break;
case "point":
light._light = components.pointLight(formatLight(light));
break;
case "spot":
light._light = components.spotLight({
...formatLight(light),
innerAngle: light.spot?.innerConeAngle || 0,
angle: light.spot?.outerConeAngle || Math.PI / 4.0,
});
break;
default:
throw new Error(`Unexpected light type: ${light.type}`);
}
return light;
}
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/node.schema.json
async function handleNode(node, gltf, i, ctx, options) {
const entityComponents = {};
// const entity = {};
let transform;
if (node.matrix) {
const mn = mat4.create();
const scale = [
Math.hypot(node.matrix[0], node.matrix[1], node.matrix[2]),
Math.hypot(node.matrix[4], node.matrix[5], node.matrix[6]),
Math.hypot(node.matrix[8], node.matrix[9], node.matrix[10]),
];
for (const col of [0, 1, 2]) {
mn[col] = node.matrix[col] / scale[0];
mn[col + 4] = node.matrix[col + 4] / scale[1];
mn[col + 8] = node.matrix[col + 8] / scale[2];
}
transform = {
position: [node.matrix[12], node.matrix[13], node.matrix[14]],
rotation: quat.fromMat4(quat.create(), mn),
scale,
};
} else {
transform = {
position: node.translation || [0, 0, 0],
rotation: node.rotation || [0, 0, 0, 1],
scale: node.scale || [1, 1, 1],
};
}
entityComponents.transform = components.transform(transform);
// entity.transform = transform;
// transform.entity = entity;
if (options.includeCameras && Number.isInteger(node.camera)) {
const camera = gltf.cameras[node.camera];
const enabled = options.enabledCameras.includes(node.camera);
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/camera.schema.json
if (camera.type === "orthographic") {
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/camera.orthographic.schema.json
entityComponents.camera = components.camera({
enabled,
name: camera.name || `camera_${node.camera}`,
projection: "orthographic",
near: camera.orthographic.znear,
far: camera.orthographic.zfar,
left: -camera.orthographic.xmag,
right: camera.orthographic.xmag,
top: camera.orthographic.ymag,
bottom: -camera.orthographic.ymag,
});
} else {
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/camera.perspective.schema.json
entityComponents.camera = components.camera({
enabled,
name: camera.name || `camera_${node.camera}`,
near: camera.perspective.znear,
far: camera.perspective.zfar || Infinity,
fov: camera.perspective.yfov,
aspect:
camera.perspective.aspectRatio ||
ctx.gl.drawingBufferWidth / ctx.gl.drawingBufferHeight,
});
}
}
// https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_lights_punctual
if (
options.includeLights &&
Number.isInteger(node.extensions?.KHR_lights_punctual?.light)
) {
const light =
gltf.extensions?.KHR_lights_punctual?.lights[
node.extensions.KHR_lights_punctual.light
];
if (light) {
const { _light } = getLight(light);
entityComponents[`${light.type}Light`] = _light;
}
}
node.entity = entity(entityComponents);
node.entity.name = node.name || `node_${i}`;
// node.entity = entity;
// node.entity.name = node.name || `node_${i}`;
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/skin.schema.json
let skinCmp = null;
if (Number.isInteger(node.skin)) {
const skin = gltf.skins[node.skin];
const accessor = getAccessor(
gltf.accessors[skin.inverseBindMatrices],
gltf.bufferViews,
);
let inverseBindMatrices = [];
for (let i = 0; i < accessor._data.length; i += 16) {
inverseBindMatrices.push(accessor._data.slice(i, i + 16));
}
skinCmp = components.skin({
inverseBindMatrices: inverseBindMatrices,
});
// skinCmp = {
// inverseBindMatrices,
// };
}
if (Number.isInteger(node.mesh)) {
let instancedAttributes = {};
if (node.extensions && node.extensions.EXT_mesh_gpu_instancing) {
instancedAttributes = await handlePrimitive(
node.extensions.EXT_mesh_gpu_instancing,
gltf,
ctx,
);
Object.keys(instancedAttributes).forEach((attribName) => {
instancedAttributes[attribName].divisor = 1;
});
instancedAttributes.instances = instancedAttributes.offsets.count;
// TODO: if not known attribute, put it in geometry.attributes
}
const primitives = await handleMesh(
gltf.meshes[node.mesh],
instancedAttributes,
gltf,
ctx,
options,
);
if (primitives.length === 1) {
Object.assign(node.entity, primitives[0]);
if (skinCmp) {
node.entity.skin = skinCmp;
}
// const components = primitives[0];
// Object.assign(entity, components);
// if (skinCmp) {
// // node.entity.skin = skinCmp;
// node.entity.addComponent(skinCmp);
// }
return node.entity;
} else {
// create sub nodes for each primitive
const primitiveNodes = primitives.map((components, j) => {
const subEntity = entity(components);
// const subEntity = {
// ...components,
// transform: {},
// // TODO: add components
// };
// subEntity.transform.entity = subEntity;
subEntity.name = `node_${i}_${j}`;
subEntity.transform = {
...(subEntity.transform || {}),
parent: node.entity.transform,
};
// subEntity.transform.parent = node.entity.transform;
// TODO: should skin component be shared?
if (skinCmp) subEntity.skin = skinCmp;
// if (skinCmp) {
// subEntity.skin = skinCmp;
// }
return subEntity;
});
const nodes = [node.entity].concat(primitiveNodes);
return nodes;
}
}
return node.entity;
}
function handleAnimation(animation, { accessors, bufferViews, nodes }, index) {
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/animation.schema.json
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/animation.channel.schema.json
const channels = animation.channels.map((channel) => {
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/animation.sampler.schema.json
const sampler = animation.samplers[channel.sampler];
const input = getAccessor(accessors[sampler.input], bufferViews);
const output = getAccessor(accessors[sampler.output], bufferViews);
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/animation.channel.target.schema.json
const target = nodes[channel.target.node].entity;
const outputData = [];
let od = output._data;
if (output.normalized) {
const scale =
MESH_QUANTIZATION_SCALE[
WEBGL_TYPED_ARRAY_BY_COMPONENT_TYPES[output.componentType]
];
const scaled = new Float32Array(od.length);
for (let j = 0, jl = od.length; j < jl; j++) {
scaled[j] = od[j] * scale;
}
od = scaled;
}
let offset = GLTF_ACCESSOR_TYPE_COMPONENTS_NUMBER[output.type];
if (channel.target.path === "weights") {
offset = target.morph.weights.length;
}
for (let i = 0; i < od.length; i += offset) {
if (offset === 1) {
outputData.push([od[i]]);
}
if (offset === 2) {
outputData.push([od[i], od[i + 1]]);
}
if (offset === 3) {
outputData.push([od[i], od[i + 1], od[i + 2]]);
}
if (offset === 4) {
outputData.push([od[i], od[i + 1], od[i + 2], od[i + 3]]);
}
}
return {
input: input._data,
output: outputData,
interpolation: sampler.interpolation,
target,
path: channel.target.path,
};
});
// return components.animation({
// channels: channels,
// autoplay: true,
// loop: true,
// });
const duration = channels.reduce(
(duration, { input }) => Math.max(duration, input[input.length - 1]),
0,
);
return components.animation({
name: animation.name || `Animation ${index}`,
channels,
duration,
autoplay: true,
loop: true,
});
}
// LOADER
// =============================================================================
function uint8ArrayToArrayBuffer({ buffer, byteOffset, byteLength }) {
return buffer.slice(byteOffset, byteLength + byteOffset);
}
class BinaryReader {
constructor(arrayBuffer) {
this._arrayBuffer = arrayBuffer;
this._dataView = new DataView(arrayBuffer);
this._byteOffset = 0;
}
getPosition() {
return this._byteOffset;
}
getLength() {
return this._arrayBuffer.byteLength;
}
readUint32() {
const value = this._dataView.getUint32(this._byteOffset, true);
this._byteOffset += 4;
return value;
}
readUint8Array(length) {
const value = new Uint8Array(this._arrayBuffer, this._byteOffset, length);
this._byteOffset += length;
return value;
}
skipBytes(length) {
this._byteOffset += length;
}
}
function unpackBinary(data) {
const binaryReader = new BinaryReader(data);
// Check header
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#header
// uint32 magic
// uint32 version
// uint32 length
const magic = binaryReader.readUint32();
if (magic !== MAGIC) throw new Error(`Unexpected magic: ${magic}`);
const version = binaryReader.readUint32();
if (version !== 2) throw new Error(`Unsupported version: ${version} `);
const length = binaryReader.readUint32();
if (length !== binaryReader.getLength()) {
throw new Error(
`Length in header does not match actual data length: ${length} != ${binaryReader.getLength()}`,
);
}
// Decode chunks
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#chunks
// uint32 chunkLength
// uint32 chunkType
// ubyte[] chunkData
// JSON
const chunkLength = binaryReader.readUint32();
const chunkType = binaryReader.readUint32();
if (chunkType !== CHUNK_TYPE.JSON)
throw new Error("First chunk format is not JSON");
// Decode Buffer to Text
const buffer = binaryReader.readUint8Array(chunkLength);
let json;
if (typeof TextDecoder !== "undefined") {
json = new TextDecoder().decode(buffer);
} else {
let result = "";
const length = buffer.byteLength;
for (let i = 0; i < length; i++) {
result += String.fromCharCode(buffer[i]);
}
json = result;
}
// BIN
let bin = null;
while (binaryReader.getPosition() < binaryReader.getLength()) {
const chunkLength = binaryReader.readUint32();
const chunkType = binaryReader.readUint32();
switch (chunkType) {
case CHUNK_TYPE.JSON: {
throw new Error("Unexpected JSON chunk");
}
case CHUNK_TYPE.BIN: {
bin = binaryReader.readUint8Array(chunkLength);
break;
}
default: {
binaryReader.skipBytes(chunkLength);
break;
}
}
}
return {
json,
bin,
};
}
function loadData(data) {
if (data instanceof ArrayBuffer) {
const unpacked = unpackBinary(data);
return {
json: JSON.parse(unpacked.json),
bin: uint8ArrayToArrayBuffer(unpacked.bin),
};
}
return { json: data };
}
function isBase64(uri) {
return uri.length < 5 ? false : uri.substr(0, 5) === "data:";
}
function decodeBase64(uri) {
const decodedString = atob(uri.split(",")[1]);
const bufferLength = decodedString.length;
const bufferView = new Uint8Array(new ArrayBuffer(bufferLength));
for (let i = 0; i < bufferLength; i++) {
bufferView[i] = decodedString.charCodeAt(i);
}
return bufferView.buffer;
}
const DEFAULT_OPTIONS = {
enabledCameras: [0],
enabledScene: undefined,
includeCameras: false,
includeLights: false,
dracoOptions: {},
basisOptions: {},
supportImageBitmap: !isSafari,
};
async function loadGltf(url, options = {}) {
const opts = Object.assign({}, DEFAULT_OPTIONS, options);
const { ctx } = options;
console.debug("loaders.gltf", url, options, opts);
// Load and unpack data
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#glb-file-format-specification
const extension = getFileExtension(url);
const basePath = getDirname(url);
const isBinary = extension === "glb";
console.debug("loaders.gltf", url, extension, isBinary);
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/glTF.schema.json
const { json, bin } = loadData(
isBinary ? await loadArrayBuffer(url) : await loadJson(url),
);
console.debug("loaders.gltf", json, bin);
// Check required extensions
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#specifying-extensions
if (json.extensionsRequired) {
const requiredExtensions = json.extensionsRequired.filter(
(extension) => !SUPPORTED_EXTENSIONS.includes(extension),
);
if (requiredExtensions.length) {
console.error(
"glTF loader: missing required extensions",
requiredExtensions,
);
}
}
if (json.extensionsUsed) {
const unsupportedExtensions = json.extensionsUsed.filter(
(extension) => !SUPPORTED_EXTENSIONS.includes(extension),
);
if (unsupportedExtensions.length) {
console.warn(
"glTF loader: unsupported extensions",
unsupportedExtensions,
);
}
}
// Check asset version
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/asset.schema.json
const version = parseInt(json.asset.version);
if (!version || version < 2) {
console.warn(
`glTF Loader: Invalid or unsupported version: ${json.asset.version}`,
);
}
// Data setup
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#binary-data-storage
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/buffer.schema.json
await Promise.all(
json.buffers.map(async (buffer) => {
if (isBinary) {
buffer._data = bin;
} else {
if (isBase64(buffer.uri)) {
buffer._data = decodeBase64(buffer.uri);
} else {
buffer._data = await loadArrayBuffer(
[basePath, buffer.uri].join("/"),
);
}
}
}),
);
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/bufferView.schema.json
for (let bufferView of json.bufferViews) {
const bufferData = json.buffers[bufferView.buffer]._data;
if (bufferView.byteOffset === undefined) bufferView.byteOffset = 0;
bufferView._data = bufferData.slice(
bufferView.byteOffset,
bufferView.byteOffset + bufferView.byteLength,
);
// Set buffer if target is present
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#bufferviewtarget
if (bufferView.target === WEBGL_CONSTANTS.ELEMENT_ARRAY_BUFFER) {
bufferView._indexBuffer = ctx.indexBuffer(bufferView._data);
} else if (bufferView.target === WEBGL_CONSTANTS.ARRAY_BUFFER) {
bufferView._vertexBuffer = ctx.vertexBuffer(bufferView._data);
}
}
// Load images
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/image.schema.json
// TODO: images should only be loaded as needed by texture to handle fallbacks and prevent extra loading
if (json.images) {
await Promise.all(
json.images.map(async (image) => {
// https://www.khronos.org/registry/glTF/specs/2.0/glTF-2.0.html#uris
if (isBinary || image.bufferView) {
const bufferView = json.bufferViews[image.bufferView];
bufferView.byteOffset = bufferView.byteOffset || 0;
const buffer = json.buffers[bufferView.buffer];
const data = buffer._data.slice(
bufferView.byteOffset,
bufferView.byteOffset + bufferView.byteLength,
);
if (image.mimeType === "image/ktx2") {
image._img = await loadKtx2(data, {
basisOptions: { gl: ctx.gl, ...opts.basisOptions },
});
} else {
const blob = new Blob([data], { type: image.mimeType });
image._img = await loadImage({
url: URL.createObjectURL(blob),
crossOrigin: "anonymous",
});
}
} else if (isBase64(image.uri)) {
image._img = await loadImage({
url: image.uri,
crossOrigin: "anonymous",
});
} else {
const url = decodeURIComponent([basePath, image.uri].join("/"));
if (image.uri.endsWith(".ktx2")) {
image._img = await loadKtx2(url, {
gl: ctx.gl,
basisOptions: { gl: ctx.gl, ...opts.basisOptions },
});
} else {
image._img = opts.supportImageBitmap
? await loadImageBitmap(await loadBlob(url, { mode: "cors" }))
: await loadImage({ url, crossOrigin: "anonymous" });
}
}
}),
);
}
const transformSystem = systems.transform();
// Load scene
// https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/schema/scene.schema.json
let scenes = await Promise.all(
(json.scenes || [{}]).map(async (scene, index) => {
// Create scene root entity
scene.root = entity({
transform: components.transform({
enabled: opts.enabledScene || index === (json.scene || 0),
}),
});
scene.root.name = scene.name || `scene_${index}`;
// Add scene entities for each node and its children
// TODO: scene.entities is just convenience. We could use a user-friendly entity traverse.
// scene.entities = json.nodes.reduce(async (entities, node, i) => {
// const result = await handleNode(node, json, i, ctx, opts);
// if (result.length) {
// result.forEach((primitive) => entities.push(primitive));
// } else {
// entities.push(result);
// }
// return entities;
// }, []);
scene.entities = await Promise.all(
json.nodes.map(
async (node, i) => await handleNode(node, json, i, ctx, opts),
),
);
scene.entities = scene.entities.flat();
scene.entities.unshift(scene.root);
// Build pex-renderer hierarchy
json.nodes.forEach(({ children }, index) => {
const parentNode = json.nodes[index];
const parentTransform = parentNode.entity.transform;
// Default to scene root
if (!parentNode.entity.transform.parent) {
parentNode.entity.transform.parent = scene.root.transform;
}
if (children) {
children.forEach((childIndex