• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / luma.gl / 25681495384

11 May 2026 03:59PM UTC coverage: 74.425% (+0.01%) from 74.412%
25681495384

push

github

web-flow
feat(engine); Build interleaved geometries (#2604)

Co-authored-by: Ib Green <ib.green.home@gmail.com>

5442 of 8256 branches covered (65.92%)

Branch coverage included in aggregate %.

83 of 99 new or added lines in 4 files covered. (83.84%)

2 existing lines in 2 files now uncovered.

12129 of 15353 relevant lines covered (79.0%)

764.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.64
/modules/engine/src/geometry/geometry-utils.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {VertexFormat} from '@luma.gl/core';
6
import {vertexFormatDecoder} from '@luma.gl/core';
7
import type {TypedArray} from '@math.gl/core';
8
import {Geometry, getGeometryShaderAttributeName, type GeometryAttribute} from './geometry';
9

10
type TypedArrayConstructor = {
11
  new (length: number): TypedArray;
12
  new (buffer: ArrayBufferLike): TypedArray;
13
  readonly BYTES_PER_ELEMENT: number;
14
};
15

16
type GeometryLike = {
17
  indices?: GeometryAttribute;
18
  attributes: Record<string, GeometryAttribute | undefined>;
19
};
20

21
/** Options for {@link makeInterleavedGeometry}. */
22
export type MakeInterleavedGeometryOptions = {
23
  /** Name of the packed geometry buffer. Defaults to `geometry`. */
24
  bufferName?: string;
25

26
  /** Attribute names to pack. Defaults to all non-index geometry attributes. */
27
  attributes?: string[];
28

29
  /**
30
   * Minimum byte alignment for each packed attribute and for the final byte stride.
31
   *
32
   * Defaults to 4 bytes, matching WebGPU and WebGL vertex-buffer alignment constraints.
33
   */
34
  minAttributeAlignment?: number;
35
};
36

37
type InterleavedAttribute = {
38
  sourceName: string;
39
  attributeName: string;
40
  value: TypedArray;
41
  size: number;
42
  format: VertexFormat;
43
  byteOffset: number;
44
  byteLength: number;
45
};
46

47
/**
48
 * Expands indexed geometry attributes into non-indexed attributes.
49
 *
50
 * The returned object keeps the original attribute keys and replaces each non-constant attribute
51
 * with data expanded through the index buffer. The `indices` field is intentionally omitted from
52
 * the returned geometry-like object.
53
 */
54
export function unpackIndexedGeometry<T extends GeometryLike>(geometry: T): GeometryLike {
55
  const {indices, attributes} = geometry;
3✔
56
  if (!indices) {
3✔
57
    return geometry;
1✔
58
  }
59

60
  const vertexCount = indices.value.length;
2✔
61
  const unpackedAttributes: Record<string, GeometryAttribute> = {};
2✔
62

63
  for (const attributeName in attributes) {
2✔
64
    const attribute = attributes[attributeName];
7✔
65
    if (!attribute) {
7!
NEW
66
      continue; // eslint-disable-line
×
67
    }
68
    const {value, size} = attribute;
7✔
69
    const constant = attribute['constant'];
7✔
70
    if (constant || !size) {
7✔
71
      continue; // eslint-disable-line
1✔
72
    }
73
    const ArrayType = value.constructor as TypedArrayConstructor;
6✔
74
    const unpackedValue = new ArrayType(vertexCount * size);
6✔
75
    for (let x = 0; x < vertexCount; ++x) {
6✔
76
      const index = indices.value[x];
36✔
77
      for (let i = 0; i < size; i++) {
36✔
78
        unpackedValue[x * size + i] = value[index * size + i];
96✔
79
      }
80
    }
81
    unpackedAttributes[attributeName] = {size, value: unpackedValue};
6✔
82
  }
83

84
  return {
2✔
85
    attributes: Object.assign({}, attributes, unpackedAttributes)
86
  };
87
}
88

89
/**
90
 * Packs a CPU {@link Geometry} into one interleaved vertex buffer.
91
 *
92
 * The returned value is a normal `Geometry` whose `attributes` contains one packed typed array,
93
 * and whose `bufferLayout` maps that packed buffer back to the original shader attributes.
94
 * Calling this function on an already interleaved geometry with the same `bufferName` is
95
 * idempotent and returns the original instance.
96
 */
97
export function makeInterleavedGeometry(
98
  geometry: Geometry,
99
  options: MakeInterleavedGeometryOptions = {}
63✔
100
): Geometry {
101
  const bufferName = options.bufferName || 'geometry';
63✔
102
  if (isInterleavedGeometry(geometry, bufferName)) {
63✔
103
    return geometry;
1✔
104
  }
105

106
  const minAttributeAlignment = options.minAttributeAlignment || 4;
62✔
107
  const sourceAttributes = getInterleavedSourceAttributes(geometry, options.attributes);
63✔
108
  const interleavedAttributes: InterleavedAttribute[] = [];
63✔
109
  let byteOffset = 0;
63✔
110
  let attributeVertexCount = Infinity;
63✔
111

112
  for (const [sourceName, attribute] of sourceAttributes) {
63✔
113
    if (!attribute) {
174!
NEW
114
      continue; // eslint-disable-line no-continue
×
115
    }
116
    if (attribute['constant']) {
174!
NEW
117
      throw new Error(`Attribute ${sourceName} is constant`);
×
118
    }
119
    const {value, size, normalized} = attribute;
174✔
120
    if (!ArrayBuffer.isView(value)) {
174!
NEW
121
      throw new Error(`Attribute ${sourceName} is missing typed array data`);
×
122
    }
123
    if (size === undefined) {
174!
NEW
124
      throw new Error(`Attribute ${sourceName} is missing a size`);
×
125
    }
126

127
    const format = vertexFormatDecoder.getVertexFormatFromAttribute(value, size, normalized);
174✔
128
    const vertexFormatInfo = vertexFormatDecoder.getVertexFormatInfo(format);
174✔
129

130
    byteOffset = alignTo(byteOffset, minAttributeAlignment);
174✔
131
    interleavedAttributes.push({
174✔
132
      sourceName,
133
      attributeName: getGeometryShaderAttributeName(sourceName),
134
      value,
135
      size,
136
      format,
137
      byteOffset,
138
      byteLength: vertexFormatInfo.byteLength
139
    });
140
    byteOffset += vertexFormatInfo.byteLength;
174✔
141
    const sourceVertexCount = value.length / size;
174✔
142
    if (!Number.isInteger(sourceVertexCount)) {
174!
NEW
143
      throw new Error(`Attribute ${sourceName} length is not divisible by size`);
×
144
    }
145
    attributeVertexCount = Math.min(attributeVertexCount, sourceVertexCount);
174✔
146
  }
147

148
  if (interleavedAttributes.length === 0 || !Number.isFinite(attributeVertexCount)) {
62!
NEW
149
    throw new Error(`Geometry ${geometry.id} has no interleavable attributes`);
×
150
  }
151

152
  const byteStride = alignTo(byteOffset, minAttributeAlignment);
62✔
153
  const arrayBuffer = new ArrayBuffer(attributeVertexCount * byteStride);
62✔
154

155
  for (const attribute of interleavedAttributes) {
62✔
156
    writeInterleavedAttribute(arrayBuffer, attributeVertexCount, byteStride, attribute);
174✔
157
  }
158

159
  return new Geometry({
62✔
160
    id: geometry.id,
161
    topology: geometry.topology || 'triangle-list',
62!
162
    vertexCount: geometry.vertexCount,
163
    indices: geometry.indices,
164
    attributes: {
165
      [bufferName]: {
166
        value: new Uint8Array(arrayBuffer),
167
        size: byteStride,
168
        byteStride
169
      }
170
    },
171
    bufferLayout: [
172
      {
173
        name: bufferName,
174
        stepMode: 'vertex',
175
        byteStride,
176
        attributes: interleavedAttributes.map(attribute => ({
174✔
177
          attribute: attribute.attributeName,
178
          format: attribute.format,
179
          byteOffset: attribute.byteOffset
180
        }))
181
      }
182
    ]
183
  });
184
}
185

186
function isInterleavedGeometry(geometry: Geometry, bufferName: string): boolean {
187
  if (geometry.bufferLayout.length !== 1) {
63✔
188
    return false;
61✔
189
  }
190

191
  const bufferLayout = geometry.bufferLayout[0];
2✔
192
  return (
2✔
193
    bufferLayout.name === bufferName &&
4✔
194
    Boolean(bufferLayout.attributes?.length) &&
195
    Boolean(geometry.attributes[bufferName])
196
  );
197
}
198

199
function getInterleavedSourceAttributes(
200
  geometry: Geometry,
201
  attributeNames?: string[]
202
): Array<[string, GeometryAttribute | undefined]> {
203
  if (attributeNames) {
62!
NEW
204
    return attributeNames.map(attributeName => {
×
NEW
205
      const normalizedAttributeName = getGeometryShaderAttributeName(attributeName);
×
NEW
206
      return [normalizedAttributeName, geometry.attributes[normalizedAttributeName]];
×
207
    });
208
  }
209
  return Object.entries(geometry.attributes);
62✔
210
}
211

212
function writeInterleavedAttribute(
213
  arrayBuffer: ArrayBuffer,
214
  vertexCount: number,
215
  byteStride: number,
216
  attribute: InterleavedAttribute
217
): void {
218
  const ArrayType = attribute.value.constructor as TypedArrayConstructor;
174✔
219
  const bytesPerElement = ArrayType.BYTES_PER_ELEMENT;
174✔
220

221
  if (attribute.byteOffset % bytesPerElement !== 0 || byteStride % bytesPerElement !== 0) {
174!
NEW
222
    throw new Error(`Attribute ${attribute.sourceName} is not aligned to its component type`);
×
223
  }
224

225
  const target = new ArrayType(arrayBuffer) as any;
174✔
226
  const source = attribute.value as any;
174✔
227
  const elementOffset = attribute.byteOffset / bytesPerElement;
174✔
228
  const elementStride = byteStride / bytesPerElement;
174✔
229

230
  for (let vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) {
174✔
231
    const sourceIndex = vertexIndex * attribute.size;
12,971✔
232
    const targetIndex = vertexIndex * elementStride + elementOffset;
12,971✔
233
    for (let componentIndex = 0; componentIndex < attribute.size; componentIndex++) {
12,971✔
234
      target[targetIndex + componentIndex] = source[sourceIndex + componentIndex];
36,663✔
235
    }
236
  }
237
}
238

239
function alignTo(byteOffset: number, alignment: number): number {
240
  return Math.ceil(byteOffset / alignment) * alignment;
236✔
241
}
242

243
// export function calculateVertexNormals(positions: Float32Array): Uint8Array {
244
//   let normals = new Uint8Array(positions.length / 3);
245

246
//   for (let i = 0; i < positions.length; i++) {
247
//     const vec1 = new Vector3(positions.subarray(i * 3, i + 0, i + 3));
248
//     const vec2 = new Vector3(positions.subarray(i + 3, i + 6));
249
//     const vec3 = new Vector3(positions.subarray(i + 6, i + 9));
250

251
//     const normal = new Vector3(vec1).cross(vec2).normalize();
252
//     normals.set(normal[0], i + 4);
253
//     normals.set(normal[1], i + 4 + 1);
254
//     normals.set(normal[2], i + 2);
255
//   }
256
//   const normal = new Vector3(vec1).cross(vec2).normalize();
257
// }
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc