@forgeax/engine-geometry 0.1.27 → 0.1.29

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.
@@ -25,6 +25,10 @@ export interface GpuVertexBufferLayoutEntry {
25
25
  }>;
26
26
  readonly stepMode?: 'vertex' | 'instance' | undefined;
27
27
  }
28
+ /** Canonical position/normal/uv/tangent map for ordinary interleaved meshes. */
29
+ export declare const DEFAULT_VERTEX_ATTRIBUTE_MAP: VertexAttributeMap;
30
+ /** Canonical six-attribute map for skinned interleaved meshes. */
31
+ export declare const SKIN_VERTEX_ATTRIBUTE_MAP: VertexAttributeMap;
28
32
  /**
29
33
  * Build a non-skin `VertexAttributeMap` carrying exactly `uvSetCount` UV sets
30
34
  * (set 0 = `uv`, then `uv1..uv{uvSetCount-1}`). `deriveVertexBufferLayout`
@@ -61,6 +65,16 @@ export interface VertexLayoutProjection {
61
65
  readonly arrayStride: number;
62
66
  readonly digest: string;
63
67
  }
68
+ /**
69
+ * Derive a vertex count from the geometry-owned projection.
70
+ *
71
+ * Every upload, morph write, and draw range must use this same byte-level
72
+ * calculation. Returning `undefined` keeps malformed payloads out of GPU
73
+ * resource construction instead of silently truncating a fractional count.
74
+ */
75
+ export declare function deriveVertexCount(vertices: {
76
+ readonly byteLength: number;
77
+ }, projection: VertexLayoutProjection): number | undefined;
64
78
  /** Derive the one canonical immutable layout projection for a vertex map. */
65
79
  export declare function deriveVertexLayoutProjection(map: VertexAttributeMap): VertexLayoutProjection;
66
80
  export interface PackedVertexAttributes {
@@ -1 +1 @@
1
- {"version":3,"file":"vertex-attribute-layout.d.ts","sourceRoot":"","sources":["../src/vertex-attribute-layout.ts"],"names":[],"mappings":"AA+BA,OAAO,EAEL,UAAU,EAIV,KAAK,MAAM,EACX,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,EAE/B,MAAM,uBAAuB,CAAC;AAE/B,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;;;CAehB,CAAC;AAEX,KAAK,YAAY,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAmBtD,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;QACjC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;QAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,CAAC,CAAC;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;CACvD;AAuCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,CAYrF;AAED,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,kBAAkB,EACvB,IAAI,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,GACnC,0BAA0B,EAAE,CAsD9B;AAED,4FAA4F;AAC5F,wBAAgB,sCAAsC,CACpD,UAAU,EAAE,sBAAsB,EAClC,IAAI,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,GACnC,0BAA0B,EAAE,CAgD9B;AAED,MAAM,WAAW,+BAA+B;IAC9C,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,oBAAoB,CAAC,CAAC,YAAY,CAAC,CAAC;IAC7D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,iFAAiF;AACjF,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,aAAa,EAAE,CAAC,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,SAAS,+BAA+B,EAAE,CAAC;IAChE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAmCD,6EAA6E;AAC7E,wBAAgB,4BAA4B,CAAC,GAAG,EAAE,kBAAkB,GAAG,sBAAsB,CAkC5F;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,UAAU,EAAE,sBAAsB,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;CACjC;AAED,qBAAa,wBAAyB,SAAQ,UAAU;IACtD,SAAiB,MAAM,EAAE,yBAAyB,CAAC;gBAEvC,MAAM,EAAE,yBAAyB;CAQ9C;AAED,MAAM,WAAW,+BAA+B;IAC9C,QAAQ,CAAC,IAAI,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;QAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,cAAc,CAAC;KAC3C,CAAC;CACH;AAED,wFAAwF;AACxF,wBAAgB,oCAAoC,CAClD,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,sBAAsB,EAAE,+BAA+B,CAAC,CA4BjE;AAED,uFAAuF;AACvF,wBAAgB,+BAA+B,CAC7C,GAAG,EAAE,kBAAkB,EACvB,WAAW,EAAE,MAAM,GAClB,MAAM,CAAC,sBAAsB,EAAE,wBAAwB,CAAC,CAkF1D"}
1
+ {"version":3,"file":"vertex-attribute-layout.d.ts","sourceRoot":"","sources":["../src/vertex-attribute-layout.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-geometry",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,10 +22,10 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-ecs": "0.1.27",
26
- "@forgeax/engine-math": "0.1.27",
27
- "@forgeax/engine-pack": "0.1.27",
28
- "@forgeax/engine-types": "0.1.27"
25
+ "@forgeax/engine-ecs": "0.1.29",
26
+ "@forgeax/engine-math": "0.1.29",
27
+ "@forgeax/engine-pack": "0.1.29",
28
+ "@forgeax/engine-types": "0.1.29"
29
29
  },
30
30
  "forgeax": {
31
31
  "metrics": {
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  buildMeshAttributeMapForUvSets,
3
+ DEFAULT_VERTEX_ATTRIBUTE_MAP,
3
4
  deriveVertexBufferLayout,
5
+ deriveVertexBufferLayoutFromProjection,
6
+ deriveVertexCount,
4
7
  deriveVertexLayoutProjection,
5
8
  deriveVertexLayoutProjectionFromMask,
6
9
  packInterleavedVertexAttributes,
10
+ SKIN_VERTEX_ATTRIBUTE_MAP,
7
11
  } from '@forgeax/engine-geometry';
8
12
  import type { VertexAttributeMap } from '@forgeax/engine-types';
9
13
  import { describe, expect, it } from 'vitest';
@@ -29,6 +33,34 @@ function makeCompleteAttributeMap(): VertexAttributeMap {
29
33
  }
30
34
 
31
35
  describe('vertex attribute layout owner', () => {
36
+ it('publishes the ordinary and skinned maps from the same geometry owner', () => {
37
+ expect(Object.keys(DEFAULT_VERTEX_ATTRIBUTE_MAP)).toEqual([
38
+ 'position',
39
+ 'normal',
40
+ 'uv',
41
+ 'tangent',
42
+ ]);
43
+ expect(Object.keys(SKIN_VERTEX_ATTRIBUTE_MAP)).toEqual([
44
+ 'position',
45
+ 'normal',
46
+ 'uv',
47
+ 'tangent',
48
+ 'skinIndex',
49
+ 'skinWeight',
50
+ ]);
51
+ expect(deriveVertexLayoutProjection(DEFAULT_VERTEX_ATTRIBUTE_MAP).arrayStride).toBe(48);
52
+ expect(deriveVertexLayoutProjection(SKIN_VERTEX_ATTRIBUTE_MAP).arrayStride).toBe(72);
53
+ });
54
+
55
+ it('derives counts only when vertex bytes match the projected stride', () => {
56
+ const plain = deriveVertexLayoutProjection(DEFAULT_VERTEX_ATTRIBUTE_MAP);
57
+ const skinned = deriveVertexLayoutProjection(SKIN_VERTEX_ATTRIBUTE_MAP);
58
+ expect(deriveVertexCount(new Float32Array(24), plain)).toBe(2);
59
+ expect(deriveVertexCount(new Float32Array(18), skinned)).toBe(1);
60
+ expect(deriveVertexCount(new Float32Array(23), plain)).toBeUndefined();
61
+ expect(deriveVertexCount(new Float32Array(17), skinned)).toBeUndefined();
62
+ });
63
+
32
64
  it('derives the complete 13-key layout from the format owner', () => {
33
65
  expect(deriveVertexBufferLayout(makeCompleteAttributeMap())).toEqual([
34
66
  {
@@ -178,4 +210,23 @@ describe('vertex attribute layout owner', () => {
178
210
  },
179
211
  ]);
180
212
  });
213
+
214
+ it('aliases sparse UV holes without duplicating authored shader locations', () => {
215
+ const map = { position: makeBuffer(), uv: makeBuffer(), uv7: makeBuffer() };
216
+ const projection = deriveVertexLayoutProjection(map);
217
+ const fromMap = deriveVertexBufferLayout(map, { shaderUvSetCount: 8 });
218
+ const fromProjection = deriveVertexBufferLayoutFromProjection(projection, {
219
+ shaderUvSetCount: 8,
220
+ });
221
+ expect(fromMap).toEqual(fromProjection);
222
+ const layout = fromProjection[0];
223
+ expect(layout?.arrayStride).toBe(28);
224
+ expect(layout?.attributes).toHaveLength(9);
225
+ expect(new Set(layout?.attributes.map((entry) => entry.shaderLocation)).size).toBe(9);
226
+ expect(
227
+ layout?.attributes
228
+ .filter((entry) => entry.shaderLocation >= 6)
229
+ .every((entry) => entry.offset === 20),
230
+ ).toBe(true);
231
+ });
181
232
  });
package/src/index.ts CHANGED
@@ -56,13 +56,16 @@ export {
56
56
  export { createTorusGeometry } from './torus';
57
57
  export {
58
58
  buildMeshAttributeMapForUvSets,
59
+ DEFAULT_VERTEX_ATTRIBUTE_MAP,
59
60
  deriveVertexBufferLayout,
60
61
  deriveVertexBufferLayoutFromProjection,
62
+ deriveVertexCount,
61
63
  deriveVertexLayoutProjection,
62
64
  deriveVertexLayoutProjectionFromMask,
63
65
  type GpuVertexBufferLayoutEntry,
64
66
  type PackedVertexAttributes,
65
67
  packInterleavedVertexAttributes,
68
+ SKIN_VERTEX_ATTRIBUTE_MAP,
66
69
  VertexAttributePackError,
67
70
  type VertexLayoutProjection,
68
71
  type VertexLayoutProjectionAttribute,
@@ -32,7 +32,6 @@
32
32
  import {
33
33
  ASSET_ERROR_HINTS,
34
34
  AssetError,
35
- countUvSets,
36
35
  err,
37
36
  ok,
38
37
  type Result,
@@ -95,6 +94,27 @@ const UV_KEYS: readonly AttributeKey[] = CANONICAL_KEYS.filter(
95
94
  (key) => key === 'uv' || key.startsWith('uv'),
96
95
  );
97
96
 
97
+ // Empty typed-array values are presence sentinels for layout derivation. Keep
98
+ // the common mesh maps here so renderers, recovery, and points/lines cannot
99
+ // grow a second copy of the canonical attribute vocabulary.
100
+ const EMPTY_FLOAT32 = new Float32Array(0);
101
+ const EMPTY_UINT16 = new Uint16Array(0);
102
+
103
+ /** Canonical position/normal/uv/tangent map for ordinary interleaved meshes. */
104
+ export const DEFAULT_VERTEX_ATTRIBUTE_MAP: VertexAttributeMap = Object.freeze({
105
+ position: EMPTY_FLOAT32,
106
+ normal: EMPTY_FLOAT32,
107
+ uv: EMPTY_FLOAT32,
108
+ tangent: EMPTY_FLOAT32,
109
+ });
110
+
111
+ /** Canonical six-attribute map for skinned interleaved meshes. */
112
+ export const SKIN_VERTEX_ATTRIBUTE_MAP: VertexAttributeMap = Object.freeze({
113
+ ...DEFAULT_VERTEX_ATTRIBUTE_MAP,
114
+ skinIndex: EMPTY_UINT16,
115
+ skinWeight: EMPTY_FLOAT32,
116
+ });
117
+
98
118
  type Entry = {
99
119
  readonly key: AttributeKey;
100
120
  readonly shaderLocation: number;
@@ -102,16 +122,13 @@ type Entry = {
102
122
  readonly format: string;
103
123
  };
104
124
 
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++) {
125
+ function emitAliasEntries(entries: Entry[], toIndex: number, currentStride: number): number {
126
+ const lastUv = entries.filter((entry) => UV_KEYS.includes(entry.key)).at(-1);
127
+ const aliasOffset = lastUv?.offset ?? currentStride;
128
+ for (let k = 0; k < toIndex && k < UV_KEYS.length; k++) {
113
129
  // biome-ignore lint/style/noNonNullAssertion: bounded index on const array
114
130
  const uvKey = UV_KEYS[k]!;
131
+ if (entries.some((entry) => entry.key === uvKey)) continue;
115
132
  entries.push({
116
133
  key: uvKey,
117
134
  shaderLocation: CANONICAL_KEYS.indexOf(uvKey),
@@ -121,7 +138,7 @@ function emitAliasEntries(
121
138
  }
122
139
  // When meshUvSetCount===0, allocate 8 bytes for the zero UV area.
123
140
  // Otherwise no stride increase (aliased to existing offset).
124
- return fromIndex === 0 ? currentStride + ATTRIBUTE_BYTE_STRIDE.uv : currentStride;
141
+ return lastUv === undefined ? currentStride + ATTRIBUTE_BYTE_STRIDE.uv : currentStride;
125
142
  }
126
143
 
127
144
  /**
@@ -138,12 +155,7 @@ function emitAliasEntries(
138
155
  * off-screen). uvSetCount <= 1 yields the canonical 4-attribute single-UV map.
139
156
  */
140
157
  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
- };
158
+ const map = { ...DEFAULT_VERTEX_ATTRIBUTE_MAP } as unknown as Record<string, Float32Array>;
147
159
  for (let set = 1; set < uvSetCount && set < UV_KEYS.length; set++) {
148
160
  // biome-ignore lint/style/noNonNullAssertion: bounded index on const array
149
161
  map[UV_KEYS[set]!] = new Float32Array(0);
@@ -176,21 +188,7 @@ export function deriveVertexBufferLayout(
176
188
 
177
189
  // ── clamp-to-last alias (plan-strategy D-1) ──
178
190
  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
- }
191
+ offset = emitAliasEntries(entries, shaderUvSetCount, offset);
194
192
  }
195
193
 
196
194
  if (present === 0 && shaderUvSetCount === 0) return [];
@@ -224,30 +222,7 @@ export function deriveVertexBufferLayoutFromProjection(
224
222
  let arrayStride = projection.arrayStride;
225
223
  const shaderUvSetCount = opts?.shaderUvSetCount ?? 0;
226
224
  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
- }
225
+ arrayStride = emitAliasEntries(entries, shaderUvSetCount, arrayStride);
251
226
  }
252
227
  entries.sort((a, b) => a.shaderLocation - b.shaderLocation);
253
228
  return projection.attributes.length === 0
@@ -281,6 +256,32 @@ export interface VertexLayoutProjection {
281
256
  readonly digest: string;
282
257
  }
283
258
 
259
+ /**
260
+ * Derive a vertex count from the geometry-owned projection.
261
+ *
262
+ * Every upload, morph write, and draw range must use this same byte-level
263
+ * calculation. Returning `undefined` keeps malformed payloads out of GPU
264
+ * resource construction instead of silently truncating a fractional count.
265
+ */
266
+ export function deriveVertexCount(
267
+ vertices: { readonly byteLength: number },
268
+ projection: VertexLayoutProjection,
269
+ ): number | undefined {
270
+ const stride = projection.arrayStride;
271
+ const byteLength = vertices.byteLength;
272
+ if (
273
+ !Number.isSafeInteger(stride) ||
274
+ stride <= 0 ||
275
+ !Number.isSafeInteger(byteLength) ||
276
+ byteLength < 0 ||
277
+ byteLength % stride !== 0
278
+ ) {
279
+ return undefined;
280
+ }
281
+ const count = byteLength / stride;
282
+ return Number.isSafeInteger(count) ? count : undefined;
283
+ }
284
+
284
285
  function bytesForFormat(format: string): number {
285
286
  if (format === 'uint16x4' || format === 'float32x2') return 8;
286
287
  if (format === 'float32x3') return 12;