@forgeax/engine-geometry 0.1.2
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 +215 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/dim2.test.d.ts +2 -0
- package/dist/__tests__/dim2.test.d.ts.map +1 -0
- package/dist/__tests__/geometry.unit.test.d.ts +2 -0
- package/dist/__tests__/geometry.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/vertex-attribute-layout-owner.unit.test.d.ts.map +1 -0
- package/dist/assets/mesh-binary.d.ts +4 -0
- package/dist/assets/mesh-binary.d.ts.map +1 -0
- package/dist/assets/mesh-decoder.d.ts +6 -0
- package/dist/assets/mesh-decoder.d.ts.map +1 -0
- package/dist/assets/primitive-mesh.d.ts +5 -0
- package/dist/assets/primitive-mesh.d.ts.map +1 -0
- package/dist/box.d.ts +41 -0
- package/dist/box.d.ts.map +1 -0
- package/dist/capsule.d.ts +14 -0
- package/dist/capsule.d.ts.map +1 -0
- package/dist/cone.d.ts +4 -0
- package/dist/cone.d.ts.map +1 -0
- package/dist/cylinder.d.ts +4 -0
- package/dist/cylinder.d.ts.map +1 -0
- package/dist/dim2.d.ts +73 -0
- package/dist/dim2.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +2000 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plane.d.ts +4 -0
- package/dist/plane.d.ts.map +1 -0
- package/dist/sphere.d.ts +4 -0
- package/dist/sphere.d.ts.map +1 -0
- package/dist/tangent.d.ts +34 -0
- package/dist/tangent.d.ts.map +1 -0
- package/dist/torus.d.ts +4 -0
- package/dist/torus.d.ts.map +1 -0
- package/dist/vertex-attribute-layout.d.ts +90 -0
- package/dist/vertex-attribute-layout.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/__tests__/asset-owner.unit.test.ts +97 -0
- package/src/__tests__/dim2.test.ts +132 -0
- package/src/__tests__/geometry.unit.test.ts +1223 -0
- package/src/__tests__/vertex-attribute-layout-owner.unit.test.ts +181 -0
- package/src/assets/mesh-binary.ts +421 -0
- package/src/assets/mesh-decoder.ts +97 -0
- package/src/assets/primitive-mesh.ts +36 -0
- package/src/box.ts +437 -0
- package/src/capsule.ts +145 -0
- package/src/cone.ts +26 -0
- package/src/cylinder.ts +180 -0
- package/src/dim2.ts +505 -0
- package/src/index.ts +52 -0
- package/src/plane.ts +78 -0
- package/src/sphere.ts +82 -0
- package/src/tangent.ts +320 -0
- package/src/torus.ts +83 -0
- package/src/vertex-attribute-layout.ts +503 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// @forgeax/engine-runtime - Vertex attribute layout SSOT (14-key closed set
|
|
2
|
+
// to GPUVertexBufferLayout derived function).
|
|
3
|
+
//
|
|
4
|
+
// deriveVertexBufferLayout(map, opts?) consumes a partial VertexAttributeMap and produces
|
|
5
|
+
// a fixed-order array of GPUVertexBufferLayout entries — the single source of
|
|
6
|
+
// truth for @location(N) assignment + GPUVertexFormat for every attribute the
|
|
7
|
+
// engine pipeline can consume. Shader WGSL @location(N) declarations and
|
|
8
|
+
// geometry factories are consumers of this SSOT; naga reflection deep-equal
|
|
9
|
+
// tests (T-33 dawn-only) keep them in sync (AC-26 / plan-strategy D-7).
|
|
10
|
+
//
|
|
11
|
+
// Canonical interleaved order (plan-strategy F-1, must match bridge + import layers):
|
|
12
|
+
// position / normal / uv / tangent / skinIndex / skinWeight / uv1..uv7 / color
|
|
13
|
+
//
|
|
14
|
+
// Key index (plan-strategy D-4 / D-7):
|
|
15
|
+
// 0: position -> @location(0) float32x3
|
|
16
|
+
// 1: normal -> @location(1) float32x3
|
|
17
|
+
// 2: uv -> @location(2) float32x2 (set 0)
|
|
18
|
+
// 3: tangent -> @location(3) float32x4
|
|
19
|
+
// 4: skinIndex -> @location(4) uint16x4
|
|
20
|
+
// 5: skinWeight -> @location(5) float32x4
|
|
21
|
+
// 6: uv1 -> @location(6) float32x2 (set 1, fea-20260629 D-4)
|
|
22
|
+
// 7: uv2 -> @location(7) float32x2 (set 2)
|
|
23
|
+
// 8: uv3 -> @location(8) float32x2 (set 3)
|
|
24
|
+
// 9: uv4 -> @location(9) float32x2 (set 4)
|
|
25
|
+
// 10: uv5 -> @location(10) float32x2 (set 5)
|
|
26
|
+
// 11: uv6 -> @location(11) float32x2 (set 6)
|
|
27
|
+
// 12: uv7 -> @location(12) float32x2 (set 7)
|
|
28
|
+
//
|
|
29
|
+
// feat-20260523-skin-skeleton-animation M2 / T-22.
|
|
30
|
+
// feat-20260629-multi-uv-set-support m3-w4: uv1..uv7 + clamp-to-last alias (D-1).
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
ASSET_ERROR_HINTS,
|
|
34
|
+
AssetError,
|
|
35
|
+
countUvSets,
|
|
36
|
+
err,
|
|
37
|
+
ok,
|
|
38
|
+
type Result,
|
|
39
|
+
type VertexAttributeMap,
|
|
40
|
+
type VertexAttributePackDetail,
|
|
41
|
+
type VertexAttributeStorage,
|
|
42
|
+
} from '@forgeax/engine-types';
|
|
43
|
+
|
|
44
|
+
const ATTRIBUTE_FORMAT_MAP = {
|
|
45
|
+
position: 'float32x3' as const,
|
|
46
|
+
normal: 'float32x3' as const,
|
|
47
|
+
uv: 'float32x2' as const,
|
|
48
|
+
tangent: 'float32x4' as const,
|
|
49
|
+
skinIndex: 'uint16x4' as const,
|
|
50
|
+
skinWeight: 'float32x4' as const,
|
|
51
|
+
uv1: 'float32x2' as const,
|
|
52
|
+
uv2: 'float32x2' as const,
|
|
53
|
+
uv3: 'float32x2' as const,
|
|
54
|
+
uv4: 'float32x2' as const,
|
|
55
|
+
uv5: 'float32x2' as const,
|
|
56
|
+
uv6: 'float32x2' as const,
|
|
57
|
+
uv7: 'float32x2' as const,
|
|
58
|
+
color: 'float32x4' as const,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
type AttributeKey = keyof typeof ATTRIBUTE_FORMAT_MAP;
|
|
62
|
+
|
|
63
|
+
const ATTRIBUTE_BYTE_STRIDE: Record<AttributeKey, number> = {
|
|
64
|
+
position: 12,
|
|
65
|
+
normal: 12,
|
|
66
|
+
uv: 8,
|
|
67
|
+
tangent: 16,
|
|
68
|
+
skinIndex: 8,
|
|
69
|
+
skinWeight: 16,
|
|
70
|
+
uv1: 8,
|
|
71
|
+
uv2: 8,
|
|
72
|
+
uv3: 8,
|
|
73
|
+
uv4: 8,
|
|
74
|
+
uv5: 8,
|
|
75
|
+
uv6: 8,
|
|
76
|
+
uv7: 8,
|
|
77
|
+
color: 16,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export interface GpuVertexBufferLayoutEntry {
|
|
81
|
+
readonly arrayStride: number;
|
|
82
|
+
readonly attributes: ReadonlyArray<{
|
|
83
|
+
readonly shaderLocation: number;
|
|
84
|
+
readonly offset: number;
|
|
85
|
+
readonly format: string;
|
|
86
|
+
}>;
|
|
87
|
+
readonly stepMode?: 'vertex' | 'instance' | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isAttributeKey = (key: string): key is AttributeKey => key in ATTRIBUTE_FORMAT_MAP;
|
|
91
|
+
|
|
92
|
+
const CANONICAL_KEYS: readonly AttributeKey[] =
|
|
93
|
+
Object.keys(ATTRIBUTE_FORMAT_MAP).filter(isAttributeKey);
|
|
94
|
+
const UV_KEYS: readonly AttributeKey[] = CANONICAL_KEYS.filter(
|
|
95
|
+
(key) => key === 'uv' || key.startsWith('uv'),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
type Entry = {
|
|
99
|
+
readonly key: AttributeKey;
|
|
100
|
+
readonly shaderLocation: number;
|
|
101
|
+
readonly offset: number;
|
|
102
|
+
readonly format: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function emitAliasEntries(
|
|
106
|
+
entries: Entry[],
|
|
107
|
+
fromIndex: number,
|
|
108
|
+
toIndex: number,
|
|
109
|
+
aliasOffset: number,
|
|
110
|
+
currentStride: number,
|
|
111
|
+
): number {
|
|
112
|
+
for (let k = fromIndex; k < toIndex && k < UV_KEYS.length; k++) {
|
|
113
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded index on const array
|
|
114
|
+
const uvKey = UV_KEYS[k]!;
|
|
115
|
+
entries.push({
|
|
116
|
+
key: uvKey,
|
|
117
|
+
shaderLocation: CANONICAL_KEYS.indexOf(uvKey),
|
|
118
|
+
offset: aliasOffset,
|
|
119
|
+
format: ATTRIBUTE_FORMAT_MAP[uvKey],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// When meshUvSetCount===0, allocate 8 bytes for the zero UV area.
|
|
123
|
+
// Otherwise no stride increase (aliased to existing offset).
|
|
124
|
+
return fromIndex === 0 ? currentStride + ATTRIBUTE_BYTE_STRIDE.uv : currentStride;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build a non-skin `VertexAttributeMap` carrying exactly `uvSetCount` UV sets
|
|
129
|
+
* (set 0 = `uv`, then `uv1..uv{uvSetCount-1}`). `deriveVertexBufferLayout`
|
|
130
|
+
* reads only key presence, so the zero-length typed-array values are sentinels.
|
|
131
|
+
*
|
|
132
|
+
* feat-20260629-multi-uv-set-support: the forward record stage owns only the
|
|
133
|
+
* mesh's `uvSetCount` (a scalar threaded through MeshGpuHandles), not the
|
|
134
|
+
* original `MeshAsset.attributes` map. It synthesizes the map here so the
|
|
135
|
+
* material PSO's vertex layout includes the real @location(6+) attributes and
|
|
136
|
+
* its stride matches the interleaved buffer (a mesh with 2 real UV sets has a
|
|
137
|
+
* 56 B stride; a 48 B PSO layout against it puts every vertex after the first
|
|
138
|
+
* off-screen). uvSetCount <= 1 yields the canonical 4-attribute single-UV map.
|
|
139
|
+
*/
|
|
140
|
+
export function buildMeshAttributeMapForUvSets(uvSetCount: number): VertexAttributeMap {
|
|
141
|
+
const map: Record<string, Float32Array> = {
|
|
142
|
+
position: new Float32Array(0),
|
|
143
|
+
normal: new Float32Array(0),
|
|
144
|
+
uv: new Float32Array(0),
|
|
145
|
+
tangent: new Float32Array(0),
|
|
146
|
+
};
|
|
147
|
+
for (let set = 1; set < uvSetCount && set < UV_KEYS.length; set++) {
|
|
148
|
+
// biome-ignore lint/style/noNonNullAssertion: bounded index on const array
|
|
149
|
+
map[UV_KEYS[set]!] = new Float32Array(0);
|
|
150
|
+
}
|
|
151
|
+
return map as unknown as VertexAttributeMap;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function deriveVertexBufferLayout(
|
|
155
|
+
map: VertexAttributeMap,
|
|
156
|
+
opts?: { shaderUvSetCount?: number },
|
|
157
|
+
): GpuVertexBufferLayoutEntry[] {
|
|
158
|
+
const shaderUvSetCount = opts?.shaderUvSetCount ?? 0;
|
|
159
|
+
|
|
160
|
+
// Process present keys in canonical order; absent keys reserve no space.
|
|
161
|
+
const entries: Entry[] = [];
|
|
162
|
+
let offset = 0;
|
|
163
|
+
|
|
164
|
+
for (const key of CANONICAL_KEYS) {
|
|
165
|
+
if (map[key] === undefined) continue;
|
|
166
|
+
entries.push({
|
|
167
|
+
key,
|
|
168
|
+
shaderLocation: CANONICAL_KEYS.indexOf(key),
|
|
169
|
+
offset,
|
|
170
|
+
format: ATTRIBUTE_FORMAT_MAP[key],
|
|
171
|
+
});
|
|
172
|
+
offset += ATTRIBUTE_BYTE_STRIDE[key];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const present = entries.length;
|
|
176
|
+
|
|
177
|
+
// ── clamp-to-last alias (plan-strategy D-1) ──
|
|
178
|
+
if (shaderUvSetCount > 0) {
|
|
179
|
+
const meshUvSetCount = countUvSets(map);
|
|
180
|
+
|
|
181
|
+
if (shaderUvSetCount > meshUvSetCount) {
|
|
182
|
+
const lastUvIndex = meshUvSetCount - 1;
|
|
183
|
+
const lastUvKey =
|
|
184
|
+
lastUvIndex >= 0 && lastUvIndex < UV_KEYS.length ? UV_KEYS[lastUvIndex] : undefined;
|
|
185
|
+
const aliasOffset = lastUvKey !== undefined ? CANONICAL_KEYS.indexOf(lastUvKey) : -1;
|
|
186
|
+
|
|
187
|
+
const aliasByteOffset =
|
|
188
|
+
aliasOffset >= 0
|
|
189
|
+
? (entries.find((e) => e.shaderLocation === aliasOffset)?.offset ?? 0)
|
|
190
|
+
: offset;
|
|
191
|
+
|
|
192
|
+
offset = emitAliasEntries(entries, meshUvSetCount, shaderUvSetCount, aliasByteOffset, offset);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (present === 0 && shaderUvSetCount === 0) return [];
|
|
197
|
+
|
|
198
|
+
// Sort by shaderLocation for deterministic vertex buffer descriptor
|
|
199
|
+
entries.sort((a, b) => a.shaderLocation - b.shaderLocation);
|
|
200
|
+
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
arrayStride: offset,
|
|
204
|
+
attributes: entries.map(({ shaderLocation, offset: entryOffset, format }) => ({
|
|
205
|
+
shaderLocation,
|
|
206
|
+
offset: entryOffset,
|
|
207
|
+
format,
|
|
208
|
+
})),
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Build the GPU descriptor from an immutable projection, adding only shader UV aliases. */
|
|
214
|
+
export function deriveVertexBufferLayoutFromProjection(
|
|
215
|
+
projection: VertexLayoutProjection,
|
|
216
|
+
opts?: { shaderUvSetCount?: number },
|
|
217
|
+
): GpuVertexBufferLayoutEntry[] {
|
|
218
|
+
const entries = projection.attributes.map(({ key, shaderLocation, offset, format }) => ({
|
|
219
|
+
key,
|
|
220
|
+
shaderLocation,
|
|
221
|
+
offset,
|
|
222
|
+
format,
|
|
223
|
+
}));
|
|
224
|
+
let arrayStride = projection.arrayStride;
|
|
225
|
+
const shaderUvSetCount = opts?.shaderUvSetCount ?? 0;
|
|
226
|
+
if (shaderUvSetCount > 0) {
|
|
227
|
+
const meshUvSetCount = entries.filter(
|
|
228
|
+
(entry) => entry.key === 'uv' || entry.key.startsWith('uv'),
|
|
229
|
+
).length;
|
|
230
|
+
if (shaderUvSetCount > meshUvSetCount) {
|
|
231
|
+
const lastUv = entries
|
|
232
|
+
.filter((entry) => entry.key === 'uv' || entry.key.startsWith('uv'))
|
|
233
|
+
.at(-1);
|
|
234
|
+
const aliasOffset = lastUv?.offset ?? arrayStride;
|
|
235
|
+
for (
|
|
236
|
+
let index = meshUvSetCount;
|
|
237
|
+
index < shaderUvSetCount && index < UV_KEYS.length;
|
|
238
|
+
index += 1
|
|
239
|
+
) {
|
|
240
|
+
const key = UV_KEYS[index];
|
|
241
|
+
if (key === undefined) continue;
|
|
242
|
+
entries.push({
|
|
243
|
+
key,
|
|
244
|
+
shaderLocation: CANONICAL_KEYS.indexOf(key),
|
|
245
|
+
offset: aliasOffset,
|
|
246
|
+
format: ATTRIBUTE_FORMAT_MAP[key],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (meshUvSetCount === 0) arrayStride += ATTRIBUTE_BYTE_STRIDE.uv;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
entries.sort((a, b) => a.shaderLocation - b.shaderLocation);
|
|
253
|
+
return projection.attributes.length === 0
|
|
254
|
+
? []
|
|
255
|
+
: [
|
|
256
|
+
{
|
|
257
|
+
arrayStride,
|
|
258
|
+
attributes: entries.map(({ shaderLocation, offset, format }) => ({
|
|
259
|
+
shaderLocation,
|
|
260
|
+
offset,
|
|
261
|
+
format,
|
|
262
|
+
})),
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface VertexLayoutProjectionAttribute {
|
|
268
|
+
readonly key: AttributeKey;
|
|
269
|
+
readonly shaderLocation: number;
|
|
270
|
+
readonly offset: number;
|
|
271
|
+
readonly format: (typeof ATTRIBUTE_FORMAT_MAP)[AttributeKey];
|
|
272
|
+
readonly byteLength: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Immutable geometry-owned projection consumed by packers and GPU consumers. */
|
|
276
|
+
export interface VertexLayoutProjection {
|
|
277
|
+
readonly schemaVersion: 1;
|
|
278
|
+
readonly attributes: readonly VertexLayoutProjectionAttribute[];
|
|
279
|
+
readonly mask: number;
|
|
280
|
+
readonly arrayStride: number;
|
|
281
|
+
readonly digest: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function bytesForFormat(format: string): number {
|
|
285
|
+
if (format === 'uint16x4' || format === 'float32x2') return 8;
|
|
286
|
+
if (format === 'float32x3') return 12;
|
|
287
|
+
return 16;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function storageOf(value: unknown): VertexAttributeStorage {
|
|
291
|
+
if (value instanceof ArrayBuffer) return 'array-buffer';
|
|
292
|
+
if (value instanceof Float32Array) return 'float32';
|
|
293
|
+
if (value instanceof Uint16Array) return 'uint16';
|
|
294
|
+
return 'other';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function elementView(
|
|
298
|
+
value: VertexAttributeMap[AttributeKey],
|
|
299
|
+
format: string,
|
|
300
|
+
): Float32Array | Uint16Array | undefined {
|
|
301
|
+
if (value instanceof Float32Array || value instanceof Uint16Array) return value;
|
|
302
|
+
if (value instanceof ArrayBuffer) {
|
|
303
|
+
return format === 'uint16x4' ? new Uint16Array(value) : new Float32Array(value);
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fnv1a(input: string): string {
|
|
309
|
+
let hash = 0x811c9dc5;
|
|
310
|
+
for (let i = 0; i < input.length; i++) {
|
|
311
|
+
hash ^= input.charCodeAt(i);
|
|
312
|
+
hash = Math.imul(hash, 0x01000193);
|
|
313
|
+
}
|
|
314
|
+
return `vlp-v1-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Derive the one canonical immutable layout projection for a vertex map. */
|
|
318
|
+
export function deriveVertexLayoutProjection(map: VertexAttributeMap): VertexLayoutProjection {
|
|
319
|
+
const attributes: VertexLayoutProjectionAttribute[] = [];
|
|
320
|
+
let offset = 0;
|
|
321
|
+
let mask = 0;
|
|
322
|
+
for (const key of CANONICAL_KEYS) {
|
|
323
|
+
const value = map[key];
|
|
324
|
+
if (value === undefined) continue;
|
|
325
|
+
const format = ATTRIBUTE_FORMAT_MAP[key];
|
|
326
|
+
const byteLength = bytesForFormat(format);
|
|
327
|
+
attributes.push({
|
|
328
|
+
key,
|
|
329
|
+
shaderLocation: CANONICAL_KEYS.indexOf(key),
|
|
330
|
+
offset,
|
|
331
|
+
format,
|
|
332
|
+
byteLength,
|
|
333
|
+
});
|
|
334
|
+
mask |= 1 << CANONICAL_KEYS.indexOf(key);
|
|
335
|
+
offset += byteLength;
|
|
336
|
+
}
|
|
337
|
+
const digestInput = [
|
|
338
|
+
'1',
|
|
339
|
+
String(mask),
|
|
340
|
+
String(offset),
|
|
341
|
+
...attributes.map(
|
|
342
|
+
(entry) => `${entry.key},${entry.shaderLocation},${entry.offset},${entry.format}`,
|
|
343
|
+
),
|
|
344
|
+
].join('|');
|
|
345
|
+
return Object.freeze({
|
|
346
|
+
schemaVersion: 1,
|
|
347
|
+
attributes: Object.freeze(attributes),
|
|
348
|
+
mask,
|
|
349
|
+
arrayStride: offset,
|
|
350
|
+
digest: fnv1a(digestInput),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface PackedVertexAttributes {
|
|
355
|
+
readonly projection: VertexLayoutProjection;
|
|
356
|
+
readonly vertices: Float32Array;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export class VertexAttributePackError extends AssetError {
|
|
360
|
+
declare readonly detail: VertexAttributePackDetail;
|
|
361
|
+
|
|
362
|
+
constructor(detail: VertexAttributePackDetail) {
|
|
363
|
+
super({
|
|
364
|
+
code: 'asset-invalid-value',
|
|
365
|
+
expected: 'canonical vertex attributes with matching storage and cardinality',
|
|
366
|
+
hint: ASSET_ERROR_HINTS['asset-invalid-value'],
|
|
367
|
+
detail,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface VertexLayoutProjectionMaskError {
|
|
373
|
+
readonly code: 'vertex-layout-mask-invalid';
|
|
374
|
+
readonly expected: string;
|
|
375
|
+
readonly hint: string;
|
|
376
|
+
readonly detail: {
|
|
377
|
+
readonly mask: number;
|
|
378
|
+
readonly knownMask: number;
|
|
379
|
+
readonly unknownMask: number;
|
|
380
|
+
readonly reason: 'empty' | 'unknown-bits';
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Rebuild the canonical projection from a packed wire mask without a second schema. */
|
|
385
|
+
export function deriveVertexLayoutProjectionFromMask(
|
|
386
|
+
mask: number,
|
|
387
|
+
): Result<VertexLayoutProjection, VertexLayoutProjectionMaskError> {
|
|
388
|
+
const knownMask = (1 << CANONICAL_KEYS.length) - 1;
|
|
389
|
+
const unsignedMask = Number.isInteger(mask) && mask >= 0 ? mask >>> 0 : 0xffffffff;
|
|
390
|
+
const unknownMask = unsignedMask & ~knownMask;
|
|
391
|
+
if (mask === 0) {
|
|
392
|
+
return err({
|
|
393
|
+
code: 'vertex-layout-mask-invalid',
|
|
394
|
+
expected: 'a non-empty canonical vertex attribute mask',
|
|
395
|
+
hint: 're-cook the mesh-bin payload from MeshAsset.attributes',
|
|
396
|
+
detail: { mask, knownMask, unknownMask, reason: 'empty' },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (!Number.isInteger(mask) || mask < 0 || unknownMask !== 0) {
|
|
400
|
+
return err({
|
|
401
|
+
code: 'vertex-layout-mask-invalid',
|
|
402
|
+
expected: `a mask using only canonical bits 0..${CANONICAL_KEYS.length - 1}`,
|
|
403
|
+
hint: 're-cook the mesh-bin payload from MeshAsset.attributes',
|
|
404
|
+
detail: { mask, knownMask, unknownMask, reason: 'unknown-bits' },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const map: Record<string, Float32Array | Uint16Array> = {};
|
|
408
|
+
for (let index = 0; index < CANONICAL_KEYS.length; index += 1) {
|
|
409
|
+
if ((mask & (1 << index)) === 0) continue;
|
|
410
|
+
const key = CANONICAL_KEYS[index];
|
|
411
|
+
if (key !== undefined)
|
|
412
|
+
map[key] = key === 'skinIndex' ? new Uint16Array(0) : new Float32Array(0);
|
|
413
|
+
}
|
|
414
|
+
return ok(deriveVertexLayoutProjection(map as VertexAttributeMap));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Pack tightly-owned attributes into the projection's canonical interleaved bytes. */
|
|
418
|
+
export function packInterleavedVertexAttributes(
|
|
419
|
+
map: VertexAttributeMap,
|
|
420
|
+
vertexCount: number,
|
|
421
|
+
): Result<PackedVertexAttributes, VertexAttributePackError> {
|
|
422
|
+
const invalid = (detail: VertexAttributePackDetail): Result<never, VertexAttributePackError> =>
|
|
423
|
+
err(new VertexAttributePackError(detail));
|
|
424
|
+
if (!Number.isInteger(vertexCount) || vertexCount < 0) {
|
|
425
|
+
return invalid({ field: 'vertexCount', reason: 'vertex-count-invalid', actual: vertexCount });
|
|
426
|
+
}
|
|
427
|
+
const projection = deriveVertexLayoutProjection(map);
|
|
428
|
+
if (projection.attributes.length === 0) {
|
|
429
|
+
return invalid({ field: 'attributes', reason: 'attributes-empty', actualCount: 0 });
|
|
430
|
+
}
|
|
431
|
+
const output = new ArrayBuffer(projection.arrayStride * vertexCount);
|
|
432
|
+
const outputFloats = new Float32Array(output);
|
|
433
|
+
const outputU16 = new Uint16Array(output);
|
|
434
|
+
for (const entry of projection.attributes) {
|
|
435
|
+
const sourceValue = map[entry.key];
|
|
436
|
+
if (sourceValue === undefined) continue;
|
|
437
|
+
const uint16 = entry.format === 'uint16x4';
|
|
438
|
+
const components = entry.byteLength / (uint16 ? 2 : 4);
|
|
439
|
+
const bytesPerComponent = uint16 ? 2 : 4;
|
|
440
|
+
if (sourceValue instanceof ArrayBuffer && sourceValue.byteLength % bytesPerComponent !== 0) {
|
|
441
|
+
return invalid({
|
|
442
|
+
field: entry.key,
|
|
443
|
+
reason: 'attribute-cardinality-mismatch',
|
|
444
|
+
vertexCount,
|
|
445
|
+
componentsPerVertex: components,
|
|
446
|
+
expectedLength: vertexCount * components,
|
|
447
|
+
actualLength: sourceValue.byteLength / bytesPerComponent,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const source = elementView(sourceValue, entry.format);
|
|
451
|
+
if (
|
|
452
|
+
source === undefined ||
|
|
453
|
+
(uint16
|
|
454
|
+
? storageOf(sourceValue) !== 'uint16' && storageOf(sourceValue) !== 'array-buffer'
|
|
455
|
+
: storageOf(sourceValue) !== 'float32' && storageOf(sourceValue) !== 'array-buffer')
|
|
456
|
+
) {
|
|
457
|
+
return invalid({
|
|
458
|
+
field: entry.key,
|
|
459
|
+
reason: 'attribute-storage-invalid',
|
|
460
|
+
expectedStorage: uint16 ? 'uint16' : 'float32',
|
|
461
|
+
actualStorage: storageOf(sourceValue),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const expectedLength = vertexCount * components;
|
|
465
|
+
if (source.length !== expectedLength) {
|
|
466
|
+
return invalid({
|
|
467
|
+
field: entry.key,
|
|
468
|
+
reason: 'attribute-cardinality-mismatch',
|
|
469
|
+
vertexCount,
|
|
470
|
+
componentsPerVertex: components,
|
|
471
|
+
expectedLength,
|
|
472
|
+
actualLength: source.length,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (entry.key === 'color') {
|
|
476
|
+
for (let elementIndex = 0; elementIndex < source.length; elementIndex += 1) {
|
|
477
|
+
const component = source[elementIndex] as number;
|
|
478
|
+
if (!Number.isFinite(component)) {
|
|
479
|
+
return invalid({
|
|
480
|
+
field: 'color',
|
|
481
|
+
reason: 'attribute-non-finite',
|
|
482
|
+
elementIndex,
|
|
483
|
+
actual: Number.isNaN(component)
|
|
484
|
+
? 'nan'
|
|
485
|
+
: component === Number.POSITIVE_INFINITY
|
|
486
|
+
? 'positive-infinity'
|
|
487
|
+
: 'negative-infinity',
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
|
|
493
|
+
for (let component = 0; component < components; component += 1) {
|
|
494
|
+
const sourceIndex = vertex * components + component;
|
|
495
|
+
const byteOffset =
|
|
496
|
+
vertex * projection.arrayStride + entry.offset + component * (uint16 ? 2 : 4);
|
|
497
|
+
if (uint16) outputU16[byteOffset / 2] = Number(source[sourceIndex] ?? 0);
|
|
498
|
+
else outputFloats[byteOffset / 4] = Number(source[sourceIndex] ?? 0);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return ok(Object.freeze({ projection, vertices: outputFloats }));
|
|
503
|
+
}
|