@bigmistqke/view.gl 0.1.18 → 0.2.0

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/src/types.ts CHANGED
@@ -285,14 +285,7 @@ export type AttributeKind =
285
285
  | 'uvec3'
286
286
  | 'uvec4'
287
287
 
288
- export type AttributeFormat =
289
- | 'float32'
290
- | 'int32'
291
- | 'uint32'
292
- | 'int16'
293
- | 'uint16'
294
- | 'int8'
295
- | 'uint8'
288
+ export type AttributeFormat = 'float32' | 'int32' | 'uint32' | 'int16' | 'uint16' | 'int8' | 'uint8'
296
289
 
297
290
  export interface AttributeDefinition {
298
291
  kind: AttributeKind
@@ -313,7 +306,9 @@ type DefaultFormat<K extends AttributeKind> = K extends IntKind
313
306
  ? 'uint32'
314
307
  : 'float32'
315
308
 
316
- type ResolvedFormat<T extends AttributeDefinition> = T extends { format: infer F extends AttributeFormat }
309
+ type ResolvedFormat<T extends AttributeDefinition> = T extends {
310
+ format: infer F extends AttributeFormat
311
+ }
317
312
  ? F
318
313
  : DefaultFormat<T['kind']>
319
314
 
@@ -327,10 +322,49 @@ type FormatToArray = {
327
322
  uint8: Uint8Array
328
323
  }
329
324
 
330
- export interface AttributeMethods<T extends AttributeDefinition = AttributeDefinition> {
325
+ /**
326
+ * What a participant contributes to a vertex array.
327
+ *
328
+ * `enableVertexAttribArray`, `vertexAttribPointer`, `vertexAttribDivisor` and
329
+ * the `ELEMENT_ARRAY_BUFFER` binding all write into whichever vertex array is
330
+ * bound when they run — they name no destination of their own. That is what
331
+ * makes them a participant in something rather than a property of themselves,
332
+ * and why `vao()` exists: it binds the array first, then asks each participant
333
+ * to write itself in.
334
+ *
335
+ * The returned function undoes the write. It is only used where there is no
336
+ * vertex array to hold the state — a WebGL1 context without
337
+ * `OES_vertex_array_object` — since a real vertex array is thrown away whole.
338
+ */
339
+ export interface VertexArrayParticipant {
340
+ applyToVertexArray(): () => void
341
+ /**
342
+ * State the CONTEXT holds rather than the vertex array, re-applied on every
343
+ * bind because no array can keep it.
344
+ *
345
+ * There is exactly one such thing: the current generic vertex attribute
346
+ * values, which an attribute reads when its array is disabled. The enable
347
+ * flag saying to read them is vertex-array state; the values themselves are
348
+ * not. So a constant source splits across the two hooks — and the tempting
349
+ * optimisation of skipping this when nothing changed would let one draw's
350
+ * constants leak into the next.
351
+ */
352
+ applyToContext?(): void
353
+ }
354
+
355
+ export interface AttributeMethods<T extends AttributeDefinition = AttributeDefinition>
356
+ extends VertexArrayParticipant {
331
357
  buffer: WebGLBuffer
332
- bind(): void
333
- set(data: FormatToArray[ResolvedFormat<T>], usage?: GLUsage): { bind(): void }
358
+ /**
359
+ * Apply this attribute to the DEFAULT vertex array, returning a disposer.
360
+ *
361
+ * Explicitly the default one: these calls have no destination of their own,
362
+ * so without binding first this would edit whichever array happened to be
363
+ * bound — and a vertex array keeps what it is given, so the damage would
364
+ * outlive the call. Use `vao()` to build an array of your own.
365
+ */
366
+ bind(): () => void
367
+ set(data: FormatToArray[ResolvedFormat<T>], usage?: GLUsage): { bind(): () => void }
334
368
  dispose(): void
335
369
  }
336
370
 
@@ -358,12 +392,55 @@ export interface InterleavedAttributeDefinition {
358
392
 
359
393
  export type InterleavedAttributeSchema = Record<string | symbol, InterleavedAttributeDefinition>
360
394
 
395
+ /**
396
+ * A source is where one draw's values for a layout come from. Both kinds are
397
+ * participants, so either can go into a `vao()`.
398
+ */
399
+ export interface Source extends VertexArrayParticipant {
400
+ /**
401
+ * Apply this source to the DEFAULT vertex array, returning a disposer. See
402
+ * `AttributeMethods.bind` — and prefer `vao()`, which is the only way to get
403
+ * a source and its fellow participants into an array of their own.
404
+ */
405
+ bind(): () => void
406
+ }
407
+
408
+ export interface BufferSource extends Source {
409
+ /**
410
+ * The same buffer, stepped once per instance rather than once per vertex.
411
+ *
412
+ * A divisor is a property of the draw, not of the data — the same markers
413
+ * are an instanced fleet in one draw and a single mesh in the next — so it
414
+ * is chosen here rather than declared in the schema.
415
+ */
416
+ perInstance: Source
417
+ set(data: Float32Array, usage?: GLUsage): void
418
+ }
419
+
420
+ export interface ConstantAttributeMethods {
421
+ set(...values: number[]): void
422
+ }
423
+
424
+ /**
425
+ * Per-key setters over the same layout the buffer source uses — a constant
426
+ * attribute is a uniform in all but name, so it is written like one.
427
+ */
428
+ export type ConstantSource<T extends InterleavedAttributeLayout[]> = Source & {
429
+ [K in T[number] as K['key'] extends string ? K['key'] : never]: ConstantAttributeMethods
430
+ }
431
+
361
432
  export interface InterleavedAttributeMethods<
362
433
  T extends InterleavedAttributeLayout[] = InterleavedAttributeLayout[],
363
434
  > {
364
- bind(): void
435
+ buffer: BufferSource
436
+ constant: ConstantSource<T>
437
+ dispose(): void
438
+ }
439
+
440
+ export interface VertexArrayMethods {
441
+ /** Binds the vertex array, returning a disposer that restores the previous one */
442
+ bind(): () => void
365
443
  unbind(): void
366
- set(data: Float32Array, usage?: GLUsage): void
367
444
  dispose(): void
368
445
  }
369
446
 
@@ -384,9 +461,10 @@ export interface BufferDefinition {
384
461
 
385
462
  export type BufferSchema = Record<string | symbol, BufferDefinition>
386
463
 
387
- export interface BufferMethods {
464
+ export interface BufferMethods extends VertexArrayParticipant {
388
465
  set(data: Float32Array | Uint16Array | Uint32Array): void
389
- bind(): void
466
+ /** Binds the buffer, returning a disposer that restores the previous binding */
467
+ bind(): () => void
390
468
  dispose(): void
391
469
  }
392
470
 
@@ -565,6 +643,16 @@ export type View<T extends ViewSchema = ViewSchema> = {
565
643
  : {}
566
644
  buffers: T['buffers'] extends BufferSchema ? Prettify<BufferView<T['buffers']>> : {}
567
645
  uniforms: T['uniforms'] extends UniformSchema ? Prettify<UniformView<T['uniforms']>> : {}
646
+ /**
647
+ * Build a vertex array from the participants one draw needs — its attributes,
648
+ * its interleaved layouts, its element buffer.
649
+ *
650
+ * This is the one member the schema cannot declare, because its input is the
651
+ * schema's own output. It is also the only place a vertex array is created:
652
+ * the participants no longer make one each, so which array they end up in is
653
+ * something you say rather than something you inherit from call order.
654
+ */
655
+ vao(participants: VertexArrayParticipant[]): VertexArrayMethods
568
656
  }
569
657
 
570
658
  /**********************************************************************************/
@@ -4,6 +4,7 @@ import {
4
4
  bufferView,
5
5
  interleavedAttributeView,
6
6
  uniformView,
7
+ vaoView,
7
8
  view,
8
9
  } from '../src/index'
9
10
  import type {
@@ -83,10 +84,11 @@ describe('view', () => {
83
84
 
84
85
  expect(result.interleavedAttributes).toBeDefined()
85
86
  expect(result.interleavedAttributes!.data).toBeDefined()
86
- expect(result.interleavedAttributes!.data.bind).toBeInstanceOf(Function)
87
- expect(result.interleavedAttributes!.data.unbind).toBeInstanceOf(Function)
87
+ expect(result.interleavedAttributes!.data.buffer.bind).toBeInstanceOf(Function)
88
+ expect(result.interleavedAttributes!.data.buffer.perInstance.bind).toBeInstanceOf(Function)
89
+ expect(result.interleavedAttributes!.data.constant.bind).toBeInstanceOf(Function)
88
90
  expect(result.interleavedAttributes!.data.dispose).toBeInstanceOf(Function)
89
- expect(result.interleavedAttributes!.data.set).toBeInstanceOf(Function)
91
+ expect(result.interleavedAttributes!.data.buffer.set).toBeInstanceOf(Function)
90
92
  })
91
93
 
92
94
  it('should create a view with buffers', () => {
@@ -166,7 +168,7 @@ describe('view', () => {
166
168
  expect(result.attributes![a_position_symbol].bind).toBeInstanceOf(Function)
167
169
 
168
170
  expect(result.interleavedAttributes![data_symbol]).toBeDefined()
169
- expect(result.interleavedAttributes![data_symbol].bind).toBeInstanceOf(Function)
171
+ expect(result.interleavedAttributes![data_symbol].buffer.bind).toBeInstanceOf(Function)
170
172
 
171
173
  expect(result.buffers![vertices_symbol]).toBeDefined()
172
174
  expect(result.buffers![vertices_symbol].bind).toBeInstanceOf(Function)
@@ -409,6 +411,64 @@ describe('attributeView', () => {
409
411
  expect(gl.vertexAttribDivisor).toHaveBeenCalledWith(expect.any(Number), 1)
410
412
  })
411
413
 
414
+ it('should return a disposer from bind that clears the instancing divisor', () => {
415
+ const schema = {
416
+ a_instancePosition: { kind: 'vec3', instanced: true },
417
+ } satisfies AttributeSchema
418
+
419
+ const attributes = attributeView(gl, program, schema)
420
+
421
+ // The divisor is global to the location rather than to the program, so
422
+ // leaving it at 1 makes the next non-instanced attribute bound there read
423
+ // a single value for every vertex.
424
+ const unbind = attributes.a_instancePosition.bind()
425
+ expect(gl.vertexAttribDivisor).toHaveBeenCalledWith(expect.any(Number), 1)
426
+
427
+ unbind()
428
+ expect(gl.vertexAttribDivisor).toHaveBeenLastCalledWith(expect.any(Number), 0)
429
+ })
430
+
431
+ it('should state a zero divisor for non-instanced attributes rather than inherit one', () => {
432
+ const schema = {
433
+ a_position: { kind: 'vec2' },
434
+ } satisfies AttributeSchema
435
+
436
+ const attributes = attributeView(gl, program, schema)
437
+
438
+ // A location left at 1 by an earlier instanced attribute would otherwise
439
+ // feed a single value to every vertex here.
440
+ attributes.a_position.bind()
441
+ expect(gl.vertexAttribDivisor).toHaveBeenCalledWith(expect.any(Number), 0)
442
+ })
443
+
444
+ it('should restore the previous divisor rather than assume it was zero', () => {
445
+ const schema = {
446
+ a_instancePosition: { kind: 'vec3', instanced: true },
447
+ } satisfies AttributeSchema
448
+
449
+ const attributes = attributeView(gl, program, schema)
450
+ ;(gl.getVertexAttrib as any).mockReturnValue(2)
451
+
452
+ const unbind = attributes.a_instancePosition.bind()
453
+ unbind()
454
+ expect(gl.vertexAttribDivisor).toHaveBeenLastCalledWith(expect.any(Number), 2)
455
+ })
456
+
457
+ it('should return a disposer that only restores once', () => {
458
+ const schema = {
459
+ a_instancePosition: { kind: 'vec3', instanced: true },
460
+ } satisfies AttributeSchema
461
+
462
+ const attributes = attributeView(gl, program, schema)
463
+
464
+ const unbind = attributes.a_instancePosition.bind()
465
+ unbind()
466
+ const callsAfterFirst = (gl.vertexAttribDivisor as any).mock.calls.length
467
+ // Undoing twice would put back state a later bind() is relying on.
468
+ unbind()
469
+ expect((gl.vertexAttribDivisor as any).mock.calls).toHaveLength(callsAfterFirst)
470
+ })
471
+
412
472
  it('should use custom buffer if provided', () => {
413
473
  const customBuffer = gl.createBuffer()!
414
474
  const schema = {
@@ -720,10 +780,38 @@ describe('interleavedAttributeView', () => {
720
780
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
721
781
 
722
782
  expect(interleavedAttributes.vertexData).toBeDefined()
723
- expect(interleavedAttributes.vertexData.bind).toBeInstanceOf(Function)
724
- expect(interleavedAttributes.vertexData.unbind).toBeInstanceOf(Function)
783
+ expect(interleavedAttributes.vertexData.buffer.bind).toBeInstanceOf(Function)
784
+ expect(interleavedAttributes.vertexData.buffer.perInstance.bind).toBeInstanceOf(Function)
725
785
  expect(interleavedAttributes.vertexData.dispose).toBeInstanceOf(Function)
726
- expect(interleavedAttributes.vertexData.set).toBeInstanceOf(Function)
786
+ expect(interleavedAttributes.vertexData.buffer.set).toBeInstanceOf(Function)
787
+ })
788
+
789
+ it('should return a disposer from bind that restores the default vertex array', () => {
790
+ const schema = {
791
+ vertexData: {
792
+ layout: [
793
+ { key: 'a_position', kind: 'vec3' },
794
+ { key: 'a_color', kind: 'vec4' },
795
+ ],
796
+ instanced: true,
797
+ },
798
+ } satisfies InterleavedAttributeSchema
799
+
800
+ const interleavedAttributes = interleavedAttributeView(gl, program, schema)
801
+
802
+ // A bound vertex array stays selected for everything drawn afterwards, so
803
+ // the next draw would write its own attributes into THIS array and corrupt
804
+ // the layer that owns it. The disposer hands the default back.
805
+ // Restoring the previous array rather than null keeps nested binds working.
806
+ const previous = { id: 'previously-bound-vao' }
807
+ ;(gl.getParameter as any).mockReturnValue(previous)
808
+
809
+ const unbind = interleavedAttributes.vertexData.buffer.bind()
810
+ expect(unbind).toBeInstanceOf(Function)
811
+
812
+ ;(gl.bindVertexArray as any).mockClear()
813
+ unbind()
814
+ expect(gl.bindVertexArray).toHaveBeenCalledWith(previous)
727
815
  })
728
816
 
729
817
  it('should calculate correct stride and offsets', () => {
@@ -740,7 +828,7 @@ describe('interleavedAttributeView', () => {
740
828
 
741
829
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
742
830
 
743
- interleavedAttributes.vertexData.bind()
831
+ interleavedAttributes.vertexData.buffer.bind()
744
832
 
745
833
  // Check vertexAttribPointer calls
746
834
  const calls = (gl.vertexAttribPointer as any).mock.calls
@@ -764,23 +852,28 @@ describe('interleavedAttributeView', () => {
764
852
  expect(calls[2][5]).toBe(24) // offset
765
853
  })
766
854
 
767
- it('should handle instanced interleaved attributes', () => {
855
+ it('steps per instance or per vertex from the same buffer', () => {
856
+ // A divisor is a property of the draw, not of the data, so it is chosen
857
+ // here rather than declared in the schema.
768
858
  const schema = {
769
859
  instanceData: {
770
860
  layout: [
771
861
  { key: 'a_instancePos', kind: 'vec3' },
772
862
  { key: 'a_instanceScale', kind: 'float' },
773
863
  ],
774
- instanced: true,
775
864
  },
776
865
  } satisfies InterleavedAttributeSchema
777
866
 
778
867
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
779
868
 
780
- interleavedAttributes.instanceData.bind()
781
-
869
+ interleavedAttributes.instanceData.buffer.perInstance.bind()
782
870
  expect(gl.vertexAttribDivisor).toHaveBeenCalledTimes(2)
783
871
  expect(gl.vertexAttribDivisor).toHaveBeenCalledWith(expect.any(Number), 1)
872
+
873
+ vi.mocked(gl.vertexAttribDivisor).mockClear()
874
+
875
+ interleavedAttributes.instanceData.buffer.bind()
876
+ expect(gl.vertexAttribDivisor).toHaveBeenCalledWith(expect.any(Number), 0)
784
877
  })
785
878
 
786
879
  it('should handle integer attributes in layout', () => {
@@ -797,7 +890,7 @@ describe('interleavedAttributeView', () => {
797
890
 
798
891
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
799
892
 
800
- interleavedAttributes.data.bind()
893
+ interleavedAttributes.data.buffer.bind()
801
894
 
802
895
  const floatCalls = (gl.vertexAttribPointer as any).mock.calls
803
896
  const intCalls = (gl.vertexAttribIPointer as any).mock.calls
@@ -822,7 +915,7 @@ describe('interleavedAttributeView', () => {
822
915
  } satisfies InterleavedAttributeSchema
823
916
 
824
917
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
825
- interleavedAttributes.data.bind()
918
+ interleavedAttributes.data.buffer.bind()
826
919
 
827
920
  const calls = (gl.vertexAttribPointer as any).mock.calls
828
921
  const stride = 16 // 8 + 4 + 4
@@ -875,25 +968,13 @@ describe('interleavedAttributeView', () => {
875
968
  1, // color
876
969
  ])
877
970
 
878
- interleavedAttributes.vertexData.set(data)
971
+ interleavedAttributes.vertexData.buffer.set(data)
879
972
  expect(gl.bufferData).toHaveBeenCalledWith(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
880
973
  })
881
974
 
882
- it('should use VAO when available', () => {
883
- const vertexArray = { id: 1 }
884
- const vaoExt = {
885
- createVertexArrayOES: vi.fn(() => vertexArray),
886
- bindVertexArrayOES: vi.fn(),
887
- deleteVertexArrayOES: vi.fn(),
888
- }
889
-
890
- // Override the getExtension method for this test
891
- const originalGetExtension = gl.getExtension
892
- gl.getExtension = vi.fn((name: string) => {
893
- if (name === 'OES_vertex_array_object') return vaoExt as any
894
- return originalGetExtension.call(gl, name)
895
- }) as any
896
-
975
+ it('does not create a vertex array of its own', () => {
976
+ // A layout used to make one here, which is why the other participants in a
977
+ // draw had to be bound after it to be included. Arrays are vao()'s job.
897
978
  const schema = {
898
979
  vertexData: {
899
980
  layout: [{ key: 'a_position', kind: 'vec2' }],
@@ -901,26 +982,9 @@ describe('interleavedAttributeView', () => {
901
982
  },
902
983
  } satisfies InterleavedAttributeSchema
903
984
 
904
- const interleavedAttributes = interleavedAttributeView(gl as any, program, schema)
985
+ interleavedAttributeView(gl as any, program, schema)
905
986
 
906
- expect(vaoExt.createVertexArrayOES).toHaveBeenCalled()
907
- expect(vaoExt.bindVertexArrayOES).toHaveBeenCalledWith(vertexArray)
908
- expect(vaoExt.bindVertexArrayOES).toHaveBeenCalledWith(null) // unbind after setup
909
-
910
- // Test bind
911
- interleavedAttributes.vertexData.bind()
912
- expect(vaoExt.bindVertexArrayOES).toHaveBeenCalledWith(vertexArray)
913
-
914
- // Test unbind
915
- interleavedAttributes.vertexData.unbind()
916
- expect(vaoExt.bindVertexArrayOES).toHaveBeenCalledWith(null)
917
-
918
- // Test dispose
919
- interleavedAttributes.vertexData.dispose()
920
- expect(vaoExt.deleteVertexArrayOES).toHaveBeenCalledWith(vertexArray)
921
-
922
- // Restore original method
923
- gl.getExtension = originalGetExtension
987
+ expect(gl.createVertexArray).not.toHaveBeenCalled()
924
988
  })
925
989
 
926
990
  it('should dispose buffers', () => {
@@ -999,13 +1063,13 @@ describe('interleavedAttributeView', () => {
999
1063
  const interleavedAttributes = interleavedAttributeView(gl, program, schema)
1000
1064
 
1001
1065
  expect(interleavedAttributes[data_symbol]).toBeDefined()
1002
- expect(interleavedAttributes[data_symbol].bind).toBeInstanceOf(Function)
1003
- expect(interleavedAttributes[data_symbol].unbind).toBeInstanceOf(Function)
1066
+ expect(interleavedAttributes[data_symbol].buffer.bind).toBeInstanceOf(Function)
1067
+ expect(interleavedAttributes[data_symbol].buffer.perInstance.bind).toBeInstanceOf(Function)
1004
1068
  expect(interleavedAttributes[data_symbol].dispose).toBeInstanceOf(Function)
1005
- expect(interleavedAttributes[data_symbol].set).toBeInstanceOf(Function)
1069
+ expect(interleavedAttributes[data_symbol].buffer.set).toBeInstanceOf(Function)
1006
1070
 
1007
1071
  // Test binding
1008
- interleavedAttributes[data_symbol].bind()
1072
+ interleavedAttributes[data_symbol].buffer.bind()
1009
1073
 
1010
1074
  // Should bind buffer and set up attributes
1011
1075
  expect(gl.bindBuffer).toHaveBeenCalled()
@@ -1033,7 +1097,7 @@ describe('interleavedAttributeView', () => {
1033
1097
  expect(interleavedAttributes[vertexData]).toBeDefined()
1034
1098
 
1035
1099
  // Test that it correctly calculates stride
1036
- interleavedAttributes[vertexData].bind()
1100
+ interleavedAttributes[vertexData].buffer.bind()
1037
1101
 
1038
1102
  const calls = (gl.vertexAttribPointer as any).mock.calls
1039
1103
  // All calls should have the same stride (3+3+2)*4 = 32 bytes
@@ -1080,6 +1144,26 @@ describe('bufferView', () => {
1080
1144
  expect(gl.bindBuffer).toHaveBeenCalledWith(gl.ELEMENT_ARRAY_BUFFER, expect.any(Object))
1081
1145
  })
1082
1146
 
1147
+ it('should return a disposer from bind that releases the target', () => {
1148
+ const schema = {
1149
+ indices: { target: 'ELEMENT_ARRAY_BUFFER' },
1150
+ } satisfies BufferSchema
1151
+
1152
+ const buffers = bufferView(gl, schema)
1153
+
1154
+ // An ELEMENT_ARRAY_BUFFER binding is recorded in whichever vertex array is
1155
+ // bound, so restoring the previous buffer rather than null keeps the
1156
+ // disposer from stripping the index buffer off an array we do not own.
1157
+ const previous = { id: 'previously-bound' }
1158
+ ;(gl.getParameter as any).mockReturnValue(previous)
1159
+
1160
+ const unbind = buffers.indices.bind()
1161
+ expect(unbind).toBeInstanceOf(Function)
1162
+
1163
+ unbind()
1164
+ expect(gl.bindBuffer).toHaveBeenLastCalledWith(gl.ELEMENT_ARRAY_BUFFER, previous)
1165
+ })
1166
+
1083
1167
  it('should set buffer data with specified usage', () => {
1084
1168
  const schema = {
1085
1169
  vertices: { target: 'ARRAY_BUFFER', usage: 'STATIC_DRAW' },
@@ -1166,3 +1250,198 @@ describe('bufferView', () => {
1166
1250
  expect(gl.bufferData).toHaveBeenCalledWith(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW)
1167
1251
  })
1168
1252
  })
1253
+
1254
+ describe('vaoView', () => {
1255
+ let gl: WebGL2RenderingContext
1256
+ let program: WebGLProgram
1257
+ beforeEach(() => {
1258
+ const mock = createMockCanvas()
1259
+ gl = mock.gl
1260
+ program = gl.createProgram()!
1261
+ })
1262
+
1263
+ function participants() {
1264
+ const attributes = attributeView(gl as any, program, {
1265
+ a_corner: { kind: 'vec2' },
1266
+ } satisfies AttributeSchema)
1267
+ const interleaved = interleavedAttributeView(gl as any, program, {
1268
+ a_instance: {
1269
+ layout: [{ key: 'a_position', kind: 'vec2' }],
1270
+ },
1271
+ } satisfies InterleavedAttributeSchema)
1272
+ const buffers = bufferView(gl as any, {
1273
+ b_index: { target: 'ELEMENT_ARRAY_BUFFER' },
1274
+ } satisfies BufferSchema)
1275
+ return { attributes, interleaved, buffers }
1276
+ }
1277
+
1278
+ it('writes every participant into one array, at construction', () => {
1279
+ const { attributes, interleaved, buffers } = participants()
1280
+ const vertexArray = { id: 'vao' } as unknown as WebGLVertexArrayObject
1281
+ gl.createVertexArray = vi.fn(() => vertexArray) as any
1282
+
1283
+ const calls: string[] = []
1284
+ gl.bindVertexArray = vi.fn(v => calls.push(v === vertexArray ? 'bind' : 'unbind')) as any
1285
+ gl.vertexAttribPointer = vi.fn(() => calls.push('pointer')) as any
1286
+ gl.bindBuffer = vi.fn((target: number) =>
1287
+ calls.push(target === gl.ELEMENT_ARRAY_BUFFER ? 'indices' : 'array'),
1288
+ ) as any
1289
+
1290
+ vaoView(gl as any, [
1291
+ attributes.a_corner,
1292
+ interleaved.a_instance.buffer.perInstance,
1293
+ buffers.b_index,
1294
+ ])
1295
+
1296
+ // Every participant's state is written while the new array is bound —
1297
+ // that is the whole contract, and what call order used to decide.
1298
+ const bound = calls.indexOf('bind')
1299
+ const unbound = calls.lastIndexOf('unbind')
1300
+ expect(bound).toBeGreaterThanOrEqual(0)
1301
+ expect(calls.indexOf('pointer')).toBeGreaterThan(bound)
1302
+ expect(calls.indexOf('indices')).toBeGreaterThan(bound)
1303
+ expect(unbound).toBeGreaterThan(calls.lastIndexOf('indices'))
1304
+ })
1305
+
1306
+ it('binds and restores, and disposes the array', () => {
1307
+ const { attributes } = participants()
1308
+ const vertexArray = { id: 'vao' } as unknown as WebGLVertexArrayObject
1309
+ const foreign = { id: 'foreign' } as unknown as WebGLVertexArrayObject
1310
+ // Swapped before the first getVertexArrayObject call: on a context without
1311
+ // native vertex arrays the extension wrapper binds these once, up front.
1312
+ gl.createVertexArray = vi.fn(() => vertexArray) as any
1313
+ const bindVertexArray = vi.fn()
1314
+ gl.bindVertexArray = bindVertexArray as any
1315
+ gl.getParameter = vi.fn(() => foreign) as any
1316
+
1317
+ const vao = vaoView(gl as any, [attributes.a_corner])
1318
+ bindVertexArray.mockClear()
1319
+
1320
+ const restore = vao.bind()
1321
+ expect(bindVertexArray).toHaveBeenLastCalledWith(vertexArray)
1322
+ restore()
1323
+ expect(bindVertexArray).toHaveBeenLastCalledWith(foreign)
1324
+
1325
+ vao.dispose()
1326
+ expect(gl.deleteVertexArray).toHaveBeenCalledWith(vertexArray)
1327
+ })
1328
+
1329
+ it('re-applies each participant per bind when the context has no vertex arrays', () => {
1330
+ // WebGL1 without OES_vertex_array_object: nowhere to keep the state, so
1331
+ // the array is emulated by doing the work again on every bind.
1332
+ const mock = createMockGL()
1333
+ const gl1 = mock as any
1334
+ gl1.createVertexArray = undefined
1335
+ gl1.getExtension = vi.fn(() => null)
1336
+ const program1 = gl1.createProgram()
1337
+
1338
+ const attributes = attributeView(gl1, program1, {
1339
+ a_corner: { kind: 'vec2' },
1340
+ } satisfies AttributeSchema)
1341
+
1342
+ const vao = vaoView(gl1, [attributes.a_corner])
1343
+ expect(gl1.vertexAttribPointer).not.toHaveBeenCalled()
1344
+
1345
+ vao.bind()
1346
+ expect(gl1.vertexAttribPointer).toHaveBeenCalledTimes(1)
1347
+ vao.bind()
1348
+ expect(gl1.vertexAttribPointer).toHaveBeenCalledTimes(2)
1349
+ })
1350
+ })
1351
+
1352
+ describe('binding a participant on its own', () => {
1353
+ let gl: WebGL2RenderingContext
1354
+ let program: WebGLProgram
1355
+ beforeEach(() => {
1356
+ const mock = createMockCanvas()
1357
+ gl = mock.gl
1358
+ program = gl.createProgram()!
1359
+ })
1360
+
1361
+ it('targets the default vertex array, not whichever one is current', () => {
1362
+ // The pointer calls name no destination. Without binding first, this would
1363
+ // edit a caller's array — and an array keeps what it is given, so the
1364
+ // damage would outlive the call.
1365
+ const foreign = { id: 'foreign' } as unknown as WebGLVertexArrayObject
1366
+ gl.getParameter = vi.fn(() => foreign) as any
1367
+ const bindVertexArray = vi.fn()
1368
+ gl.bindVertexArray = bindVertexArray as any
1369
+
1370
+ const attributes = attributeView(gl as any, program, {
1371
+ a_corner: { kind: 'vec2' },
1372
+ } satisfies AttributeSchema)
1373
+
1374
+ const restore = attributes.a_corner.bind()
1375
+ expect(bindVertexArray).toHaveBeenCalledWith(null)
1376
+ restore()
1377
+ expect(bindVertexArray).toHaveBeenLastCalledWith(foreign)
1378
+ })
1379
+ })
1380
+
1381
+ describe('the constant source', () => {
1382
+ let gl: WebGL2RenderingContext
1383
+ let program: WebGLProgram
1384
+ beforeEach(() => {
1385
+ const mock = createMockCanvas()
1386
+ gl = mock.gl
1387
+ program = gl.createProgram()!
1388
+ })
1389
+
1390
+ const schema = {
1391
+ a_instance: {
1392
+ layout: [
1393
+ { key: 'a_pos', kind: 'vec2' },
1394
+ { key: 'a_size', kind: 'float' },
1395
+ ],
1396
+ },
1397
+ } satisfies InterleavedAttributeSchema
1398
+
1399
+ it('gives the layout per-key setters, like a uniform', () => {
1400
+ const { a_instance } = interleavedAttributeView(gl as any, program, schema)
1401
+
1402
+ expect(a_instance.constant.a_pos.set).toBeInstanceOf(Function)
1403
+ expect(a_instance.constant.a_size.set).toBeInstanceOf(Function)
1404
+ })
1405
+
1406
+ it('disables the arrays, so the attributes read the constants', () => {
1407
+ const { a_instance } = interleavedAttributeView(gl as any, program, schema)
1408
+
1409
+ a_instance.constant.applyToVertexArray()
1410
+
1411
+ expect(gl.disableVertexAttribArray).toHaveBeenCalledTimes(2)
1412
+ })
1413
+
1414
+ it('writes the values on every bind, not once into the array', () => {
1415
+ // The enable flag is vertex-array state; the values are context state, and
1416
+ // no array can keep them. Applying them once at construction would leave a
1417
+ // later draw showing whatever the previous one set.
1418
+ const { a_instance } = interleavedAttributeView(gl as any, program, schema)
1419
+ a_instance.constant.a_pos.set(1, 2)
1420
+ a_instance.constant.a_size.set(4)
1421
+
1422
+ const vao = vaoView(gl as any, [a_instance.constant])
1423
+ expect(gl.vertexAttrib2f).not.toHaveBeenCalled()
1424
+
1425
+ vao.bind()
1426
+ expect(gl.vertexAttrib2f).toHaveBeenCalledWith(expect.any(Number), 1, 2)
1427
+ expect(gl.vertexAttrib1f).toHaveBeenCalledWith(expect.any(Number), 4)
1428
+
1429
+ a_instance.constant.a_pos.set(7, 8)
1430
+ vao.bind()
1431
+ expect(gl.vertexAttrib2f).toHaveBeenLastCalledWith(expect.any(Number), 7, 8)
1432
+ })
1433
+
1434
+ it('refuses a kind vertexAttrib* cannot express, when asked for one', () => {
1435
+ // A hard limit of the API, not a choice: vertexAttrib* takes at most four
1436
+ // floats and has no matrix form. The buffer source handles it fine, so the
1437
+ // complaint waits until someone reaches for a constant.
1438
+ const matrix = {
1439
+ a_instance: { layout: [{ key: 'a_model', kind: 'mat4' }] },
1440
+ } satisfies InterleavedAttributeSchema
1441
+
1442
+ const { a_instance } = interleavedAttributeView(gl as any, program, matrix)
1443
+
1444
+ expect(() => a_instance.buffer.bind()).not.toThrow()
1445
+ expect(() => a_instance.constant).toThrow(/cannot be a constant attribute/)
1446
+ })
1447
+ })