Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/docs/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@

# Transforms

- `StripMaterialsBlock`
- Input: `Document`
- Output: `Document`
- Uses: glTF Transform document graph
- Behavior: Strips materials, assignments, and newly unused textures.
- `ValidateBlock`
- Input: `Document`
- Output: the same `Document`
Expand Down
139 changes: 139 additions & 0 deletions packages/core/src/blocks/stripMaterialsBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { type Document, ExtensionProperty, type Property, Texture, TextureInfo } from "@gltf-transform/core";

import { GltfDocumentType } from "../connectionPoints/gltfDocument";
import { Block, type BlockOptions } from "./block";
import { defineBlock } from "./blockDefinition";

const StripMaterialsBlockDefinition = /* @__PURE__ */ defineBlock({
type: "transform.strip-materials",
input: GltfDocumentType,
output: GltfDocumentType,
run: stripMaterials,
});

/** Options for naming the block or supplying its initial input. */
export type StripMaterialsBlockOptions = BlockOptions<typeof StripMaterialsBlockDefinition>;

/** Strips every authored material, material assignment, and texture made unused by their removal. */
export class StripMaterialsBlock extends Block<typeof StripMaterialsBlockDefinition> {
public constructor(options?: StripMaterialsBlockOptions) {
super(StripMaterialsBlockDefinition, options);
}
}

function stripMaterials(document: Document): Document {
const root = document.getRoot();
const graph = document.getGraph();
const materials = root.listMaterials();
const materialSet = new Set<Property>(materials);
const descendantExtensionProperties = new Set<ExtensionProperty>();
const affectedExtensionNames = new Set<string>();
const affectedTextureExtensionNames = new Set<string>();
const candidateTextures = new Set<Texture>();
const pending: Property[] = [...materials];
const visited = new Set<Property>();
let hasMaterialVariants = false;

while (pending.length > 0) {
const property = pending.pop();
if (property === undefined || visited.has(property)) {
continue;
}
visited.add(property);

for (const edge of graph.listChildEdges(property)) {
const child = edge.getChild();
if (child instanceof Texture) {
candidateTextures.add(child);
pending.push(child);
} else if (child instanceof ExtensionProperty) {
descendantExtensionProperties.add(child);
affectedExtensionNames.add(child.extensionName);
pending.push(child);
} else if (child instanceof TextureInfo) {
pending.push(child);
}
}

if (materialSet.has(property)) {
for (const edge of graph.listParentEdges(property)) {
const parent = edge.getParent();
if (parent instanceof ExtensionProperty && parent.extensionName === "KHR_materials_variants") {
hasMaterialVariants = true;
}
}
}
}

materials.forEach((material) => material.dispose());

if (hasMaterialVariants) {
root.listExtensionsUsed()
.find((extension) => extension.extensionName === "KHR_materials_variants")
?.dispose();
}

let disposedResource = true;
while (disposedResource) {
disposedResource = disposeOrphanedExtensionProperties(descendantExtensionProperties);
for (const texture of candidateTextures) {
if (texture.isDisposed()) {
candidateTextures.delete(texture);
} else if (texture.listParents().every((parent) => parent === root)) {
const textureExtensionName = getTextureExtensionName(texture.getMimeType());
if (textureExtensionName !== undefined) {
affectedTextureExtensionNames.add(textureExtensionName);
}
texture.setImage(null).setURI("").setMimeType("").dispose();
candidateTextures.delete(texture);
disposedResource = true;
}
}
}

for (const extension of root.listExtensionsUsed()) {
const hasNoProperties = affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0;
const hasNoTextures = affectedTextureExtensionNames.has(extension.extensionName) && !isTextureExtensionInUse(extension.extensionName, root.listTextures());
if (hasNoProperties || hasNoTextures) {
extension.dispose();
}
}

return document;
}

function disposeOrphanedExtensionProperties(properties: Set<ExtensionProperty>): boolean {
let disposedAnyProperty = false;
let disposedProperty = true;
while (disposedProperty) {
disposedProperty = false;
for (const property of properties) {
if (property.isDisposed()) {
properties.delete(property);
} else if (property.listParents().length === 0) {
property.dispose();
properties.delete(property);
disposedProperty = true;
disposedAnyProperty = true;
}
}
}
return disposedAnyProperty;
}

function getTextureExtensionName(mimeType: string): string | undefined {
switch (mimeType) {
case "image/avif":
return "EXT_texture_avif";
case "image/ktx2":
return "KHR_texture_basisu";
case "image/webp":
return "EXT_texture_webp";
default:
return undefined;
}
}

function isTextureExtensionInUse(extensionName: string, textures: readonly Texture[]): boolean {
return textures.some((texture) => getTextureExtensionName(texture.getMimeType()) === extensionName);
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { StripMaterialsBlock, type StripMaterialsBlockOptions } from "./blocks/stripMaterialsBlock";
export { EncodeDracoBlock } from "./blocks/encodeDracoBlock";
export { EncodeKTX2Block } from "./blocks/encodeKtx2Block";
export { EncodeMeshoptBlock } from "./blocks/encodeMeshoptBlock";
Expand Down
3 changes: 3 additions & 0 deletions tests/helpers/glb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface GlbJson {
readonly byteOffset?: number;
readonly extensions?: Readonly<Record<string, unknown>>;
}>;
readonly extensions?: Readonly<Record<string, unknown>>;
readonly extensionsRequired?: readonly string[];
readonly extensionsUsed?: readonly string[];
readonly images?: ReadonlyArray<{ readonly bufferView?: number; readonly mimeType?: string; readonly name?: string }>;
Expand All @@ -27,7 +28,9 @@ export interface GlbJson {
}>;
readonly meshes?: ReadonlyArray<{
readonly primitives: ReadonlyArray<{
readonly attributes?: Readonly<Record<string, number>>;
readonly extensions?: Readonly<Record<string, unknown>>;
readonly material?: number;
}>;
}>;
}
Expand Down
97 changes: 97 additions & 0 deletions tests/integration/stripMaterials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";

import { GltfInputBlock, GltfOutputBlock, NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index";
import { parseGlbAsync } from "../helpers/glb";
import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf";

describe("material stripping", () => {
it("removes materials, assignments, and newly unused texture data from connected glTF output", async () => {
const url = "https://example.com/model.gltf";
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(generateExtendedMaterialGltfJson())))
);

try {
const source = new GltfInputBlock({ input: url });
const stripMaterials = new StripMaterialsBlock();
const destination = new GltfOutputBlock();
source.output.connectTo(stripMaterials.input);
stripMaterials.output.connectTo(destination.input);

const parsed = await parseGlbAsync(await new NodeAsset({ name: "material-free-glb", outputBlock: destination }).executeAsync());
const primitive = parsed.json.meshes?.[0]?.primitives[0];

expect(parsed.json.materials).toBeUndefined();
expect(parsed.json.textures).toBeUndefined();
expect(parsed.json.images).toBeUndefined();
expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_materials_clearcoat");
expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_materials_variants");
expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_texture_transform");
expect(parsed.json.extensions?.KHR_materials_variants).toBeUndefined();
expect(primitive?.material).toBeUndefined();
expect(primitive?.extensions?.KHR_materials_variants).toBeUndefined();
expect(primitive?.attributes).toEqual({ NORMAL: 1, POSITION: 0, TEXCOORD_0: 2 });
} finally {
vi.unstubAllGlobals();
}
});

it("round trips material-free input without changing geometry", async () => {
const url = "https://example.com/model.gltf";
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(generateGltfJson())))
);

try {
const source = new GltfInputBlock({ input: url });
const stripMaterials = new StripMaterialsBlock();
const destination = new GltfOutputBlock();
source.output.connectTo(stripMaterials.input);
stripMaterials.output.connectTo(destination.input);

const parsed = await parseGlbAsync(await new NodeAsset({ name: "already-material-free-glb", outputBlock: destination }).executeAsync());

expect(parsed.json.materials).toBeUndefined();
expect(parsed.json.meshes?.[0]?.primitives[0]?.attributes).toEqual({ NORMAL: 1, POSITION: 0 });
} finally {
vi.unstubAllGlobals();
}
});
});

function generateExtendedMaterialGltfJson(): string {
const gltf = JSON.parse(generateTexturedGltfJson()) as {
extensions?: Record<string, unknown>;
extensionsUsed?: string[];
materials: Array<{ extensions?: Record<string, unknown> }>;
meshes: Array<{ primitives: Array<{ extensions?: Record<string, unknown> }> }>;
};

gltf.extensionsUsed = ["KHR_materials_clearcoat", "KHR_materials_variants", "KHR_texture_transform"];
gltf.extensions = {
KHR_materials_variants: {
variants: [{ name: "Alternate" }],
},
};
gltf.materials[0]!.extensions = {
KHR_materials_clearcoat: {
clearcoatFactor: 1,
clearcoatTexture: {
index: 0,
extensions: {
KHR_texture_transform: {
offset: [0.5, 0.5],
},
},
},
},
};
gltf.meshes[0]!.primitives[0]!.extensions = {
KHR_materials_variants: {
mappings: [{ material: 0, variants: [0] }],
},
};
return JSON.stringify(gltf);
}
125 changes: 125 additions & 0 deletions tests/unit/stripMaterialsBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Document } from "@gltf-transform/core";
import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureBasisu, KHRTextureTransform, KHRXMP } from "@gltf-transform/extensions";
import { describe, expect, it } from "vitest";

import { NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index";

describe("StripMaterialsBlock", () => {
it("removes every material and only textures made unused by their deletion", async () => {
const { document, primitive } = createDocument();
const removedTexture = document.createTexture("removed");
const preexistingUnusedTexture = document.createTexture("preexisting-unused");
const firstMaterial = document.createMaterial("first").setBaseColorTexture(removedTexture);
document.createMaterial("second").setEmissiveTexture(removedTexture);
primitive.setMaterial(firstMaterial);

const position = primitive.getAttribute("POSITION");
const texCoord = primitive.getAttribute("TEXCOORD_0");
const color = primitive.getAttribute("COLOR_0");
const indices = primitive.getIndices();
const block = new StripMaterialsBlock({ input: document });

const result = await new NodeAsset({ name: "strip-materials", outputBlock: block }).executeAsync();

expect(result).toBe(document);
expect(document.getRoot().listMaterials()).toEqual([]);
expect(primitive.getMaterial()).toBeNull();
expect(document.getRoot().listTextures()).toEqual([preexistingUnusedTexture]);
expect(primitive.getAttribute("POSITION")).toBe(position);
expect(primitive.getAttribute("TEXCOORD_0")).toBe(texCoord);
expect(primitive.getAttribute("COLOR_0")).toBe(color);
expect(primitive.getIndices()).toBe(indices);
});

it("removes material extension resources while preserving surviving texture uses", async () => {
const { document, node, primitive } = createDocument();
const sharedTexture = document.createTexture("shared");
const extensionTexture = document
.createTexture("clearcoat")
.setImage(new Uint8Array([1, 2, 3]))
.setMimeType("image/ktx2");
const material = document.createMaterial("material").setBaseColorTexture(sharedTexture);
primitive.setMaterial(material);

document.createExtension(KHRTextureBasisu).setRequired(true);
const clearcoatExtension = document.createExtension(KHRMaterialsClearcoat);
const clearcoat = clearcoatExtension.createClearcoat().setClearcoatTexture(extensionTexture);
material.setExtension(KHRMaterialsClearcoat.EXTENSION_NAME, clearcoat);
const clearcoatTextureInfo = clearcoat.getClearcoatTextureInfo()!;
const textureTransformExtension = document.createExtension(KHRTextureTransform);
clearcoatTextureInfo.setExtension(KHRTextureTransform.EXTENSION_NAME, textureTransformExtension.createTransform().setOffset([0.5, 0.5]));

const variantsExtension = document.createExtension(KHRMaterialsVariants);
const variant = variantsExtension.createVariant("variant");
const mapping = variantsExtension.createMapping().setMaterial(material).addVariant(variant);
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, variantsExtension.createMappingList().addMapping(mapping));

const meshFeaturesExtension = document.createExtension(EXTMeshFeatures);
const featureTexture = meshFeaturesExtension.createFeatureIDTexture().setTexture(sharedTexture);
const featureId = meshFeaturesExtension.createFeatureID().setFeatureCount(1).setTexture(featureTexture);
primitive.setExtension(EXTMeshFeatures.EXTENSION_NAME, meshFeaturesExtension.createFeatures().addFeatureID(featureId));

const xmpExtension = document.createExtension(KHRXMP);
const packet = xmpExtension.createPacket();
const texturePacket = xmpExtension.createPacket();
material.setExtension(KHRXMP.EXTENSION_NAME, packet);
node.setExtension(KHRXMP.EXTENSION_NAME, packet);
extensionTexture.setExtension(KHRXMP.EXTENSION_NAME, texturePacket);

const block = new StripMaterialsBlock({ input: document });
await new NodeAsset({ name: "strip-material-extensions", outputBlock: block }).executeAsync();

expect(document.getRoot().listTextures()).toEqual([sharedTexture]);
expect(extensionTexture.getImage()).toBeNull();
expect(primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)).toBeNull();
expect(primitive.getExtension(EXTMeshFeatures.EXTENSION_NAME)).not.toBeNull();
expect(node.getExtension(KHRXMP.EXTENSION_NAME)).toBe(packet);
expect(xmpExtension.listPackets()).toEqual([packet]);
expect(document.getRoot().listExtensionsUsed()).toEqual([meshFeaturesExtension, xmpExtension]);
expect(document.getRoot().listExtensionsRequired()).toEqual([]);
});

it("supports material-free documents and repeated execution", async () => {
const document = new Document();
const preexistingUnusedTexture = document.createTexture("preexisting-unused").setMimeType("image/ktx2");
const textureExtension = document.createExtension(KHRTextureBasisu).setRequired(true);
const block = new StripMaterialsBlock({ input: document });
const asset = new NodeAsset({ name: "strip-no-materials", outputBlock: block });

await expect(asset.executeAsync()).resolves.toBe(document);
await expect(asset.executeAsync()).resolves.toBe(document);
expect(document.getRoot().listTextures()).toEqual([preexistingUnusedTexture]);
expect(document.getRoot().listExtensionsUsed()).toEqual([textureExtension]);
expect(document.getRoot().listExtensionsRequired()).toEqual([textureExtension]);
});
});

function createDocument(): {
document: Document;
node: ReturnType<Document["createNode"]>;
primitive: ReturnType<Document["createPrimitive"]>;
} {
const document = new Document();
const buffer = document.createBuffer();
const position = document
.createAccessor("position", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const texCoord = document
.createAccessor("texcoord", buffer)
.setType("VEC2")
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]));
const color = document
.createAccessor("color", buffer)
.setType("VEC4")
.setArray(new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]));
const indices = document
.createAccessor("indices", buffer)
.setType("SCALAR")
.setArray(new Uint16Array([0, 1, 2]));
const primitive = document.createPrimitive().setAttribute("POSITION", position).setAttribute("TEXCOORD_0", texCoord).setAttribute("COLOR_0", color).setIndices(indices);
const mesh = document.createMesh().addPrimitive(primitive);
const node = document.createNode().setMesh(mesh);
document.createScene().addChild(node);
return { document, node, primitive };
}
Loading