@forgeax/engine-gltf 0.1.23 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -3
- package/dist/__tests__/physical-clearcoat.unit.test.d.ts +2 -0
- package/dist/__tests__/physical-clearcoat.unit.test.d.ts.map +1 -0
- package/dist/__tests__/physical-material-import.integration.test.d.ts +2 -0
- package/dist/__tests__/physical-material-import.integration.test.d.ts.map +1 -0
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.d.ts.map +1 -1
- package/dist/check-extensions.d.ts +1 -1
- package/dist/check-extensions.d.ts.map +1 -1
- package/dist/cli-gltf.mjs +181 -8
- package/dist/cli-gltf.mjs.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/gltf-importer.d.ts.map +1 -1
- package/dist/image-color-space.d.ts +12 -0
- package/dist/image-color-space.d.ts.map +1 -1
- package/dist/importer-entry.mjs +373 -24
- package/dist/importer-entry.mjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +374 -25
- package/dist/index.mjs.map +1 -1
- package/dist/material/parse-material.d.ts +66 -0
- package/dist/material/parse-material.d.ts.map +1 -1
- package/dist/node-file-entry.mjs +181 -8
- package/dist/node-file-entry.mjs.map +1 -1
- package/package.json +12 -12
- package/src/__tests__/bridge-material-values.unit.test.ts +84 -1
- package/src/__tests__/bridge-skin-stride.unit.test.ts +22 -0
- package/src/__tests__/gltf-error-derived-views.test-d.ts +4 -0
- package/src/__tests__/gltf-error-migration.test.ts +3 -3
- package/src/__tests__/gltf.unit.test.ts +52 -6
- package/src/__tests__/material-pack-refs.integration.test.ts +53 -0
- package/src/__tests__/parse-gltf.unit.test.ts +29 -1
- package/src/__tests__/physical-clearcoat.unit.test.ts +110 -0
- package/src/__tests__/physical-material-import.integration.test.ts +239 -0
- package/src/bridge.ts +258 -13
- package/src/check-extensions.ts +16 -10
- package/src/errors.ts +21 -2
- package/src/gltf-importer.ts +35 -0
- package/src/image-color-space.ts +29 -4
- package/src/index.ts +8 -1
- package/src/material/parse-material.ts +307 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import {
|
|
3
|
+
ImporterRegistry,
|
|
4
|
+
type ImportRunnerFs,
|
|
5
|
+
type RunImportMeta,
|
|
6
|
+
runImport,
|
|
7
|
+
} from '@forgeax/engine-import';
|
|
8
|
+
import type { TextureAsset } from '@forgeax/engine-types';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { gltfImporter } from '../gltf-importer.js';
|
|
11
|
+
|
|
12
|
+
const SOURCE = 'physical-material.gltf';
|
|
13
|
+
const TINY_PNG_BASE64 =
|
|
14
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC';
|
|
15
|
+
|
|
16
|
+
const MATERIAL_GUID = '019f0000-0000-7000-8000-000000000100';
|
|
17
|
+
const TEXTURE_GUIDS = [
|
|
18
|
+
'019f0000-0000-7000-8000-000000000101',
|
|
19
|
+
'019f0000-0000-7000-8000-000000000102',
|
|
20
|
+
'019f0000-0000-7000-8000-000000000103',
|
|
21
|
+
'019f0000-0000-7000-8000-000000000104',
|
|
22
|
+
'019f0000-0000-7000-8000-000000000105',
|
|
23
|
+
] as const;
|
|
24
|
+
const SAMPLER_GUIDS = [
|
|
25
|
+
'019f0000-0000-7000-8000-000000000106',
|
|
26
|
+
'019f0000-0000-7000-8000-000000000107',
|
|
27
|
+
'019f0000-0000-7000-8000-000000000108',
|
|
28
|
+
'019f0000-0000-7000-8000-000000000109',
|
|
29
|
+
'019f0000-0000-7000-8000-00000000010a',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
function dataUri(): string {
|
|
33
|
+
return `data:image/png;base64,${TINY_PNG_BASE64}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sourceBytes(): Uint8Array {
|
|
37
|
+
const textures = TEXTURE_GUIDS.map((_guid, index) => ({ source: index, sampler: index }));
|
|
38
|
+
return new TextEncoder().encode(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
asset: { version: '2.0' },
|
|
41
|
+
extensionsUsed: [
|
|
42
|
+
'KHR_materials_clearcoat',
|
|
43
|
+
'KHR_materials_anisotropy',
|
|
44
|
+
'KHR_materials_sheen',
|
|
45
|
+
'KHR_materials_iridescence',
|
|
46
|
+
'KHR_materials_specular',
|
|
47
|
+
],
|
|
48
|
+
materials: [
|
|
49
|
+
{
|
|
50
|
+
name: 'PhysicalSurface',
|
|
51
|
+
pbrMetallicRoughness: {
|
|
52
|
+
baseColorFactor: [0.8, 0.7, 0.6, 1],
|
|
53
|
+
metallicFactor: 0.1,
|
|
54
|
+
roughnessFactor: 0.35,
|
|
55
|
+
},
|
|
56
|
+
extensions: {
|
|
57
|
+
KHR_materials_clearcoat: {
|
|
58
|
+
clearcoatFactor: 0.75,
|
|
59
|
+
clearcoatRoughnessFactor: 0.2,
|
|
60
|
+
clearcoatNormalTexture: { index: 0 },
|
|
61
|
+
},
|
|
62
|
+
KHR_materials_anisotropy: {
|
|
63
|
+
anisotropyStrength: 0.5,
|
|
64
|
+
anisotropyRotation: 0.25,
|
|
65
|
+
anisotropyTexture: { index: 1 },
|
|
66
|
+
},
|
|
67
|
+
KHR_materials_sheen: {
|
|
68
|
+
sheenColorFactor: [0.2, 0.1, 0.05],
|
|
69
|
+
sheenRoughnessFactor: 0.3,
|
|
70
|
+
sheenColorTexture: { index: 2 },
|
|
71
|
+
},
|
|
72
|
+
KHR_materials_iridescence: {
|
|
73
|
+
iridescenceFactor: 0.6,
|
|
74
|
+
iridescenceIor: 1.4,
|
|
75
|
+
iridescenceThicknessMinimum: 120,
|
|
76
|
+
iridescenceThicknessMaximum: 380,
|
|
77
|
+
iridescenceTexture: { index: 3 },
|
|
78
|
+
},
|
|
79
|
+
KHR_materials_specular: {
|
|
80
|
+
specularFactor: 0.8,
|
|
81
|
+
specularColorFactor: [0.9, 0.85, 0.75],
|
|
82
|
+
specularColorTexture: { index: 4 },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
textures,
|
|
88
|
+
samplers: [{}, {}, {}, {}, {}],
|
|
89
|
+
images: TEXTURE_GUIDS.map(() => ({ uri: dataUri(), mimeType: 'image/png' })),
|
|
90
|
+
scenes: [],
|
|
91
|
+
nodes: [],
|
|
92
|
+
meshes: [],
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function meta(): RunImportMeta {
|
|
98
|
+
return {
|
|
99
|
+
importer: 'gltf',
|
|
100
|
+
source: SOURCE,
|
|
101
|
+
subAssets: [
|
|
102
|
+
{
|
|
103
|
+
guid: MATERIAL_GUID,
|
|
104
|
+
sourceIndex: 0,
|
|
105
|
+
sourceKey: 'material/physical-surface',
|
|
106
|
+
kind: 'material',
|
|
107
|
+
},
|
|
108
|
+
...TEXTURE_GUIDS.map((guid, sourceIndex) => ({
|
|
109
|
+
guid,
|
|
110
|
+
sourceIndex,
|
|
111
|
+
sourceKey: `texture/${sourceIndex}`,
|
|
112
|
+
kind: 'texture' as const,
|
|
113
|
+
})),
|
|
114
|
+
...SAMPLER_GUIDS.map((guid, sourceIndex) => ({
|
|
115
|
+
guid,
|
|
116
|
+
sourceIndex,
|
|
117
|
+
sourceKey: `sampler/${sourceIndex}`,
|
|
118
|
+
kind: 'sampler' as const,
|
|
119
|
+
})),
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function decodeBase64(value: string): Uint8Array {
|
|
125
|
+
const binary = atob(value);
|
|
126
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fs(): ImportRunnerFs {
|
|
130
|
+
return {
|
|
131
|
+
readSource: async (sourcePath) =>
|
|
132
|
+
sourcePath === SOURCE
|
|
133
|
+
? { ok: true, value: sourceBytes() }
|
|
134
|
+
: { ok: false, error: new Error(`unexpected source path ${sourcePath}`) },
|
|
135
|
+
decodeImage: async (_bytes, _mimeType, settings) => {
|
|
136
|
+
const colorSpace = settings.colorSpace === 'srgb' ? 'srgb' : 'linear';
|
|
137
|
+
const texture: TextureAsset = {
|
|
138
|
+
kind: 'texture',
|
|
139
|
+
shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
|
|
140
|
+
format: colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm',
|
|
141
|
+
data: new Uint8Array([128, 96, 64, 255]),
|
|
142
|
+
colorSpace,
|
|
143
|
+
mips: { kind: 'generate' },
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
value: {
|
|
148
|
+
texture,
|
|
149
|
+
bytes: decodeBase64(TINY_PNG_BASE64),
|
|
150
|
+
mediaType: 'image/png',
|
|
151
|
+
assetCodec: { name: 'rgba8', version: '1' },
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe('glTF physical material importer route', () => {
|
|
159
|
+
it('carries all five KHR layers from importer through pack and runtime material parsing', async () => {
|
|
160
|
+
const importers = new ImporterRegistry();
|
|
161
|
+
importers.register(gltfImporter);
|
|
162
|
+
|
|
163
|
+
const result = await runImport(meta(), importers, fs());
|
|
164
|
+
expect(result.ok).toBe(true);
|
|
165
|
+
if (!result.ok || 'skipped' in result.value)
|
|
166
|
+
throw new Error('physical import did not publish a pack');
|
|
167
|
+
|
|
168
|
+
expect(result.value.pack.assets).toHaveLength(11);
|
|
169
|
+
expect(result.value.cookProducts).toHaveLength(11);
|
|
170
|
+
expect(result.value.cookProducts.every((product) => product.receipt !== undefined)).toBe(true);
|
|
171
|
+
|
|
172
|
+
const material = result.value.pack.assets.find((asset) => asset.guid === MATERIAL_GUID);
|
|
173
|
+
if (material === undefined) throw new Error('physical material pack row missing');
|
|
174
|
+
expect(material.refs).toEqual([
|
|
175
|
+
TEXTURE_GUIDS[0],
|
|
176
|
+
SAMPLER_GUIDS[0],
|
|
177
|
+
TEXTURE_GUIDS[1],
|
|
178
|
+
SAMPLER_GUIDS[1],
|
|
179
|
+
TEXTURE_GUIDS[2],
|
|
180
|
+
SAMPLER_GUIDS[2],
|
|
181
|
+
TEXTURE_GUIDS[3],
|
|
182
|
+
SAMPLER_GUIDS[3],
|
|
183
|
+
TEXTURE_GUIDS[4],
|
|
184
|
+
SAMPLER_GUIDS[4],
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const payload = material.payload;
|
|
188
|
+
const parameterNames = new Set(
|
|
189
|
+
(payload.parameters as readonly { name: string }[]).map((parameter) => parameter.name),
|
|
190
|
+
);
|
|
191
|
+
for (const name of [
|
|
192
|
+
'clearcoat',
|
|
193
|
+
'anisotropyStrength',
|
|
194
|
+
'sheenColor',
|
|
195
|
+
'iridescence',
|
|
196
|
+
'specular',
|
|
197
|
+
'clearcoatNormalTexture',
|
|
198
|
+
'anisotropyTexture',
|
|
199
|
+
'sheenColorTexture',
|
|
200
|
+
'iridescenceTexture',
|
|
201
|
+
'specularColorTexture',
|
|
202
|
+
]) {
|
|
203
|
+
expect(parameterNames.has(name)).toBe(true);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const runtime = new AssetRegistry({
|
|
207
|
+
findMaterialArtifact: () => ({ ok: false, error: new Error('shader registry not wired') }),
|
|
208
|
+
} as never);
|
|
209
|
+
const parsed = runtime.parseAndReturnAsset({
|
|
210
|
+
kind: material.kind,
|
|
211
|
+
payload,
|
|
212
|
+
refs: [...material.refs],
|
|
213
|
+
});
|
|
214
|
+
expect(parsed.ok).toBe(true);
|
|
215
|
+
if (!parsed.ok) return;
|
|
216
|
+
expect(parsed.value.asset.kind).toBe('material');
|
|
217
|
+
const values = (parsed.value.asset as { values?: Record<string, unknown> }).values ?? {};
|
|
218
|
+
expect(values.clearcoatNormalTexture).toMatchObject({
|
|
219
|
+
texture: TEXTURE_GUIDS[0],
|
|
220
|
+
sampler: SAMPLER_GUIDS[0],
|
|
221
|
+
});
|
|
222
|
+
expect(values.anisotropyTexture).toMatchObject({
|
|
223
|
+
texture: TEXTURE_GUIDS[1],
|
|
224
|
+
sampler: SAMPLER_GUIDS[1],
|
|
225
|
+
});
|
|
226
|
+
expect(values.sheenColorTexture).toMatchObject({
|
|
227
|
+
texture: TEXTURE_GUIDS[2],
|
|
228
|
+
sampler: SAMPLER_GUIDS[2],
|
|
229
|
+
});
|
|
230
|
+
expect(values.iridescenceTexture).toMatchObject({
|
|
231
|
+
texture: TEXTURE_GUIDS[3],
|
|
232
|
+
sampler: SAMPLER_GUIDS[3],
|
|
233
|
+
});
|
|
234
|
+
expect(values.specularColorTexture).toMatchObject({
|
|
235
|
+
texture: TEXTURE_GUIDS[4],
|
|
236
|
+
sampler: SAMPLER_GUIDS[4],
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
package/src/bridge.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// (B3) child world pos accumulates parent transform
|
|
20
20
|
// (B6) camera detection via GltfNodeIr.camera field (not legacy nodes[1] heuristic)
|
|
21
21
|
|
|
22
|
-
import { packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
|
|
22
|
+
import { computeTangentVec4, packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
|
|
23
23
|
import type { Mat4 } from '@forgeax/engine-math';
|
|
24
24
|
import { box3, mat4, quat, vec3 } from '@forgeax/engine-math';
|
|
25
25
|
import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
|
|
@@ -40,6 +40,13 @@ import type {
|
|
|
40
40
|
Submesh,
|
|
41
41
|
VertexAttributeMap,
|
|
42
42
|
} from '@forgeax/engine-types';
|
|
43
|
+
import {
|
|
44
|
+
STANDARD_LAYER_PARAMETER_GROUPS,
|
|
45
|
+
STANDARD_MATERIAL_PARAM_SCHEMA,
|
|
46
|
+
STANDARD_PHYSICAL_PARAMETER_NAMES,
|
|
47
|
+
STANDARD_TRANSMISSION_PARAMETER_NAMES,
|
|
48
|
+
standardMaterialParameters,
|
|
49
|
+
} from '@forgeax/engine-types';
|
|
43
50
|
import { createMaterialError, err, type GltfError, gltfErr, ok } from './errors.js';
|
|
44
51
|
import type {
|
|
45
52
|
GltfDoc,
|
|
@@ -216,6 +223,21 @@ export function meshIrToMeshAsset(
|
|
|
216
223
|
const materialSlot = slotFor(mesh.materialIndex);
|
|
217
224
|
const primVertexCount = mesh.positions.length / 3;
|
|
218
225
|
const primIndexCount = mesh.indices === undefined ? 0 : mesh.indices.length;
|
|
226
|
+
// A tangent admission check may already have computed a valid frame for a
|
|
227
|
+
// physical glTF material. Persist that producer result into the canonical
|
|
228
|
+
// MeshAsset instead of silently replacing it with the historical identity
|
|
229
|
+
// tangent. Non-physical meshes keep the identity fallback when the source
|
|
230
|
+
// lacks the inputs required by the tangent producer.
|
|
231
|
+
let generatedTangents: Float32Array | undefined;
|
|
232
|
+
if (mesh.tangents === undefined && mesh.normals !== undefined && mesh.texcoord0 !== undefined) {
|
|
233
|
+
const generated = computeTangentVec4(
|
|
234
|
+
mesh.positions,
|
|
235
|
+
mesh.normals,
|
|
236
|
+
mesh.texcoord0,
|
|
237
|
+
mesh.indices,
|
|
238
|
+
);
|
|
239
|
+
if (generated.ok) generatedTangents = generated.value;
|
|
240
|
+
}
|
|
219
241
|
if (mesh.colors0 !== undefined && mesh.colors0.length !== primVertexCount * 4) {
|
|
220
242
|
return err(
|
|
221
243
|
gltfErr('gltf-mesh-bridge-invalid', {
|
|
@@ -247,12 +269,13 @@ export function meshIrToMeshAsset(
|
|
|
247
269
|
uvsCat[(vertexCursor + i) * 2 + 0] = mesh.texcoord0[t + 0] as number;
|
|
248
270
|
uvsCat[(vertexCursor + i) * 2 + 1] = mesh.texcoord0[t + 1] as number;
|
|
249
271
|
}
|
|
250
|
-
|
|
272
|
+
const sourceTangents = mesh.tangents ?? generatedTangents;
|
|
273
|
+
if (sourceTangents !== undefined) {
|
|
251
274
|
const g = i * 4;
|
|
252
|
-
tangentsCat[(vertexCursor + i) * 4 + 0] =
|
|
253
|
-
tangentsCat[(vertexCursor + i) * 4 + 1] =
|
|
254
|
-
tangentsCat[(vertexCursor + i) * 4 + 2] =
|
|
255
|
-
tangentsCat[(vertexCursor + i) * 4 + 3] =
|
|
275
|
+
tangentsCat[(vertexCursor + i) * 4 + 0] = sourceTangents[g + 0] as number;
|
|
276
|
+
tangentsCat[(vertexCursor + i) * 4 + 1] = sourceTangents[g + 1] as number;
|
|
277
|
+
tangentsCat[(vertexCursor + i) * 4 + 2] = sourceTangents[g + 2] as number;
|
|
278
|
+
tangentsCat[(vertexCursor + i) * 4 + 3] = sourceTangents[g + 3] as number;
|
|
256
279
|
} else {
|
|
257
280
|
tangentsCat[(vertexCursor + i) * 4 + 0] = 1;
|
|
258
281
|
tangentsCat[(vertexCursor + i) * 4 + 3] = 1;
|
|
@@ -778,7 +801,17 @@ type MaterialTextureSlot =
|
|
|
778
801
|
| 'occlusionTexture'
|
|
779
802
|
| 'emissiveTexture'
|
|
780
803
|
| 'transmissionTexture'
|
|
781
|
-
| 'thicknessTexture'
|
|
804
|
+
| 'thicknessTexture'
|
|
805
|
+
| 'clearcoatTexture'
|
|
806
|
+
| 'clearcoatRoughnessTexture'
|
|
807
|
+
| 'clearcoatNormalTexture'
|
|
808
|
+
| 'anisotropyTexture'
|
|
809
|
+
| 'sheenColorTexture'
|
|
810
|
+
| 'sheenRoughnessTexture'
|
|
811
|
+
| 'iridescenceTexture'
|
|
812
|
+
| 'iridescenceThicknessTexture'
|
|
813
|
+
| 'specularTexture'
|
|
814
|
+
| 'specularColorTexture';
|
|
782
815
|
|
|
783
816
|
function textureValue(
|
|
784
817
|
info: GltfTextureInfoIr | number | undefined,
|
|
@@ -811,6 +844,12 @@ function textureValue(
|
|
|
811
844
|
return { ...value, normalScale: normal.scale };
|
|
812
845
|
}
|
|
813
846
|
}
|
|
847
|
+
if (slot === 'clearcoatNormalTexture') {
|
|
848
|
+
const normal = info as GltfMaterialIr['clearcoatNormalTexture'];
|
|
849
|
+
if (typeof normal === 'object' && normal?.scale !== undefined) {
|
|
850
|
+
return { ...value, normalScale: normal.scale };
|
|
851
|
+
}
|
|
852
|
+
}
|
|
814
853
|
if (slot === 'occlusionTexture') {
|
|
815
854
|
const occlusion = info as GltfMaterialIr['occlusionTexture'];
|
|
816
855
|
if (typeof occlusion === 'object' && occlusion?.strength !== undefined) {
|
|
@@ -820,6 +859,102 @@ function textureValue(
|
|
|
820
859
|
return value;
|
|
821
860
|
}
|
|
822
861
|
|
|
862
|
+
/**
|
|
863
|
+
* Select the exact Standard root contract emitted by the glTF producer.
|
|
864
|
+
* Numeric/base fields come from the shared schema; physical texture slots are
|
|
865
|
+
* admitted only when the source actually declares them. A parent root is
|
|
866
|
+
* retained for the legacy base-only path, while an extended glTF material
|
|
867
|
+
* becomes its own root so its extension declarations own cook/ABI identity.
|
|
868
|
+
*/
|
|
869
|
+
function standardRootParameterNames(mat: GltfMaterialIr): {
|
|
870
|
+
readonly names: ReadonlySet<string>;
|
|
871
|
+
readonly extended: boolean;
|
|
872
|
+
} {
|
|
873
|
+
const names = new Set(
|
|
874
|
+
STANDARD_MATERIAL_PARAM_SCHEMA.filter(
|
|
875
|
+
(entry) =>
|
|
876
|
+
!STANDARD_PHYSICAL_PARAMETER_NAMES.has(entry.name) &&
|
|
877
|
+
!STANDARD_TRANSMISSION_PARAMETER_NAMES.has(entry.name),
|
|
878
|
+
).map((entry) => entry.name),
|
|
879
|
+
);
|
|
880
|
+
// IOR is consumed for the base dielectric F0 fallback even when the
|
|
881
|
+
// transmission/volume extension is absent.
|
|
882
|
+
names.add('ior');
|
|
883
|
+
|
|
884
|
+
const addLayer = (layer: keyof typeof STANDARD_LAYER_PARAMETER_GROUPS): void => {
|
|
885
|
+
for (const name of STANDARD_LAYER_PARAMETER_GROUPS[layer]) names.add(name);
|
|
886
|
+
};
|
|
887
|
+
const addTexture = (name: string, info: GltfTextureInfoIr | number | undefined): void => {
|
|
888
|
+
if (info !== undefined) names.add(name);
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const clearcoat =
|
|
892
|
+
mat.clearcoatFactor !== undefined ||
|
|
893
|
+
mat.clearcoatRoughnessFactor !== undefined ||
|
|
894
|
+
mat.clearcoatNormalTexture !== undefined ||
|
|
895
|
+
mat.clearcoatTexture !== undefined ||
|
|
896
|
+
mat.clearcoatRoughnessTexture !== undefined;
|
|
897
|
+
const anisotropy =
|
|
898
|
+
mat.anisotropyStrength !== undefined ||
|
|
899
|
+
mat.anisotropyRotation !== undefined ||
|
|
900
|
+
mat.anisotropyTexture !== undefined;
|
|
901
|
+
const sheen =
|
|
902
|
+
mat.sheenColorFactor !== undefined ||
|
|
903
|
+
mat.sheenRoughnessFactor !== undefined ||
|
|
904
|
+
mat.sheenColorTexture !== undefined ||
|
|
905
|
+
mat.sheenRoughnessTexture !== undefined;
|
|
906
|
+
const iridescence =
|
|
907
|
+
mat.iridescenceFactor !== undefined ||
|
|
908
|
+
mat.iridescenceIor !== undefined ||
|
|
909
|
+
mat.iridescenceThicknessMinimum !== undefined ||
|
|
910
|
+
mat.iridescenceThicknessMaximum !== undefined ||
|
|
911
|
+
mat.iridescenceTexture !== undefined ||
|
|
912
|
+
mat.iridescenceThicknessTexture !== undefined;
|
|
913
|
+
if (clearcoat) addLayer('clearcoat');
|
|
914
|
+
if (anisotropy) addLayer('anisotropy');
|
|
915
|
+
if (sheen) addLayer('sheen');
|
|
916
|
+
if (iridescence) addLayer('iridescence');
|
|
917
|
+
if (clearcoat && mat.clearcoatNormalTexture !== undefined) names.add('clearcoatNormalScale');
|
|
918
|
+
|
|
919
|
+
addTexture('clearcoatTexture', mat.clearcoatTexture);
|
|
920
|
+
addTexture('clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture);
|
|
921
|
+
addTexture('clearcoatNormalTexture', mat.clearcoatNormalTexture);
|
|
922
|
+
addTexture('anisotropyTexture', mat.anisotropyTexture);
|
|
923
|
+
addTexture('sheenColorTexture', mat.sheenColorTexture);
|
|
924
|
+
addTexture('sheenRoughnessTexture', mat.sheenRoughnessTexture);
|
|
925
|
+
addTexture('iridescenceTexture', mat.iridescenceTexture);
|
|
926
|
+
addTexture('iridescenceThicknessTexture', mat.iridescenceThicknessTexture);
|
|
927
|
+
addTexture('specularTexture', mat.specularTexture);
|
|
928
|
+
addTexture('specularColorTexture', mat.specularColorTexture);
|
|
929
|
+
|
|
930
|
+
const transmission =
|
|
931
|
+
mat.transmissionFactor !== undefined ||
|
|
932
|
+
mat.transmissionTexture !== undefined ||
|
|
933
|
+
mat.ior !== undefined ||
|
|
934
|
+
mat.thicknessFactor !== undefined ||
|
|
935
|
+
mat.thicknessTexture !== undefined ||
|
|
936
|
+
mat.attenuationColor !== undefined ||
|
|
937
|
+
mat.attenuationDistance !== undefined;
|
|
938
|
+
if (transmission) {
|
|
939
|
+
// Transmission/volume keeps its existing paired backdrop ABI. The
|
|
940
|
+
// producer may leave either value at its neutral fallback, but the pair
|
|
941
|
+
// remains part of the pre-existing transmission root contract.
|
|
942
|
+
for (const name of STANDARD_TRANSMISSION_PARAMETER_NAMES) names.add(name);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
names,
|
|
947
|
+
extended:
|
|
948
|
+
clearcoat ||
|
|
949
|
+
anisotropy ||
|
|
950
|
+
sheen ||
|
|
951
|
+
iridescence ||
|
|
952
|
+
transmission ||
|
|
953
|
+
mat.specularTexture !== undefined ||
|
|
954
|
+
mat.specularColorTexture !== undefined,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
823
958
|
export function validateMaterialUvSets(
|
|
824
959
|
mat: GltfMaterialIr,
|
|
825
960
|
primitive: string,
|
|
@@ -834,6 +969,16 @@ export function validateMaterialUvSets(
|
|
|
834
969
|
['emissiveTexture', mat.emissiveTexture],
|
|
835
970
|
['transmissionTexture', mat.transmissionTexture],
|
|
836
971
|
['thicknessTexture', mat.thicknessTexture],
|
|
972
|
+
['clearcoatTexture', mat.clearcoatTexture],
|
|
973
|
+
['clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture],
|
|
974
|
+
['clearcoatNormalTexture', mat.clearcoatNormalTexture],
|
|
975
|
+
['anisotropyTexture', mat.anisotropyTexture],
|
|
976
|
+
['sheenColorTexture', mat.sheenColorTexture],
|
|
977
|
+
['sheenRoughnessTexture', mat.sheenRoughnessTexture],
|
|
978
|
+
['iridescenceTexture', mat.iridescenceTexture],
|
|
979
|
+
['iridescenceThicknessTexture', mat.iridescenceThicknessTexture],
|
|
980
|
+
['specularTexture', mat.specularTexture],
|
|
981
|
+
['specularColorTexture', mat.specularColorTexture],
|
|
837
982
|
];
|
|
838
983
|
for (const [slot, rawBinding] of slots) {
|
|
839
984
|
const binding = textureInfo(rawBinding);
|
|
@@ -854,8 +999,67 @@ export function validateMaterialUvSets(
|
|
|
854
999
|
return ok(undefined);
|
|
855
1000
|
}
|
|
856
1001
|
|
|
1002
|
+
export function validateMaterialTangentInputs(
|
|
1003
|
+
mat: GltfMaterialIr,
|
|
1004
|
+
mesh: GltfMeshIr,
|
|
1005
|
+
layer = 'clearcoat',
|
|
1006
|
+
): Result<void, MaterialError> {
|
|
1007
|
+
// Anisotropy changes the base GGX lobe even when it has no texture map, so
|
|
1008
|
+
// its tangent direction is part of the scalar layer contract. Select UV 0
|
|
1009
|
+
// for the scalar-only form; a mapped form keeps the map's explicit UV set.
|
|
1010
|
+
const anisotropyDeclared =
|
|
1011
|
+
mat.anisotropyStrength !== undefined ||
|
|
1012
|
+
mat.anisotropyRotation !== undefined ||
|
|
1013
|
+
mat.anisotropyTexture !== undefined;
|
|
1014
|
+
const tangentSlot = anisotropyDeclared
|
|
1015
|
+
? { layer: 'anisotropy', info: mat.anisotropyTexture }
|
|
1016
|
+
: mat.clearcoatNormalTexture !== undefined
|
|
1017
|
+
? { layer: 'clearcoat', info: mat.clearcoatNormalTexture }
|
|
1018
|
+
: undefined;
|
|
1019
|
+
if (tangentSlot === undefined) return ok(undefined);
|
|
1020
|
+
const selected = textureInfo(tangentSlot.info);
|
|
1021
|
+
const uvSet = selected?.texCoord ?? 0;
|
|
1022
|
+
const uv = mesh[`texcoord${uvSet === 0 ? '0' : uvSet}` as keyof GltfMeshIr];
|
|
1023
|
+
const attributes = ['NORMAL', `TEXCOORD_${uvSet}`, 'TANGENT'];
|
|
1024
|
+
const fail = (reason: string): Result<void, MaterialError> =>
|
|
1025
|
+
err(
|
|
1026
|
+
createMaterialError('material-tangent-required', {
|
|
1027
|
+
code: 'material-tangent-required',
|
|
1028
|
+
material: mat.name ?? '<unnamed>',
|
|
1029
|
+
mesh: mesh.name ?? '<unnamed>',
|
|
1030
|
+
layer: tangentSlot.layer ?? layer,
|
|
1031
|
+
uv: `TEXCOORD_${uvSet}`,
|
|
1032
|
+
attributes,
|
|
1033
|
+
reason,
|
|
1034
|
+
}),
|
|
1035
|
+
);
|
|
1036
|
+
if (mesh.tangents !== undefined) {
|
|
1037
|
+
if (
|
|
1038
|
+
mesh.tangents.length !== (mesh.positions.length / 3) * 4 ||
|
|
1039
|
+
mesh.tangents.some((value) => !Number.isFinite(value))
|
|
1040
|
+
) {
|
|
1041
|
+
return fail('imported TANGENT must be finite vec4 per vertex');
|
|
1042
|
+
}
|
|
1043
|
+
return ok(undefined);
|
|
1044
|
+
}
|
|
1045
|
+
if (mesh.normals === undefined) return fail('NORMAL is required to generate tangent');
|
|
1046
|
+
if (!(uv instanceof Float32Array))
|
|
1047
|
+
return fail('the selected UV set is required to generate tangent');
|
|
1048
|
+
const generated = computeTangentVec4(mesh.positions, mesh.normals, uv, mesh.indices);
|
|
1049
|
+
if (!generated.ok) {
|
|
1050
|
+
const detail = generated.error.detail;
|
|
1051
|
+
const reason =
|
|
1052
|
+
detail !== undefined && 'reason' in detail
|
|
1053
|
+
? String(detail.reason)
|
|
1054
|
+
: 'tangent producer rejected topology';
|
|
1055
|
+
return fail(reason);
|
|
1056
|
+
}
|
|
1057
|
+
return ok(undefined);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
857
1060
|
/** Convert a parsed GltfMaterialIr into a standard-root derived MaterialAsset. */
|
|
858
1061
|
export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext): MaterialAsset {
|
|
1062
|
+
const rootContract = standardRootParameterNames(mat);
|
|
859
1063
|
const values: Record<string, NonNullable<MaterialAsset['values']>[string]> = {
|
|
860
1064
|
baseColor: mat.baseColorFactor,
|
|
861
1065
|
metallic: mat.metallicFactor,
|
|
@@ -873,6 +1077,16 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
|
|
|
873
1077
|
['emissiveTexture', mat.emissiveTexture],
|
|
874
1078
|
['transmissionTexture', mat.transmissionTexture],
|
|
875
1079
|
['thicknessTexture', mat.thicknessTexture],
|
|
1080
|
+
['clearcoatTexture', mat.clearcoatTexture],
|
|
1081
|
+
['clearcoatRoughnessTexture', mat.clearcoatRoughnessTexture],
|
|
1082
|
+
['clearcoatNormalTexture', mat.clearcoatNormalTexture],
|
|
1083
|
+
['anisotropyTexture', mat.anisotropyTexture],
|
|
1084
|
+
['sheenColorTexture', mat.sheenColorTexture],
|
|
1085
|
+
['sheenRoughnessTexture', mat.sheenRoughnessTexture],
|
|
1086
|
+
['iridescenceTexture', mat.iridescenceTexture],
|
|
1087
|
+
['iridescenceThicknessTexture', mat.iridescenceThicknessTexture],
|
|
1088
|
+
['specularTexture', mat.specularTexture],
|
|
1089
|
+
['specularColorTexture', mat.specularColorTexture],
|
|
876
1090
|
];
|
|
877
1091
|
for (const [slot, info] of textureSlots) {
|
|
878
1092
|
const value = textureValue(info, slot, ctx);
|
|
@@ -881,11 +1095,37 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
|
|
|
881
1095
|
if (mat.occlusionTexture !== undefined && values.occlusionStrength === undefined) {
|
|
882
1096
|
values.occlusionStrength = 1;
|
|
883
1097
|
}
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1098
|
+
if (rootContract.extended && rootContract.names.has('transmission')) {
|
|
1099
|
+
values.transmission = mat.transmissionFactor ?? 0;
|
|
1100
|
+
values.ior = mat.ior ?? 1.5;
|
|
1101
|
+
values.thickness = mat.thicknessFactor ?? 0;
|
|
1102
|
+
values.attenuationColor = mat.attenuationColor ?? [1, 1, 1];
|
|
1103
|
+
if (mat.attenuationDistance !== undefined) values.attenuationDistance = mat.attenuationDistance;
|
|
1104
|
+
}
|
|
1105
|
+
if (rootContract.names.has('clearcoat')) {
|
|
1106
|
+
values.clearcoat = mat.clearcoatFactor ?? 0;
|
|
1107
|
+
values.clearcoatRoughness = mat.clearcoatRoughnessFactor ?? 0;
|
|
1108
|
+
}
|
|
1109
|
+
const clearcoatNormal = textureInfo(mat.clearcoatNormalTexture) as
|
|
1110
|
+
| (GltfTextureInfoIr & { readonly scale?: number })
|
|
1111
|
+
| undefined;
|
|
1112
|
+
if (clearcoatNormal?.scale !== undefined) values.clearcoatNormalScale = clearcoatNormal.scale;
|
|
1113
|
+
if (rootContract.names.has('anisotropyStrength')) {
|
|
1114
|
+
values.anisotropyStrength = mat.anisotropyStrength ?? 0;
|
|
1115
|
+
values.anisotropyRotation = mat.anisotropyRotation ?? 0;
|
|
1116
|
+
}
|
|
1117
|
+
if (rootContract.names.has('sheenColor')) {
|
|
1118
|
+
values.sheenColor = mat.sheenColorFactor ?? [0, 0, 0];
|
|
1119
|
+
values.sheenRoughness = mat.sheenRoughnessFactor ?? 0;
|
|
1120
|
+
}
|
|
1121
|
+
if (rootContract.names.has('iridescence')) {
|
|
1122
|
+
values.iridescence = mat.iridescenceFactor ?? 0;
|
|
1123
|
+
values.iridescenceIor = mat.iridescenceIor ?? 1.3;
|
|
1124
|
+
values.iridescenceThicknessMinimum = mat.iridescenceThicknessMinimum ?? 100;
|
|
1125
|
+
values.iridescenceThicknessMaximum = mat.iridescenceThicknessMaximum ?? 400;
|
|
1126
|
+
}
|
|
1127
|
+
if (mat.specularFactor !== undefined) values.specular = mat.specularFactor;
|
|
1128
|
+
if (mat.specularColorFactor !== undefined) values.specularColor = mat.specularColorFactor;
|
|
889
1129
|
|
|
890
1130
|
const module = ctx?.skinned === true ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr';
|
|
891
1131
|
|
|
@@ -932,8 +1172,13 @@ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext
|
|
|
932
1172
|
return {
|
|
933
1173
|
kind: 'material',
|
|
934
1174
|
colorSpace: 'linear',
|
|
935
|
-
...(ctx?.standardRootGuid
|
|
1175
|
+
...(!rootContract.extended && ctx?.standardRootGuid !== undefined
|
|
1176
|
+
? { parent: ctx.standardRootGuid }
|
|
1177
|
+
: {}),
|
|
936
1178
|
passes: [pass],
|
|
1179
|
+
...(ctx?.standardRootGuid !== undefined && !rootContract.extended
|
|
1180
|
+
? {}
|
|
1181
|
+
: { parameters: standardMaterialParameters(rootContract.names) }),
|
|
937
1182
|
values,
|
|
938
1183
|
};
|
|
939
1184
|
}
|
package/src/check-extensions.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Required-extension support contains EXT_mesh_gpu_instancing,
|
|
4
4
|
// EXT_meshopt_compression, KHR_lights_punctual, KHR_texture_transform, and
|
|
5
|
-
// the KHR material transmission/IOR/volume
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// the KHR material transmission/IOR/volume/clearcoat/anisotropy/sheen/
|
|
6
|
+
// iridescence/specular extensions. The exported list is the public supported
|
|
7
|
+
// extension surface consumed by both the required-extension gate and callers
|
|
8
|
+
// that need to discover support.
|
|
8
9
|
// (feat-20260518-gltf-instancing-and-name-component plan-strategy section
|
|
9
10
|
// 2 D-1 / D-3). Any extension listed in `extensionsRequired[]` outside
|
|
10
11
|
// the supported extension set triggers `gltf-extension-unsupported` (hard fail).
|
|
@@ -18,25 +19,30 @@
|
|
|
18
19
|
// diagnostics list is the single channel (no `console.error`).
|
|
19
20
|
//
|
|
20
21
|
// Future expansion (KHR_materials_unlit, ...) extends
|
|
21
|
-
// `
|
|
22
|
+
// `EXTENSION_ALLOWLIST` in place; each addition lands under its own feat-*
|
|
22
23
|
// loop with breaking-change registry entry.
|
|
23
24
|
|
|
24
25
|
import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/** Public supported extension list used by the required/used declaration gate. */
|
|
27
28
|
export const EXTENSION_ALLOWLIST: readonly string[] = [
|
|
28
29
|
'EXT_mesh_gpu_instancing',
|
|
29
30
|
'EXT_meshopt_compression',
|
|
30
|
-
'MSFT_lod',
|
|
31
|
-
'MSFT_screencoverage',
|
|
32
|
-
];
|
|
33
|
-
const SUPPORTED_EXTENSIONS: readonly string[] = [
|
|
34
|
-
...EXTENSION_ALLOWLIST,
|
|
35
31
|
'KHR_lights_punctual',
|
|
36
32
|
'KHR_texture_transform',
|
|
37
33
|
'KHR_materials_transmission',
|
|
38
34
|
'KHR_materials_ior',
|
|
39
35
|
'KHR_materials_volume',
|
|
36
|
+
'KHR_materials_clearcoat',
|
|
37
|
+
'KHR_materials_anisotropy',
|
|
38
|
+
'KHR_materials_sheen',
|
|
39
|
+
'KHR_materials_iridescence',
|
|
40
|
+
'KHR_materials_specular',
|
|
41
|
+
];
|
|
42
|
+
const SUPPORTED_EXTENSIONS: readonly string[] = [
|
|
43
|
+
...EXTENSION_ALLOWLIST,
|
|
44
|
+
'MSFT_lod',
|
|
45
|
+
'MSFT_screencoverage',
|
|
40
46
|
];
|
|
41
47
|
|
|
42
48
|
export interface ExtensionsCheckResult {
|