@forgeax/engine-gltf 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +204 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/animation-target-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-material-samplers.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
- package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
- package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
- package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
- package/dist/__tests__/bridge.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
- package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
- package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
- package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/data-uri-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/extension-admission.unit.test.d.ts +2 -0
- package/dist/__tests__/extension-admission.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-derived-views.test-d.d.ts +2 -0
- package/dist/__tests__/gltf-error-derived-views.test-d.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-migration.test.d.ts +2 -0
- package/dist/__tests__/gltf-error-migration.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-importer.test.d.ts +2 -0
- package/dist/__tests__/gltf-importer.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/gltf-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/gltf.unit.test.d.ts +2 -0
- package/dist/__tests__/gltf.unit.test.d.ts.map +1 -0
- package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts +2 -0
- package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts.map +1 -0
- package/dist/__tests__/material-missing-uv.unit.test.d.ts +2 -0
- package/dist/__tests__/material-missing-uv.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-pack-refs.integration.test.d.ts +2 -0
- package/dist/__tests__/material-pack-refs.integration.test.d.ts.map +1 -0
- package/dist/__tests__/material-per-slot-ir.unit.test.d.ts +2 -0
- package/dist/__tests__/material-per-slot-ir.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-texture-transform.unit.test.d.ts +2 -0
- package/dist/__tests__/material-texture-transform.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-unused-uv.unit.test.d.ts +2 -0
- package/dist/__tests__/material-unused-uv.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/meshopt-decode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-falsifier.unit.test.d.ts +2 -0
- package/dist/__tests__/meshopt-falsifier.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts +2 -0
- package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts +2 -0
- package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-import.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-import.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-import.unit.test.d.ts +2 -0
- package/dist/__tests__/morph-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-decode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf-multi-uv.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf-multi-uv.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf.unit.test.d.ts.map +1 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/source-key-diagnostics.integration.test.d.ts +2 -0
- package/dist/__tests__/source-key-diagnostics.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-key-producer.integration.test.d.ts +2 -0
- package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-key.unit.test.d.ts +2 -0
- package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
- package/dist/accessor/decode-accessor.d.ts +75 -0
- package/dist/accessor/decode-accessor.d.ts.map +1 -0
- package/dist/accessor/decode-color.d.ts +15 -0
- package/dist/accessor/decode-color.d.ts.map +1 -0
- package/dist/accessor/index.d.ts +3 -0
- package/dist/accessor/index.d.ts.map +1 -0
- package/dist/bridge.d.ts +103 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/check-extensions.d.ts +29 -0
- package/dist/check-extensions.d.ts.map +1 -0
- package/dist/cli-gltf.d.ts +8 -0
- package/dist/cli-gltf.d.ts.map +1 -0
- package/dist/cli-gltf.mjs +2435 -0
- package/dist/cli-gltf.mjs.map +1 -0
- package/dist/data-uri.d.ts +6 -0
- package/dist/data-uri.d.ts.map +1 -0
- package/dist/errors.d.ts +201 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/extract-image-bytes.d.ts +42 -0
- package/dist/extract-image-bytes.d.ts.map +1 -0
- package/dist/gltf-importer.d.ts +21 -0
- package/dist/gltf-importer.d.ts.map +1 -0
- package/dist/image-color-space.d.ts +35 -0
- package/dist/image-color-space.d.ts.map +1 -0
- package/dist/importer-entry.d.ts +10 -0
- package/dist/importer-entry.d.ts.map +1 -0
- package/dist/importer-entry.mjs +3378 -0
- package/dist/importer-entry.mjs.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3673 -0
- package/dist/index.mjs.map +1 -0
- package/dist/meshopt-decode.d.ts +39 -0
- package/dist/meshopt-decode.d.ts.map +1 -0
- package/dist/node-file-entry.d.ts +14 -0
- package/dist/node-file-entry.d.ts.map +1 -0
- package/dist/node-file-entry.mjs +1946 -0
- package/dist/node-file-entry.mjs.map +1 -0
- package/dist/node-path.d.ts +14 -0
- package/dist/node-path.d.ts.map +1 -0
- package/dist/parse-animation.d.ts +70 -0
- package/dist/parse-animation.d.ts.map +1 -0
- package/dist/parse-glb-chunks.d.ts +25 -0
- package/dist/parse-glb-chunks.d.ts.map +1 -0
- package/dist/parse-glb.d.ts +3 -0
- package/dist/parse-glb.d.ts.map +1 -0
- package/dist/parse-gltf-header.d.ts +18 -0
- package/dist/parse-gltf-header.d.ts.map +1 -0
- package/dist/parse-gltf.d.ts +263 -0
- package/dist/parse-gltf.d.ts.map +1 -0
- package/dist/parse-skin.d.ts +70 -0
- package/dist/parse-skin.d.ts.map +1 -0
- package/dist/reimport-reuse-meta.d.ts +48 -0
- package/dist/reimport-reuse-meta.d.ts.map +1 -0
- package/dist/serialize-meta.d.ts +2 -0
- package/dist/serialize-meta.d.ts.map +1 -0
- package/dist/source-key.d.ts +44 -0
- package/dist/source-key.d.ts.map +1 -0
- package/dist/sub-asset-key.d.ts +12 -0
- package/dist/sub-asset-key.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +2 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/dist/transform.d.ts +37 -0
- package/dist/transform.d.ts.map +1 -0
- package/package.json +92 -0
- package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
- package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
- package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
- package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
- package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
- package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
- package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
- package/src/__tests__/bridge-test-helpers.ts +9 -0
- package/src/__tests__/bridge.unit.test.ts +1227 -0
- package/src/__tests__/cli-gltf.integration.test.ts +122 -0
- package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
- package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
- package/src/__tests__/extension-admission.unit.test.ts +19 -0
- package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
- package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
- package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
- package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
- package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
- package/src/__tests__/gltf-error-migration.test.ts +147 -0
- package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
- package/src/__tests__/gltf-importer.test.ts +12 -0
- package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
- package/src/__tests__/gltf.unit.test.ts +2975 -0
- package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
- package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
- package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
- package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
- package/src/__tests__/material-texture-transform.unit.test.ts +105 -0
- package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
- package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
- package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
- package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
- package/src/__tests__/meshopt-real-matrix.integration.test.ts +370 -0
- package/src/__tests__/morph-import.integration.test.ts +115 -0
- package/src/__tests__/morph-import.unit.test.ts +62 -0
- package/src/__tests__/parse-decode.unit.test.ts +919 -0
- package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
- package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
- package/src/__tests__/parse-gltf.unit.test.ts +133 -0
- package/src/__tests__/pick-e2e.integration.test.ts +238 -0
- package/src/__tests__/public-surface.unit.test.ts +10 -0
- package/src/__tests__/source-key-diagnostics.integration.test.ts +140 -0
- package/src/__tests__/source-key-producer.integration.test.ts +171 -0
- package/src/__tests__/source-key.unit.test.ts +159 -0
- package/src/accessor/decode-accessor.ts +309 -0
- package/src/accessor/decode-color.ts +128 -0
- package/src/accessor/index.ts +15 -0
- package/src/bridge.ts +928 -0
- package/src/check-extensions.ts +85 -0
- package/src/cli-gltf.ts +339 -0
- package/src/data-uri.ts +29 -0
- package/src/errors.ts +386 -0
- package/src/extract-image-bytes.ts +343 -0
- package/src/gltf-importer.ts +976 -0
- package/src/image-color-space.ts +104 -0
- package/src/importer-entry.ts +24 -0
- package/src/index.ts +148 -0
- package/src/meshopt-decode.ts +200 -0
- package/src/node-file-entry.ts +98 -0
- package/src/node-path.ts +41 -0
- package/src/parse-animation.ts +286 -0
- package/src/parse-glb-chunks.ts +167 -0
- package/src/parse-glb.ts +16 -0
- package/src/parse-gltf-header.ts +58 -0
- package/src/parse-gltf.ts +1731 -0
- package/src/parse-skin.ts +238 -0
- package/src/reimport-reuse-meta.ts +134 -0
- package/src/serialize-meta.ts +24 -0
- package/src/source-key.ts +106 -0
- package/src/sub-asset-key.ts +61 -0
- package/src/to-asset-pack.ts +8 -0
- package/src/transform.ts +117 -0
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
// bridge.ts - public gltfDocToSceneAsset SSOT (M3 w9 / feat-20260518).
|
|
2
|
+
//
|
|
3
|
+
// Excises the previously inline 80-line implementation in
|
|
4
|
+
// apps/hello/gltf/src/main.ts. AI users get a single import path:
|
|
5
|
+
//
|
|
6
|
+
// ```ts
|
|
7
|
+
// import { gltfDocToSceneAsset, toMaterialAsset } from '@forgeax/engine-gltf';
|
|
8
|
+
// const scene = gltfDocToSceneAsset(doc, { meshHandles, materialHandles });
|
|
9
|
+
// ```
|
|
10
|
+
//
|
|
11
|
+
// Pure function: no fs, no fetch, no throw. Walks the default scene's
|
|
12
|
+
// node graph depth-first, accumulating world transforms for children (B3).
|
|
13
|
+
// Handles for mesh / material come from caller-supplied Map lookups so the
|
|
14
|
+
// bridge stays runtime-agnostic.
|
|
15
|
+
//
|
|
16
|
+
// feat-20260608-mesh-multi-section-primitive-multi-material-slot M3 / w13+w15:
|
|
17
|
+
// (B1) N prim per glTF mesh merged into 1 entity with MeshRenderer.materials[]
|
|
18
|
+
// (B2) visit recursively walks ir.children
|
|
19
|
+
// (B3) child world pos accumulates parent transform
|
|
20
|
+
// (B6) camera detection via GltfNodeIr.camera field (not legacy nodes[1] heuristic)
|
|
21
|
+
|
|
22
|
+
import { packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
|
|
23
|
+
import type { Mat4 } from '@forgeax/engine-math';
|
|
24
|
+
import { box3, mat4, quat, vec3 } from '@forgeax/engine-math';
|
|
25
|
+
import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
|
|
26
|
+
import type {
|
|
27
|
+
AssetGuid,
|
|
28
|
+
Handle,
|
|
29
|
+
LocalEntityId,
|
|
30
|
+
MaterialAsset,
|
|
31
|
+
MaterialError,
|
|
32
|
+
MaterialTextureValue,
|
|
33
|
+
MeshAsset,
|
|
34
|
+
MeshMaterialSlot,
|
|
35
|
+
MorphTarget,
|
|
36
|
+
RenderQueue,
|
|
37
|
+
Result,
|
|
38
|
+
SceneAsset,
|
|
39
|
+
SceneEntity,
|
|
40
|
+
Submesh,
|
|
41
|
+
VertexAttributeMap,
|
|
42
|
+
} from '@forgeax/engine-types';
|
|
43
|
+
import { createMaterialError, err, type GltfError, gltfErr, ok } from './errors.js';
|
|
44
|
+
import type {
|
|
45
|
+
GltfDoc,
|
|
46
|
+
GltfMaterialIr,
|
|
47
|
+
GltfMeshIr,
|
|
48
|
+
GltfNodeIr,
|
|
49
|
+
GltfPunctualLightIr,
|
|
50
|
+
GltfTextureInfoIr,
|
|
51
|
+
} from './parse-gltf.js';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert one or more parsed `GltfMeshIr` primitives sharing the same glTF
|
|
55
|
+
* mesh-index into a single runtime `MeshAsset` POD. Each primitive becomes one
|
|
56
|
+
* `Submesh` row; per-primitive vertex data is concatenated into one big
|
|
57
|
+
* interleaved vertex buffer (12 floats / vertex for unskinned meshes; 18 floats
|
|
58
|
+
* / vertex when any primitive carries `joints0` + `weights0`), and per-primitive
|
|
59
|
+
* index buffers are concatenated into one big index buffer. Each submesh's
|
|
60
|
+
* indices are biased by the running vertex offset so they reference vertices
|
|
61
|
+
* within the merged buffer.
|
|
62
|
+
*
|
|
63
|
+
* Per-MeshAsset stride decision (D-2): if any primitive contains skin
|
|
64
|
+
* attributes, the entire MeshAsset is promoted to 18-float stride and
|
|
65
|
+
* unskinned primitives' skin slots are zero-filled (joints {0,0,0,0} +
|
|
66
|
+
* weights {0,0,0,0}). This avoids splitting one glTF mesh into multiple
|
|
67
|
+
* MeshAsset rows when a model mixes skinned and unskinned primitives.
|
|
68
|
+
*
|
|
69
|
+
* AI users get a 1-to-1 mapping with `MeshRenderer.materials[]`: the i-th
|
|
70
|
+
* submesh is the i-th primitive of the i-th material slot.
|
|
71
|
+
*
|
|
72
|
+
* Pure function (no fs / fetch / registry). Missing attributes fall back to
|
|
73
|
+
* identity defaults per primitive: normal -> +Y, uv -> 0, tangent -> +X with
|
|
74
|
+
* w=1.
|
|
75
|
+
*
|
|
76
|
+
* Invalid source data is returned as a typed `GltfError`; callers must branch
|
|
77
|
+
* on `Result.ok` before publishing a MeshAsset. Caller (gltfImporter / smoke
|
|
78
|
+
* driver) is responsible for grouping by `meshIr.meshIndex` before calling.
|
|
79
|
+
*/
|
|
80
|
+
export function meshIrToMeshAsset(
|
|
81
|
+
prims: readonly GltfMeshIr[],
|
|
82
|
+
materials: {
|
|
83
|
+
readonly guidByIndex?: ReadonlyMap<number, string>;
|
|
84
|
+
readonly nameByIndex?: ReadonlyMap<number, string>;
|
|
85
|
+
readonly sourceKeyByIndex?: ReadonlyMap<number, string>;
|
|
86
|
+
} = {},
|
|
87
|
+
): Result<MeshAsset, GltfError> {
|
|
88
|
+
if (prims.length === 0) {
|
|
89
|
+
return err(
|
|
90
|
+
gltfErr('gltf-mesh-bridge-invalid', {
|
|
91
|
+
reason: 'empty-input',
|
|
92
|
+
primitiveCount: 0,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
let totalVertexCount = 0;
|
|
97
|
+
let totalIndexCount = 0;
|
|
98
|
+
let hasAnySkin = false;
|
|
99
|
+
const hasAnyColor = prims.some((p) => p.colors0 !== undefined);
|
|
100
|
+
let hasAnyIndices = false;
|
|
101
|
+
// feat-20260629-multi-uv-set-support m1-w3: derive the widest canonical UV
|
|
102
|
+
// key list across all primitives so the geometry projection accommodates it.
|
|
103
|
+
let widestUvIndex = 0;
|
|
104
|
+
const morphTargetCount = prims[0]?.morphTargets?.length ?? 0;
|
|
105
|
+
for (const p of prims) {
|
|
106
|
+
const primVc = p.positions.length / 3;
|
|
107
|
+
totalVertexCount += primVc;
|
|
108
|
+
if (p.indices !== undefined) {
|
|
109
|
+
totalIndexCount += p.indices.length;
|
|
110
|
+
hasAnyIndices = true;
|
|
111
|
+
} else {
|
|
112
|
+
totalIndexCount += primVc;
|
|
113
|
+
}
|
|
114
|
+
if (p.joints0 !== undefined && p.weights0 !== undefined) hasAnySkin = true;
|
|
115
|
+
if ((p.morphTargets?.length ?? 0) !== morphTargetCount) {
|
|
116
|
+
return err(
|
|
117
|
+
gltfErr('gltf-mesh-bridge-invalid', {
|
|
118
|
+
meshIndex: p.meshIndex,
|
|
119
|
+
primitiveIndex: prims.indexOf(p),
|
|
120
|
+
reason: 'morph-count-mismatch',
|
|
121
|
+
expectedTargetCount: morphTargetCount,
|
|
122
|
+
actualTargetCount: p.morphTargets?.length ?? 0,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// Count present UV sets in this primitive.
|
|
127
|
+
for (let k = 7; k >= 1; k--) {
|
|
128
|
+
const key = `texcoord${k}` as keyof GltfMeshIr;
|
|
129
|
+
if (p[key] !== undefined) {
|
|
130
|
+
widestUvIndex = Math.max(widestUvIndex, k);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const uvKeys = Array.from({ length: widestUvIndex }, (_, index) => `uv${index + 1}`);
|
|
137
|
+
|
|
138
|
+
const positionsCat = new Float32Array(totalVertexCount * 3);
|
|
139
|
+
const normalsCat = new Float32Array(totalVertexCount * 3);
|
|
140
|
+
const uvsCat = new Float32Array(totalVertexCount * 2);
|
|
141
|
+
const tangentsCat = new Float32Array(totalVertexCount * 4);
|
|
142
|
+
const colorsCat = hasAnyColor ? new Float32Array(totalVertexCount * 4) : undefined;
|
|
143
|
+
const morphTargets: MorphTarget[] = Array.from({ length: morphTargetCount }, (_, targetIndex) => {
|
|
144
|
+
const source = prims[0]?.morphTargets?.[targetIndex];
|
|
145
|
+
return {
|
|
146
|
+
...(source?.position === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { position: new Float32Array(totalVertexCount * 3) }),
|
|
149
|
+
...(source?.normal === undefined ? {} : { normal: new Float32Array(totalVertexCount * 3) }),
|
|
150
|
+
...(source?.tangent === undefined ? {} : { tangent: new Float32Array(totalVertexCount * 4) }),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
// feat-20260629-multi-uv-set-support m1-w3: per-UV-set standalone typed arrays
|
|
154
|
+
// for MeshAsset.attributes (uv1..uvK). Allocated only for imported sets.
|
|
155
|
+
const uvCats = uvKeys.map(() => new Float32Array(totalVertexCount * 2));
|
|
156
|
+
// D-2 / w8: when promoted to a skinned projection, retain canonical
|
|
157
|
+
// standalone arrays; the geometry packer owns the interleaved GPU bytes.
|
|
158
|
+
const skinIndicesCat = hasAnySkin ? new Uint16Array(totalVertexCount * 4) : undefined;
|
|
159
|
+
const skinWeightsCat = hasAnySkin ? new Float32Array(totalVertexCount * 4) : undefined;
|
|
160
|
+
// Multi-primitive merge biases each primitive's index range by the running
|
|
161
|
+
// vertex offset, so the merged max index is `totalVertexCount - 1`. When a
|
|
162
|
+
// glTF mesh has > 65535 vertices across all primitives (common for
|
|
163
|
+
// moderately complex scenes like Sponza at ~192k verts), Uint16 overflows
|
|
164
|
+
// and we must widen to Uint32. WebGPU's setIndexBuffer takes 'uint16' or
|
|
165
|
+
// 'uint32' (auto-selected by the runtime via TypedArray.constructor); both
|
|
166
|
+
// are supported by every WebGPU device.
|
|
167
|
+
// bug-20260612 hello-skin visual layered gate: when no primitive carries
|
|
168
|
+
// indices (glTF non-indexed geometry per spec — primitive.indices optional),
|
|
169
|
+
// route MeshAsset.indices to undefined so the runtime takes the
|
|
170
|
+
// pass.draw(vertexCount) non-indexed path. Mixed bags (some prims indexed,
|
|
171
|
+
// some not) synthesize identity indices for the non-indexed prims so the
|
|
172
|
+
// merged MeshAsset stays single-index-buffer. hasAnyIndices === false
|
|
173
|
+
// skips the index buffer allocation entirely.
|
|
174
|
+
const useUint32 = totalVertexCount > 0xffff;
|
|
175
|
+
const indices: Uint16Array | Uint32Array | undefined = !hasAnyIndices
|
|
176
|
+
? undefined
|
|
177
|
+
: useUint32
|
|
178
|
+
? new Uint32Array(totalIndexCount)
|
|
179
|
+
: new Uint16Array(totalIndexCount);
|
|
180
|
+
const submeshes: Submesh[] = [];
|
|
181
|
+
const materialSlots: MeshMaterialSlot[] = [];
|
|
182
|
+
const slotByMaterial = new Map<number | null, number>();
|
|
183
|
+
const usedNames = new Set<string>();
|
|
184
|
+
const uniqueSlotName = (base: string): string => {
|
|
185
|
+
let candidate = base.trim() || 'Material';
|
|
186
|
+
let suffix = 2;
|
|
187
|
+
while (usedNames.has(candidate)) candidate = `${base}_${suffix++}`;
|
|
188
|
+
usedNames.add(candidate);
|
|
189
|
+
return candidate;
|
|
190
|
+
};
|
|
191
|
+
const slotFor = (materialIndex: number | null): number => {
|
|
192
|
+
const existing = slotByMaterial.get(materialIndex);
|
|
193
|
+
if (existing !== undefined) return existing;
|
|
194
|
+
const slotIndex = materialSlots.length;
|
|
195
|
+
const guid = materialIndex === null ? undefined : materials.guidByIndex?.get(materialIndex);
|
|
196
|
+
const parsed = guid === undefined ? undefined : AssetGuidCodec.parse(guid);
|
|
197
|
+
materialSlots.push({
|
|
198
|
+
slotName: uniqueSlotName(
|
|
199
|
+
materialIndex === null
|
|
200
|
+
? 'Default'
|
|
201
|
+
: (materials.nameByIndex?.get(materialIndex) ?? `Material_${materialIndex}`),
|
|
202
|
+
),
|
|
203
|
+
sourceKey:
|
|
204
|
+
materialIndex === null
|
|
205
|
+
? 'gltf:default'
|
|
206
|
+
: (materials.sourceKeyByIndex?.get(materialIndex) ?? `gltf:material:${materialIndex}`),
|
|
207
|
+
...(parsed?.ok ? { defaultMaterial: parsed.value } : {}),
|
|
208
|
+
});
|
|
209
|
+
slotByMaterial.set(materialIndex, slotIndex);
|
|
210
|
+
return slotIndex;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
let vertexCursor = 0;
|
|
214
|
+
let indexCursor = 0;
|
|
215
|
+
for (const mesh of prims) {
|
|
216
|
+
const materialSlot = slotFor(mesh.materialIndex);
|
|
217
|
+
const primVertexCount = mesh.positions.length / 3;
|
|
218
|
+
const primIndexCount = mesh.indices === undefined ? 0 : mesh.indices.length;
|
|
219
|
+
if (mesh.colors0 !== undefined && mesh.colors0.length !== primVertexCount * 4) {
|
|
220
|
+
return err(
|
|
221
|
+
gltfErr('gltf-mesh-bridge-invalid', {
|
|
222
|
+
meshIndex: mesh.meshIndex,
|
|
223
|
+
primitiveIndex: prims.indexOf(mesh),
|
|
224
|
+
reason: 'color-cardinality',
|
|
225
|
+
semantic: 'COLOR_0',
|
|
226
|
+
vertexCount: primVertexCount,
|
|
227
|
+
expectedLength: primVertexCount * 4,
|
|
228
|
+
actualLength: mesh.colors0.length,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
for (let i = 0; i < primVertexCount; i++) {
|
|
233
|
+
const p = i * 3;
|
|
234
|
+
positionsCat[(vertexCursor + i) * 3 + 0] = mesh.positions[p + 0] as number;
|
|
235
|
+
positionsCat[(vertexCursor + i) * 3 + 1] = mesh.positions[p + 1] as number;
|
|
236
|
+
positionsCat[(vertexCursor + i) * 3 + 2] = mesh.positions[p + 2] as number;
|
|
237
|
+
if (mesh.normals !== undefined) {
|
|
238
|
+
const n = i * 3;
|
|
239
|
+
normalsCat[(vertexCursor + i) * 3 + 0] = mesh.normals[n + 0] as number;
|
|
240
|
+
normalsCat[(vertexCursor + i) * 3 + 1] = mesh.normals[n + 1] as number;
|
|
241
|
+
normalsCat[(vertexCursor + i) * 3 + 2] = mesh.normals[n + 2] as number;
|
|
242
|
+
} else {
|
|
243
|
+
normalsCat[(vertexCursor + i) * 3 + 1] = 1;
|
|
244
|
+
}
|
|
245
|
+
if (mesh.texcoord0 !== undefined) {
|
|
246
|
+
const t = i * 2;
|
|
247
|
+
uvsCat[(vertexCursor + i) * 2 + 0] = mesh.texcoord0[t + 0] as number;
|
|
248
|
+
uvsCat[(vertexCursor + i) * 2 + 1] = mesh.texcoord0[t + 1] as number;
|
|
249
|
+
}
|
|
250
|
+
if (mesh.tangents !== undefined) {
|
|
251
|
+
const g = i * 4;
|
|
252
|
+
tangentsCat[(vertexCursor + i) * 4 + 0] = mesh.tangents[g + 0] as number;
|
|
253
|
+
tangentsCat[(vertexCursor + i) * 4 + 1] = mesh.tangents[g + 1] as number;
|
|
254
|
+
tangentsCat[(vertexCursor + i) * 4 + 2] = mesh.tangents[g + 2] as number;
|
|
255
|
+
tangentsCat[(vertexCursor + i) * 4 + 3] = mesh.tangents[g + 3] as number;
|
|
256
|
+
} else {
|
|
257
|
+
tangentsCat[(vertexCursor + i) * 4 + 0] = 1;
|
|
258
|
+
tangentsCat[(vertexCursor + i) * 4 + 3] = 1;
|
|
259
|
+
}
|
|
260
|
+
if (colorsCat !== undefined) {
|
|
261
|
+
const colorDst = (vertexCursor + i) * 4;
|
|
262
|
+
const colorSrc = i * 4;
|
|
263
|
+
if (mesh.colors0 === undefined) {
|
|
264
|
+
colorsCat[colorDst + 0] = 1;
|
|
265
|
+
colorsCat[colorDst + 1] = 1;
|
|
266
|
+
colorsCat[colorDst + 2] = 1;
|
|
267
|
+
colorsCat[colorDst + 3] = 1;
|
|
268
|
+
} else {
|
|
269
|
+
colorsCat[colorDst + 0] = mesh.colors0[colorSrc + 0] as number;
|
|
270
|
+
colorsCat[colorDst + 1] = mesh.colors0[colorSrc + 1] as number;
|
|
271
|
+
colorsCat[colorDst + 2] = mesh.colors0[colorSrc + 2] as number;
|
|
272
|
+
colorsCat[colorDst + 3] = mesh.colors0[colorSrc + 3] as number;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (let targetIndex = 0; targetIndex < morphTargetCount; targetIndex++) {
|
|
276
|
+
const source = mesh.morphTargets?.[targetIndex];
|
|
277
|
+
const target = morphTargets[targetIndex] as MorphTarget;
|
|
278
|
+
const vertex = vertexCursor + i;
|
|
279
|
+
if (source?.position !== undefined && target.position !== undefined) {
|
|
280
|
+
target.position.set(source.position.subarray(i * 3, i * 3 + 3), vertex * 3);
|
|
281
|
+
}
|
|
282
|
+
if (source?.normal !== undefined && target.normal !== undefined) {
|
|
283
|
+
target.normal.set(source.normal.subarray(i * 3, i * 3 + 3), vertex * 3);
|
|
284
|
+
}
|
|
285
|
+
if (source?.tangent !== undefined && target.tangent !== undefined) {
|
|
286
|
+
target.tangent.set(source.tangent.subarray(i * 4, i * 4 + 4), vertex * 4);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// D-2 / w8: when the MeshAsset is promoted to a skinned projection,
|
|
290
|
+
// retain both canonical standalone arrays. The geometry packer writes
|
|
291
|
+
// their typed values into the canonical interleaved byte layout once.
|
|
292
|
+
if (hasAnySkin && skinIndicesCat !== undefined && skinWeightsCat !== undefined) {
|
|
293
|
+
const skinDst = (vertexCursor + i) * 4;
|
|
294
|
+
if (mesh.joints0 !== undefined && mesh.weights0 !== undefined) {
|
|
295
|
+
const j = i * 4;
|
|
296
|
+
const j0 = mesh.joints0[j + 0] as number;
|
|
297
|
+
const j1 = mesh.joints0[j + 1] as number;
|
|
298
|
+
const j2 = mesh.joints0[j + 2] as number;
|
|
299
|
+
const j3 = mesh.joints0[j + 3] as number;
|
|
300
|
+
skinIndicesCat[skinDst + 0] = j0;
|
|
301
|
+
skinIndicesCat[skinDst + 1] = j1;
|
|
302
|
+
skinIndicesCat[skinDst + 2] = j2;
|
|
303
|
+
skinIndicesCat[skinDst + 3] = j3;
|
|
304
|
+
const w0 = mesh.weights0[j + 0] as number;
|
|
305
|
+
const w1 = mesh.weights0[j + 1] as number;
|
|
306
|
+
const w2 = mesh.weights0[j + 2] as number;
|
|
307
|
+
const w3 = mesh.weights0[j + 3] as number;
|
|
308
|
+
skinWeightsCat[skinDst + 0] = w0;
|
|
309
|
+
skinWeightsCat[skinDst + 1] = w1;
|
|
310
|
+
skinWeightsCat[skinDst + 2] = w2;
|
|
311
|
+
skinWeightsCat[skinDst + 3] = w3;
|
|
312
|
+
}
|
|
313
|
+
// else: unskinned primitive in a mixed MeshAsset; typed arrays remain
|
|
314
|
+
// zero-filled, which is the canonical fallback.
|
|
315
|
+
}
|
|
316
|
+
// feat-20260629-multi-uv-set-support m1-w3: write uv1..uvK after skin data.
|
|
317
|
+
// Canonical interleaved order: position/normal/uv/tangent/skinIndex/skinWeight/uv1..uv7.
|
|
318
|
+
// UV1 starts at offset UV1_OFFSET (12 for unskinned, 18 for skinned) in float slots.
|
|
319
|
+
// Each additional UV set 2F. Missing texcoordK → zero-fill (plan-strategy M1).
|
|
320
|
+
for (let k = 1; k <= uvKeys.length; k++) {
|
|
321
|
+
const uvKey = `texcoord${k}` as keyof GltfMeshIr;
|
|
322
|
+
const catIdx = k - 1;
|
|
323
|
+
const cat = uvCats[catIdx] as Float32Array;
|
|
324
|
+
const catDst = (vertexCursor + i) * 2;
|
|
325
|
+
const srcArr = mesh[uvKey] as Float32Array | undefined;
|
|
326
|
+
if (srcArr !== undefined) {
|
|
327
|
+
const t = i * 2;
|
|
328
|
+
cat[catDst + 0] = srcArr[t + 0] as number;
|
|
329
|
+
cat[catDst + 1] = srcArr[t + 1] as number;
|
|
330
|
+
}
|
|
331
|
+
// else: zero-fill (implicit — Float32Array defaults to 0)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Bias each submesh's indices by the running vertex offset so they
|
|
335
|
+
// reference into the merged vertex buffer rather than the per-primitive
|
|
336
|
+
// local 0-base. When the merged MeshAsset is non-indexed (hasAnyIndices
|
|
337
|
+
// === false → `indices` is undefined here), skip the bias loop and
|
|
338
|
+
// emit a vertex-only submesh (indexCount=0; runtime dispatches via
|
|
339
|
+
// `pass.draw(vertexCount)`). When mixed (some prims indexed, this one
|
|
340
|
+
// not), synthesize identity indices [vertexCursor..+primVertexCount-1]
|
|
341
|
+
// so the merged single-index-buffer contract holds.
|
|
342
|
+
if (indices !== undefined) {
|
|
343
|
+
if (mesh.indices !== undefined) {
|
|
344
|
+
for (let i = 0; i < primIndexCount; i++) {
|
|
345
|
+
const src = mesh.indices[i] as number;
|
|
346
|
+
indices[indexCursor + i] = src + vertexCursor;
|
|
347
|
+
}
|
|
348
|
+
submeshes.push({
|
|
349
|
+
indexOffset: indexCursor,
|
|
350
|
+
indexCount: primIndexCount,
|
|
351
|
+
vertexCount: primVertexCount,
|
|
352
|
+
topology: 'triangle-list',
|
|
353
|
+
materialSlot,
|
|
354
|
+
});
|
|
355
|
+
indexCursor += primIndexCount;
|
|
356
|
+
} else {
|
|
357
|
+
// mixed bag: synthesize identity indices for this non-indexed prim
|
|
358
|
+
for (let i = 0; i < primVertexCount; i++) {
|
|
359
|
+
indices[indexCursor + i] = vertexCursor + i;
|
|
360
|
+
}
|
|
361
|
+
submeshes.push({
|
|
362
|
+
indexOffset: indexCursor,
|
|
363
|
+
indexCount: primVertexCount,
|
|
364
|
+
vertexCount: primVertexCount,
|
|
365
|
+
topology: 'triangle-list',
|
|
366
|
+
materialSlot,
|
|
367
|
+
});
|
|
368
|
+
indexCursor += primVertexCount;
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
// pure non-indexed mesh: indexCount=0, vertexCount carries draw count.
|
|
372
|
+
submeshes.push({
|
|
373
|
+
indexOffset: 0,
|
|
374
|
+
indexCount: 0,
|
|
375
|
+
vertexCount: primVertexCount,
|
|
376
|
+
topology: 'triangle-list',
|
|
377
|
+
materialSlot,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
vertexCursor += primVertexCount;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const attributes: VertexAttributeMap = {
|
|
384
|
+
position: positionsCat,
|
|
385
|
+
normal: normalsCat,
|
|
386
|
+
uv: uvsCat,
|
|
387
|
+
tangent: tangentsCat,
|
|
388
|
+
...(colorsCat === undefined ? {} : { color: colorsCat }),
|
|
389
|
+
...(skinIndicesCat === undefined ? {} : { skinIndex: skinIndicesCat }),
|
|
390
|
+
...(skinWeightsCat === undefined ? {} : { skinWeight: skinWeightsCat }),
|
|
391
|
+
...Object.fromEntries(uvCats.map((cat, idx) => [`uv${idx + 1}`, cat])),
|
|
392
|
+
};
|
|
393
|
+
const packed = packInterleavedVertexAttributes(attributes, totalVertexCount);
|
|
394
|
+
if (!packed.ok) {
|
|
395
|
+
return err(
|
|
396
|
+
gltfErr('gltf-mesh-bridge-invalid', {
|
|
397
|
+
meshIndex: prims[0]?.meshIndex ?? -1,
|
|
398
|
+
reason: 'layout-invalid',
|
|
399
|
+
cause: packed.error.detail,
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return ok({
|
|
405
|
+
kind: 'mesh',
|
|
406
|
+
vertices: packed.value.vertices,
|
|
407
|
+
...(indices === undefined ? {} : { indices }),
|
|
408
|
+
submeshes,
|
|
409
|
+
materialSlots,
|
|
410
|
+
aabb: box3.fromPositions(box3.create(), positionsCat),
|
|
411
|
+
attributes,
|
|
412
|
+
...(morphTargetCount === 0 ? {} : { morphTargets }),
|
|
413
|
+
...(prims[0]?.morphWeights === undefined
|
|
414
|
+
? {}
|
|
415
|
+
: { morphWeights: new Float32Array(prims[0].morphWeights) }),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface GltfBridgeContext {
|
|
420
|
+
/** glTF mesh index -> registry MeshAsset handle (multi-submesh). */
|
|
421
|
+
readonly meshHandles: ReadonlyMap<number, Handle<'MeshAsset', 'shared'>>;
|
|
422
|
+
/** glTF material index -> registry MaterialAsset handle. */
|
|
423
|
+
readonly materialHandles: ReadonlyMap<number, Handle<'MaterialAsset', 'shared'>>;
|
|
424
|
+
/**
|
|
425
|
+
* glTF skin index -> SkeletonAsset GUID (string form). When a GltfNodeIr carries
|
|
426
|
+
* a skin reference, the bridge emits `Skin: { skeleton: <guid-string> }` on
|
|
427
|
+
* that node's entity; AssetRegistry._resolveSceneGuids resolves the GUID to
|
|
428
|
+
* a runtime Handle at instantiate time (same protocol as MeshFilter and
|
|
429
|
+
* MeshRenderer.materials[]). Optional — skinless glTFs pass an empty Map
|
|
430
|
+
* (or omit the field) and the bridge does not emit Skin.
|
|
431
|
+
*
|
|
432
|
+
* tweak-20260611 M6 / D-7: emitting Skin from the bridge means the standard
|
|
433
|
+
* loadByGuid<SceneAsset> + instantiate path Just Works for skinned glTF;
|
|
434
|
+
* demos no longer need to runtime-parseGlb + post-load patch the SceneAsset.
|
|
435
|
+
* postSpawnResolveJoints (called from AssetRegistry.instantiate) walks the
|
|
436
|
+
* matching SkinAsset.jointPaths against the spawn subtree to fill Skin.joints[].
|
|
437
|
+
*/
|
|
438
|
+
readonly skeletonGuidBySkinIndex?: ReadonlyMap<number, string>;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
interface MutableSceneEntity {
|
|
442
|
+
localId: LocalEntityId;
|
|
443
|
+
components: Record<string, Record<string, unknown>>;
|
|
444
|
+
/** Index into the external `nodes` array, set after push for ChildOf wiring. */
|
|
445
|
+
localIdx: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function lightDirection(currentWorld: Mat4): readonly [number, number, number] {
|
|
449
|
+
const direction = mat4.getForward(vec3.create(), currentWorld);
|
|
450
|
+
return [direction[0] ?? 0, direction[1] ?? 0, direction[2] ?? -1];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function lightComponent(
|
|
454
|
+
light: GltfPunctualLightIr,
|
|
455
|
+
direction: readonly [number, number, number],
|
|
456
|
+
): Record<string, unknown> {
|
|
457
|
+
const range = light.range ?? Number.POSITIVE_INFINITY;
|
|
458
|
+
if (light.type === 'directional') {
|
|
459
|
+
return { direction, color: light.color, intensity: light.intensity };
|
|
460
|
+
}
|
|
461
|
+
if (light.type === 'point') {
|
|
462
|
+
return { color: light.color, intensity: light.intensity, range };
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
direction,
|
|
466
|
+
color: light.color,
|
|
467
|
+
intensity: light.intensity,
|
|
468
|
+
range,
|
|
469
|
+
innerConeDeg: ((light.spot?.innerConeAngle ?? 0) * 180) / Math.PI,
|
|
470
|
+
outerConeDeg: ((light.spot?.outerConeAngle ?? Math.PI / 4) * 180) / Math.PI,
|
|
471
|
+
castShadow: false,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Convert a local TRS (translation, rotation quat, scale) into a Mat4.
|
|
477
|
+
* Uses @forgeax/engine-math out-param style. `out` is mutated in place.
|
|
478
|
+
*/
|
|
479
|
+
function composeMat4(
|
|
480
|
+
out: Mat4,
|
|
481
|
+
tx: number,
|
|
482
|
+
ty: number,
|
|
483
|
+
tz: number,
|
|
484
|
+
qx: number,
|
|
485
|
+
qy: number,
|
|
486
|
+
qz: number,
|
|
487
|
+
qw: number,
|
|
488
|
+
sx: number,
|
|
489
|
+
sy: number,
|
|
490
|
+
sz: number,
|
|
491
|
+
): void {
|
|
492
|
+
const t = vec3.create(tx, ty, tz);
|
|
493
|
+
const r = quat.create();
|
|
494
|
+
r[0] = qx;
|
|
495
|
+
r[1] = qy;
|
|
496
|
+
r[2] = qz;
|
|
497
|
+
r[3] = qw;
|
|
498
|
+
const s = vec3.create(sx, sy, sz);
|
|
499
|
+
mat4.compose(out, t, r, s);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Convert a parsed GltfDoc into a SceneAsset POD. Caller supplies registry
|
|
504
|
+
* handles via `ctx`; the bridge does no registration of its own.
|
|
505
|
+
*
|
|
506
|
+
* Visits the default scene's nodes depth-first, accumulating world-space
|
|
507
|
+
* transforms from parent to child (B3). Assigns sequential LocalEntityId in
|
|
508
|
+
* document order for reimport-stable identity. Camera nodes are detected by
|
|
509
|
+
* GltfNodeIr.camera (B6 fix).
|
|
510
|
+
*
|
|
511
|
+
* B1: each glTF mesh's N primitives are merged into a single entity.
|
|
512
|
+
* The caller provides `ctx.meshHandles` keyed by glTF mesh index, where each
|
|
513
|
+
* handle points to a MeshAsset whose `submeshes` already span all primitives.
|
|
514
|
+
* `ctx.materialHandles` is keyed by glTF material index; the bridge assembles a
|
|
515
|
+
* `MeshRenderer.materials[]` array from the primitives' materialIndex values
|
|
516
|
+
* in the order they appear in `doc.meshes`.
|
|
517
|
+
*
|
|
518
|
+
* B2: visit recursively walks `ir.children`.
|
|
519
|
+
*
|
|
520
|
+
* D-2 (empty container): transform-only nodes (no mesh, no camera) produce
|
|
521
|
+
* an entity with just Transform + optionally a Name component, preserving the
|
|
522
|
+
* hierarchy for animation/picking hooks.
|
|
523
|
+
*/
|
|
524
|
+
export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): SceneAsset {
|
|
525
|
+
const sceneIr = doc.scenes[doc.defaultSceneIndex];
|
|
526
|
+
const resultNodes: MutableSceneEntity[] = [];
|
|
527
|
+
if (sceneIr === undefined) return { kind: 'scene', entities: [] };
|
|
528
|
+
const importedLights = doc.lights ?? doc.extensions?.KHR_lights_punctual?.lights ?? [];
|
|
529
|
+
const animationTargetIds = new Map<number, string>();
|
|
530
|
+
for (const clip of doc.animationClips) {
|
|
531
|
+
for (const channel of clip.channels) {
|
|
532
|
+
animationTargetIds.set(channel.targetNodeIndex, channel.targetId);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Per-node world matrix accumulator (B3 fix).
|
|
537
|
+
const parentWorld = mat4.create();
|
|
538
|
+
mat4.identity(parentWorld);
|
|
539
|
+
const currentWorld = mat4.create();
|
|
540
|
+
const localMat = mat4.create();
|
|
541
|
+
|
|
542
|
+
// bug-20260613: SceneAsset entities also emit ChildOf when a glTF node has a
|
|
543
|
+
// parent (line 547 below). Runtime propagateTransforms then derives
|
|
544
|
+
// Transform.world via `parent.world * compose(child.local TRS)`. If we
|
|
545
|
+
// wrote the *world* TRS into Transform here, every child node would get
|
|
546
|
+
// baked twice -- once at importer time and again at propagate time --
|
|
547
|
+
// collapsing the skin so vertices fly to (parent.world)^2 space and the
|
|
548
|
+
// mesh appears as a scrambled silhouette. Mirror GltfNodeIr.transform's local
|
|
549
|
+
// TRS verbatim; propagateTransforms is the single accumulation path.
|
|
550
|
+
const pushLocalTransform = (
|
|
551
|
+
transform: import('./transform.js').DecomposedTransform,
|
|
552
|
+
): Record<string, unknown> => ({
|
|
553
|
+
pos: [
|
|
554
|
+
transform.translation[0] ?? 0,
|
|
555
|
+
transform.translation[1] ?? 0,
|
|
556
|
+
transform.translation[2] ?? 0,
|
|
557
|
+
],
|
|
558
|
+
// Quaternion component order [x, y, z, w] (glTF-aligned; E6).
|
|
559
|
+
quat: [
|
|
560
|
+
transform.rotation[0] ?? 0,
|
|
561
|
+
transform.rotation[1] ?? 0,
|
|
562
|
+
transform.rotation[2] ?? 0,
|
|
563
|
+
transform.rotation[3] ?? 1,
|
|
564
|
+
],
|
|
565
|
+
scale: [transform.scale[0] ?? 1, transform.scale[1] ?? 1, transform.scale[2] ?? 1],
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const visit = (gltfNodeIdx: number, parentLocalIdx: number | null): void => {
|
|
569
|
+
const ir = doc.nodes[gltfNodeIdx];
|
|
570
|
+
if (ir === undefined) return;
|
|
571
|
+
|
|
572
|
+
// Compute local transform matrix
|
|
573
|
+
composeMat4(
|
|
574
|
+
localMat,
|
|
575
|
+
ir.transform.translation[0] ?? 0,
|
|
576
|
+
ir.transform.translation[1] ?? 0,
|
|
577
|
+
ir.transform.translation[2] ?? 0,
|
|
578
|
+
ir.transform.rotation[0] ?? 0,
|
|
579
|
+
ir.transform.rotation[1] ?? 0,
|
|
580
|
+
ir.transform.rotation[2] ?? 0,
|
|
581
|
+
ir.transform.rotation[3] ?? 1,
|
|
582
|
+
ir.transform.scale[0] ?? 1,
|
|
583
|
+
ir.transform.scale[1] ?? 1,
|
|
584
|
+
ir.transform.scale[2] ?? 1,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
// Accumulate world transform (B3: parent * local)
|
|
588
|
+
if (parentLocalIdx === null) {
|
|
589
|
+
// Root node: world = local
|
|
590
|
+
for (let i = 0; i < 16; i++) {
|
|
591
|
+
currentWorld[i] = localMat[i] ?? 0;
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
mat4.multiply(currentWorld, parentWorld, localMat);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// B6: detect camera via GltfNodeIr.camera field (not legacy heuristic)
|
|
598
|
+
const isCamera = ir.camera !== null;
|
|
599
|
+
|
|
600
|
+
// D-2: empty container rule
|
|
601
|
+
// - mesh node: MeshFilter + MeshRenderer
|
|
602
|
+
// - camera node: Camera (standalone entity)
|
|
603
|
+
// - transform-only node (no mesh, no camera): Transform-only entity to preserve hierarchy
|
|
604
|
+
const hasMesh = ir.meshIndex !== null;
|
|
605
|
+
|
|
606
|
+
// Always produce a node for every glTF node (preserves hierarchy).
|
|
607
|
+
// Transform-only nodes carry just Transform + optionally Name.
|
|
608
|
+
const components: Record<string, Record<string, unknown>> = {
|
|
609
|
+
Transform: pushLocalTransform(ir.transform),
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const importedLight = importedLights[ir.lightIndex ?? -1];
|
|
613
|
+
if (importedLight !== undefined) {
|
|
614
|
+
const componentName =
|
|
615
|
+
importedLight.type === 'directional'
|
|
616
|
+
? 'DirectionalLight'
|
|
617
|
+
: importedLight.type === 'point'
|
|
618
|
+
? 'PointLight'
|
|
619
|
+
: 'SpotLight';
|
|
620
|
+
components[componentName] = lightComponent(
|
|
621
|
+
importedLight,
|
|
622
|
+
lightDirection(currentWorld),
|
|
623
|
+
) as Record<string, unknown>;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (ir.name !== undefined && ir.name !== '') {
|
|
627
|
+
components.Name = { value: ir.name };
|
|
628
|
+
}
|
|
629
|
+
const animationTargetId = animationTargetIds.get(gltfNodeIdx);
|
|
630
|
+
if (animationTargetId !== undefined) {
|
|
631
|
+
components.AnimationTargetId = { value: animationTargetId };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (hasMesh) {
|
|
635
|
+
const meshHandle = ctx.meshHandles.get(ir.meshIndex as number);
|
|
636
|
+
if (meshHandle !== undefined) {
|
|
637
|
+
components.MeshFilter = { assetHandle: meshHandle };
|
|
638
|
+
}
|
|
639
|
+
const meshMorph = doc.meshes.find((mesh) => mesh.meshIndex === ir.meshIndex);
|
|
640
|
+
const morphCount = meshMorph?.morphTargets?.length ?? 0;
|
|
641
|
+
if (morphCount > 0) {
|
|
642
|
+
const weights = ir.morphWeights ?? meshMorph?.morphWeights ?? new Float32Array(morphCount);
|
|
643
|
+
if (weights.length !== morphCount) {
|
|
644
|
+
throw new Error('gltfDocToSceneAsset: MorphWeights length does not match morph targets');
|
|
645
|
+
}
|
|
646
|
+
components.MorphWeights = { weights: new Float32Array(weights) };
|
|
647
|
+
}
|
|
648
|
+
// tweak-20260611 M6: when this node references a glTF skin, stamp a
|
|
649
|
+
// Skin component carrying the SkeletonAsset GUID as a string. The
|
|
650
|
+
// runtime AssetRegistry._resolveSceneGuids resolves the string to a
|
|
651
|
+
// Handle at instantiate time (same protocol as MeshFilter/MeshRenderer).
|
|
652
|
+
// postSpawnResolveJoints (called from AssetRegistry.instantiate) then
|
|
653
|
+
// fills Skin.joints[] by walking the matching SkinAsset.jointPaths
|
|
654
|
+
// against the spawn subtree's Name index.
|
|
655
|
+
if (ir.skinIndex !== null && ctx.skeletonGuidBySkinIndex !== undefined) {
|
|
656
|
+
const skeletonGuid = ctx.skeletonGuidBySkinIndex.get(ir.skinIndex);
|
|
657
|
+
if (skeletonGuid !== undefined) {
|
|
658
|
+
components.Skin = { skeleton: skeletonGuid };
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// B1: collect one material handle per primitive belonging to THIS
|
|
662
|
+
// node's glTF mesh (filter by meshIr.meshIndex === ir.meshIndex), in
|
|
663
|
+
// the same order primitives are emitted in `doc.meshes`. The merged
|
|
664
|
+
// MeshAsset built by `meshIrToMeshAsset` walks the same filter in the
|
|
665
|
+
// same order to produce one Submesh per primitive, so submeshes[i]
|
|
666
|
+
// pairs with materials[i] positionally (AGENTS.md §Component naming
|
|
667
|
+
// "positional materials[i] <-> submeshes[i]" + #317 multi-material
|
|
668
|
+
// contract).
|
|
669
|
+
//
|
|
670
|
+
// A primitive with materialIndex===null (or one whose ctx.materialHandles
|
|
671
|
+
// lookup misses) gets a synthetic missing-handle entry: dropping it
|
|
672
|
+
// would desynchronise materials[].length vs submeshes[].length and
|
|
673
|
+
// trigger the fail-fast `mesh-renderer-material-count-mismatch` at
|
|
674
|
+
// register time. Falling back to the first available material handle
|
|
675
|
+
// for the same mesh keeps the count alignment without inventing a
|
|
676
|
+
// shared default-material handle (Tier-B scope; Sponza / BoxTextured
|
|
677
|
+
// every primitive carries an explicit material so this fallback path
|
|
678
|
+
// is exercised only by under-specified glTFs).
|
|
679
|
+
// Mesh-owned slots carry imported defaults. Scene nodes persist only
|
|
680
|
+
// true instance differences, so the canonical imported node starts with
|
|
681
|
+
// an empty per-slot override vector.
|
|
682
|
+
components.MeshRenderer = { materials: [] };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Instances on the same entity as MeshFilter/MeshRenderer.
|
|
686
|
+
if (ir.instancing !== undefined) {
|
|
687
|
+
components.Instances = { transforms: ir.instancing.transforms };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (isCamera) {
|
|
691
|
+
// B6: camera node detected via GltfNodeIr.camera (not heuristic).
|
|
692
|
+
// Camera component sits alongside Transform (and Name if present).
|
|
693
|
+
components.Camera = {
|
|
694
|
+
fov: 0.7853981633974483,
|
|
695
|
+
aspect: 1.7777777777777777,
|
|
696
|
+
near: 0.1,
|
|
697
|
+
far: 100,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const localIdx = resultNodes.length;
|
|
702
|
+
const node: MutableSceneEntity = {
|
|
703
|
+
localId: localIdx as LocalEntityId,
|
|
704
|
+
components,
|
|
705
|
+
localIdx,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// ChildOf wiring: if this node has a parent, add ChildOf component
|
|
709
|
+
if (parentLocalIdx !== null) {
|
|
710
|
+
node.components.ChildOf = { parent: parentLocalIdx as LocalEntityId };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
resultNodes.push(node);
|
|
714
|
+
|
|
715
|
+
// B2: recursively visit children with accumulated world matrix.
|
|
716
|
+
// B3: set parentWorld to this node's world matrix so children multiply
|
|
717
|
+
// correctly (parent * childLocal = childWorld).
|
|
718
|
+
const savedParent = mat4.clone(parentWorld);
|
|
719
|
+
for (let i = 0; i < 16; i++) parentWorld[i] = currentWorld[i] ?? 0;
|
|
720
|
+
|
|
721
|
+
for (const childIdx of ir.children) {
|
|
722
|
+
visit(childIdx, localIdx);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Restore parent world after visiting all children
|
|
726
|
+
for (let i = 0; i < 16; i++) parentWorld[i] = savedParent[i] ?? 0;
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
for (const rootIdx of sceneIr.nodes) visit(rootIdx, null);
|
|
730
|
+
|
|
731
|
+
const frozen: SceneEntity[] = resultNodes.map((n) => ({
|
|
732
|
+
localId: n.localId,
|
|
733
|
+
components: n.components,
|
|
734
|
+
}));
|
|
735
|
+
const lightFacts = importedLights.map((light) => ({
|
|
736
|
+
kind: light.type,
|
|
737
|
+
intensity: light.intensity,
|
|
738
|
+
...(light.range === undefined ? {} : { range: light.range }),
|
|
739
|
+
...(light.spot === undefined ? {} : { spot: light.spot }),
|
|
740
|
+
}));
|
|
741
|
+
return {
|
|
742
|
+
kind: 'scene',
|
|
743
|
+
entities: frozen,
|
|
744
|
+
...(lightFacts.length === 0 ? {} : { lights: lightFacts }),
|
|
745
|
+
} as SceneAsset;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Internal helper: mark GltfNodeIr usable so future surface evolutions stay typed. */
|
|
749
|
+
export type _NodeIrAlias = GltfNodeIr;
|
|
750
|
+
|
|
751
|
+
export interface MaterialBridgeContext {
|
|
752
|
+
/** glTF texture index -> registry TextureAsset handle. */
|
|
753
|
+
readonly textureHandles?: ReadonlyMap<number, Handle<'TextureAsset', 'shared'>>;
|
|
754
|
+
/** glTF sampler index -> registry SamplerAsset handle. */
|
|
755
|
+
readonly samplerHandles?: ReadonlyMap<number, Handle<'SamplerAsset', 'shared'>>;
|
|
756
|
+
/** Built-in standard MaterialAsset GUID supplied by the importing project. */
|
|
757
|
+
readonly standardRootGuid?: AssetGuid;
|
|
758
|
+
/**
|
|
759
|
+
* feat-20260611 w17-a: when any primitive consuming this material carries
|
|
760
|
+
* JOINTS_0 + WEIGHTS_0, the cooker passes `skinned: true` so the emitted
|
|
761
|
+
* MaterialAsset's pass[0].program.module is `forgeax::pbr-skin` instead of
|
|
762
|
+
* `forgeax::default-standard-pbr`. The cooker (gltf-importer) is the only
|
|
763
|
+
* site with full mesh<->material wiring info; routing here keeps shader
|
|
764
|
+
* choice content-driven (not user-driven, per Q4 — runtime fail-fast in
|
|
765
|
+
* render-system-extract remains the reverse-direction safety net).
|
|
766
|
+
*/
|
|
767
|
+
readonly skinned?: boolean;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function textureInfo(info: GltfTextureInfoIr | number | undefined): GltfTextureInfoIr | undefined {
|
|
771
|
+
return info === undefined ? undefined : typeof info === 'number' ? { texture: info } : info;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
type MaterialTextureSlot =
|
|
775
|
+
| 'baseColorTexture'
|
|
776
|
+
| 'metallicRoughnessTexture'
|
|
777
|
+
| 'normalTexture'
|
|
778
|
+
| 'occlusionTexture'
|
|
779
|
+
| 'emissiveTexture';
|
|
780
|
+
|
|
781
|
+
function textureValue(
|
|
782
|
+
info: GltfTextureInfoIr | number | undefined,
|
|
783
|
+
slot: MaterialTextureSlot,
|
|
784
|
+
ctx: MaterialBridgeContext | undefined,
|
|
785
|
+
): MaterialTextureValue | undefined {
|
|
786
|
+
const binding = textureInfo(info);
|
|
787
|
+
if (binding === undefined || ctx?.textureHandles === undefined) return undefined;
|
|
788
|
+
const textureHandle = ctx.textureHandles.get(binding.texture);
|
|
789
|
+
if (textureHandle === undefined) return undefined;
|
|
790
|
+
const samplerHandle =
|
|
791
|
+
binding.sampler === undefined ? undefined : ctx.samplerHandles?.get(binding.sampler);
|
|
792
|
+
const coordinates =
|
|
793
|
+
binding.texCoord === undefined && binding.transform === undefined
|
|
794
|
+
? undefined
|
|
795
|
+
: {
|
|
796
|
+
...(binding.texCoord === undefined ? {} : { set: binding.texCoord }),
|
|
797
|
+
...(binding.transform === undefined ? {} : { transform: binding.transform }),
|
|
798
|
+
};
|
|
799
|
+
const value = {
|
|
800
|
+
texture: textureHandle as unknown as MaterialTextureValue['texture'],
|
|
801
|
+
...(samplerHandle === undefined
|
|
802
|
+
? {}
|
|
803
|
+
: { sampler: samplerHandle as unknown as NonNullable<MaterialTextureValue['sampler']> }),
|
|
804
|
+
...(coordinates === undefined ? {} : { coordinates }),
|
|
805
|
+
};
|
|
806
|
+
if (slot === 'normalTexture') {
|
|
807
|
+
const normal = info as GltfMaterialIr['normalTexture'];
|
|
808
|
+
if (typeof normal === 'object' && normal?.scale !== undefined) {
|
|
809
|
+
return { ...value, normalScale: normal.scale };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (slot === 'occlusionTexture') {
|
|
813
|
+
const occlusion = info as GltfMaterialIr['occlusionTexture'];
|
|
814
|
+
if (typeof occlusion === 'object' && occlusion?.strength !== undefined) {
|
|
815
|
+
return { ...value, occlusionStrength: occlusion.strength };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export function validateMaterialUvSets(
|
|
822
|
+
mat: GltfMaterialIr,
|
|
823
|
+
primitive: string,
|
|
824
|
+
availableSets: readonly number[],
|
|
825
|
+
): Result<void, MaterialError> {
|
|
826
|
+
const available = new Set(availableSets);
|
|
827
|
+
const slots: readonly [MaterialTextureSlot, GltfTextureInfoIr | number | undefined][] = [
|
|
828
|
+
['baseColorTexture', mat.baseColorTexture],
|
|
829
|
+
['metallicRoughnessTexture', mat.metallicRoughnessTexture],
|
|
830
|
+
['normalTexture', mat.normalTexture],
|
|
831
|
+
['occlusionTexture', mat.occlusionTexture],
|
|
832
|
+
['emissiveTexture', mat.emissiveTexture],
|
|
833
|
+
];
|
|
834
|
+
for (const [slot, rawBinding] of slots) {
|
|
835
|
+
const binding = textureInfo(rawBinding);
|
|
836
|
+
if (binding === undefined) continue;
|
|
837
|
+
const requestedSet = binding.texCoord ?? 0;
|
|
838
|
+
if (!available.has(requestedSet)) {
|
|
839
|
+
return err(
|
|
840
|
+
createMaterialError('gltf-material-uv-set-missing', {
|
|
841
|
+
material: mat.name ?? '<unnamed>',
|
|
842
|
+
primitive,
|
|
843
|
+
slot,
|
|
844
|
+
requestedSet,
|
|
845
|
+
availableSets,
|
|
846
|
+
}),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return ok(undefined);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Convert a parsed GltfMaterialIr into a standard-root derived MaterialAsset. */
|
|
854
|
+
export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext): MaterialAsset {
|
|
855
|
+
const values: Record<string, NonNullable<MaterialAsset['values']>[string]> = {
|
|
856
|
+
baseColor: mat.baseColorFactor,
|
|
857
|
+
metallic: mat.metallicFactor,
|
|
858
|
+
roughness: mat.roughnessFactor,
|
|
859
|
+
};
|
|
860
|
+
if (mat.emissiveFactor !== undefined) {
|
|
861
|
+
values.emissive = mat.emissiveFactor;
|
|
862
|
+
values.emissiveIntensity = 1;
|
|
863
|
+
}
|
|
864
|
+
const textureSlots: readonly [MaterialTextureSlot, GltfTextureInfoIr | number | undefined][] = [
|
|
865
|
+
['baseColorTexture', mat.baseColorTexture],
|
|
866
|
+
['metallicRoughnessTexture', mat.metallicRoughnessTexture],
|
|
867
|
+
['normalTexture', mat.normalTexture],
|
|
868
|
+
['occlusionTexture', mat.occlusionTexture],
|
|
869
|
+
['emissiveTexture', mat.emissiveTexture],
|
|
870
|
+
];
|
|
871
|
+
for (const [slot, info] of textureSlots) {
|
|
872
|
+
const value = textureValue(info, slot, ctx);
|
|
873
|
+
if (value !== undefined) values[slot] = value;
|
|
874
|
+
}
|
|
875
|
+
if (mat.occlusionTexture !== undefined && values.occlusionStrength === undefined) {
|
|
876
|
+
values.occlusionStrength = 1;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const module = ctx?.skinned === true ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr';
|
|
880
|
+
|
|
881
|
+
const isMask = mat.alphaMode === 'MASK';
|
|
882
|
+
const alphaCutoff = isMask ? (mat.alphaCutoff ?? 0.5) : undefined;
|
|
883
|
+
if (alphaCutoff !== undefined) values.alphaCutoff = alphaCutoff;
|
|
884
|
+
|
|
885
|
+
// glTF BLEND uses straight alpha and does not write depth.
|
|
886
|
+
const isBlend = mat.alphaMode === 'BLEND';
|
|
887
|
+
const straightAlphaBlend = {
|
|
888
|
+
color: {
|
|
889
|
+
srcFactor: 'src-alpha' as const,
|
|
890
|
+
dstFactor: 'one-minus-src-alpha' as const,
|
|
891
|
+
operation: 'add' as const,
|
|
892
|
+
},
|
|
893
|
+
alpha: {
|
|
894
|
+
srcFactor: 'one' as const,
|
|
895
|
+
dstFactor: 'one-minus-src-alpha' as const,
|
|
896
|
+
operation: 'add' as const,
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
// feat-city-glb Bug 5: transparent (BLEND) materials read but do NOT write
|
|
901
|
+
// depth (`depthWriteEnabled: false`), matching the engine's own transparent
|
|
902
|
+
// convention (learn-render 4.3 blending window material). glTF decals are
|
|
903
|
+
// frequently coplanar with the opaque surface they overlay (e.g. a crosswalk
|
|
904
|
+
// decal on the road); writing depth would z-fight / self-occlude. Back-to-
|
|
905
|
+
// front ordering is handled by the Transparent queue + transparent sort.
|
|
906
|
+
const pass = {
|
|
907
|
+
name: 'Forward',
|
|
908
|
+
program: { module },
|
|
909
|
+
renderState: {
|
|
910
|
+
tags: { LightMode: 'Forward' },
|
|
911
|
+
queue: (isBlend ? 3000 : isMask ? 2450 : 2000) as RenderQueue,
|
|
912
|
+
...(isBlend || isMask || mat.doubleSided === true
|
|
913
|
+
? {
|
|
914
|
+
...(isBlend ? { blend: straightAlphaBlend, depthWriteEnabled: false } : {}),
|
|
915
|
+
...(mat.doubleSided === true ? { cullMode: 'none' as const } : {}),
|
|
916
|
+
}
|
|
917
|
+
: {}),
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
kind: 'material',
|
|
923
|
+
colorSpace: 'linear',
|
|
924
|
+
...(ctx?.standardRootGuid === undefined ? {} : { parent: ctx.standardRootGuid }),
|
|
925
|
+
passes: [pass],
|
|
926
|
+
values,
|
|
927
|
+
};
|
|
928
|
+
}
|