diff --git a/src/engine/graphics/context/shader.ts b/src/engine/graphics/context/shader.ts index 76b7e480..8f45ee28 100644 --- a/src/engine/graphics/context/shader.ts +++ b/src/engine/graphics/context/shader.ts @@ -9,14 +9,14 @@ import { parseImageWrapping, Vector } from '../..'; -import type { Matrix } from '../../math/matrix'; +import { Matrix } from '../../math/matrix'; import { watch } from '../../util/watch'; import { UniformBuffer } from './uniform-buffer'; import { getAttributeComponentSize, getAttributePointerType } from './webgl-util'; export type UniformDictionary = Record< string, - number | boolean | Vector | Color | AffineMatrix | Float32Array | [uniformData: Float32Array, bindingPoint: number] + number | boolean | Vector | Color | AffineMatrix | Matrix | Float32Array | [uniformData: Float32Array, bindingPoint: number] >; /* * List of the possible glsl uniform types @@ -98,19 +98,19 @@ export function glTypeToUniformTypeName(gl: WebGL2RenderingContext, glType: numb return 'uniform4i'; } case gl.FLOAT_MAT2: { - return 'uniform1f'; + return 'uniformMatrix2fv'; } case gl.FLOAT_MAT3: { - return 'uniform1f'; + return 'uniformMatrix3fv'; } case gl.FLOAT_MAT4: { - return 'uniform1f'; + return 'uniformMatrix4fv'; } case gl.SAMPLER_2D: { - return 'uniform1f'; + return 'uniform1i'; } case gl.SAMPLER_CUBE: { - return 'uniform1f'; + return 'uniform1i'; } case gl.UNSIGNED_INT: { return 'uniform1ui'; @@ -143,28 +143,43 @@ export function glTypeToUniformTypeName(gl: WebGL2RenderingContext, glType: numb return 'uniformMatrix4x3fv'; } case gl.SAMPLER_2D_ARRAY: { - return 'uniform1fv'; + return 'uniform1i'; } case gl.SAMPLER_2D_ARRAY_SHADOW: { - return 'uniform1f'; + return 'uniform1i'; } case gl.SAMPLER_CUBE_SHADOW: { - return 'uniform1f'; + return 'uniform1i'; } case gl.INT_SAMPLER_2D: { - return 'uniform1f'; + return 'uniform1i'; } case gl.INT_SAMPLER_3D: { - return 'uniform1f'; + return 'uniform1i'; } case gl.INT_SAMPLER_CUBE: { - return 'uniform1f'; + return 'uniform1i'; } case gl.INT_SAMPLER_2D_ARRAY: { - return 'uniform1f'; + return 'uniform1i'; } case gl.UNSIGNED_INT_SAMPLER_2D: { - return 'uniform1ui'; + return 'uniform1i'; + } + case gl.SAMPLER_3D: { + return 'uniform1i'; + } + case gl.SAMPLER_2D_SHADOW: { + return 'uniform1i'; + } + case gl.UNSIGNED_INT_SAMPLER_3D: { + return 'uniform1i'; + } + case gl.UNSIGNED_INT_SAMPLER_CUBE: { + return 'uniform1i'; + } + case gl.UNSIGNED_INT_SAMPLER_2D_ARRAY: { + return 'uniform1i'; } default: { throw new Error(`Unknown uniform type: ${glType}`); @@ -282,6 +297,12 @@ export class Shader { private _uniforms: { [variableName: string]: UniformDefinition } = {}; private _uniformBuffers: { [blockName: string]: UniformBuffer } = {}; + private _uniformBufferBindingPoints: { [blockName: string]: number } = {}; + private _uniformBlockIndexCache: { [blockName: string]: number } = {}; + private _uniformBlockBindingSet: { [blockName: string]: boolean } = {}; + private _boundBufferBase: Map = new Map(); + private _nextUniformBufferBindingPoint = 0; + private _affineMatrixCache = new Float32Array(16); private _compiled = false; private _onPreLink?: (program: WebGLProgram) => void; private _onPostCompile?: (shader: Shader) => void; @@ -327,13 +348,13 @@ export class Shader { } this._gl = graphicsContext.__gl; this._startingTextureSlot = startingTextureSlot ?? this._startingTextureSlot; - this._maxTextureSlots = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS) - this._startingTextureSlot; + this._maxTextureSlots = this._gl.getParameter(this._gl.MAX_TEXTURE_IMAGE_UNITS); this.vertexSource = vertexSource; this.fragmentSource = fragmentSource; this.uniforms = watch(uniforms ?? this.uniforms, () => this.flagUniformsDirty()); this.images = images ?? this.images; const keys = Object.keys(this.images); - if (keys.length >= this._maxTextureSlots) { + if (keys.length > this._maxTextureSlots - this._startingTextureSlot) { this._logger.warn( `Max number texture slots ${this._maxTextureSlots} have been reached for material "${this.name}", ` + `no more textures will be uploaded due to hardware constraints.` @@ -348,8 +369,25 @@ export class Shader { * Deletes the webgl program from the gpu */ dispose() { + if (!this._gl) { + return; + } const gl = this._gl; gl.deleteProgram(this.program); + + for (const buffer of Object.values(this._uniformBuffers)) { + buffer.dispose(); + } + this._uniformBuffers = {}; + this._uniformBlockIndexCache = {}; + this._uniformBlockBindingSet = {}; + this._boundBufferBase.clear(); + this._uniformBufferBindingPoints = {}; + this._nextUniformBufferBindingPoint = 0; + + // Textures are owned by TextureLoader, do not delete them here + this._textures.clear(); + this._gl = null as any; } @@ -357,6 +395,9 @@ export class Shader { * Binds the shader program */ use() { + if (!this._gl) { + throw new Error(`Shader "${this.name}" has been disposed and cannot be used. Create a new shader instance.`); + } const gl = this._gl; gl.useProgram(this.program); Shader._ACTIVE_SHADER_INSTANCE = this; @@ -369,6 +410,9 @@ export class Shader { } unuse() { + if (!this._gl) { + return; + } const gl = this._gl; Shader._ACTIVE_SHADER_INSTANCE = null; gl.useProgram(null); @@ -381,35 +425,66 @@ export class Shader { private _setUniforms() { const gl = this._gl; const entries = Object.entries(this.uniforms); - if (entries.length) { - const uniforms = this.getUniformDefinitions(); - for (const [key, value] of entries) { - if (value instanceof Float32Array) { + for (const [key, value] of entries) { + const uniform = this._uniforms[key]; + if (value instanceof Float32Array) { + const blockIndex = this._getUniformBlockIndex(key); + if (blockIndex !== gl.INVALID_INDEX) { this.setUniformBuffer(key, value); - } else if (Array.isArray(value) && value[0] instanceof Float32Array && typeof value[1] === 'number') { - this.setUniformBuffer(key, value[0], value[1]); - } else if (typeof value === 'number') { + } else if (uniform) { + let typeName = glTypeToUniformTypeName(gl, uniform.glType); + if (typeName.startsWith('uniformMatrix')) { + this.trySetUniform(typeName, key, false, value); + } else { + if (!typeName.endsWith('v')) { + typeName = (typeName + 'v') as UniformTypeNames; + } + this.trySetUniform(typeName, key, value); + } + } else { + this.trySetUniformFloatArray(key, value); + } + } else if (Array.isArray(value) && value[0] instanceof Float32Array && typeof value[1] === 'number') { + this.setUniformBuffer(key, value[0], value[1]); + } else if (typeof value === 'number') { + if (!uniform) { this.trySetUniformFloat(key, value); - } else if (typeof value === 'boolean') { - this.trySetUniformBoolean(key, value); - } else if (value instanceof Vector) { - this.trySetUniformFloatVector(key, value); - } else if (value instanceof Color) { - this.trySetUniformFloatColor(key, value); - } else if (value instanceof AffineMatrix) { - this.setUniformAffineMatrix(key, value); } else { - const uniform = uniforms.find((u) => u.name === key); - if (uniform) { - const typeName = glTypeToUniformTypeName(gl, uniform.glType) as UniformTypeNames; - this.trySetUniform(typeName as any, key, value); + const typeName = glTypeToUniformTypeName(gl, uniform.glType); + if (typeName === 'uniform1i') { + this.trySetUniformInt(key, ~~value); + } else if (typeName === 'uniform1ui') { + this.trySetUniform('uniform1ui', key, value >>> 0); } else { - this._logger.warnOnce( - `Could not locate uniform named ${key},` + - ` this can happen if the uniform is unused in the shader code some GPUs will remove this as an optimization.` - ); + this.trySetUniformFloat(key, value); } } + } else if (typeof value === 'boolean') { + this.trySetUniformBoolean(key, value); + } else if (value instanceof Vector) { + if (uniform?.glType === gl.FLOAT_VEC3) { + this.trySetUniform('uniform3f', key, value.x, value.y, 0); + } else if (uniform?.glType === gl.FLOAT_VEC4) { + this.trySetUniform('uniform4f', key, value.x, value.y, 0, 0); + } else { + this.trySetUniformFloatVector(key, value); + } + } else if (value instanceof Color) { + this.trySetUniformFloatColor(key, value); + } else if (value instanceof AffineMatrix) { + this.setUniformAffineMatrix(key, value); + } else if (value instanceof Matrix) { + this.setUniformMatrix(key, value); + } else { + if (uniform) { + const typeName = glTypeToUniformTypeName(gl, uniform.glType) as UniformTypeNames; + this.trySetUniform(typeName as any, key, value); + } else { + this._logger.warnOnce( + `Could not locate uniform named ${key},` + + ` this can happen if the uniform is unused in the shader code some GPUs will remove this as an optimization.` + ); + } } } } @@ -432,8 +507,8 @@ export class Shader { ); // remove force attribute after upload imageElement.removeAttribute('forceUpload'); - if (!this._textures.has(image)) { - this._textures.set(image, texture!); + if (texture && !this._textures.has(image)) { + this._textures.set(image, texture); } return texture; @@ -452,12 +527,22 @@ export class Shader { ); } continue; - } // skip unloaded images, maybe warn + } + if (textureSlot >= this._maxTextureSlots) { + if (!suppressWarning) { + this._logger.warnOnce( + `Max number texture slots ${this._maxTextureSlots} have been reached for material "${this.name}", ` + + `no more textures will be uploaded due to hardware constraints.` + ); + } + break; + } const texture = this._loadImageSource(image); if (!texture) { if (!suppressWarning) { this._logger.warnOnce(`Image ${textureName} (${image.image.src}) could not be loaded for some reason in shader ${this.name}`); } + continue; } gl.activeTexture(gl.TEXTURE0 + textureSlot); gl.bindTexture(gl.TEXTURE_2D, texture); @@ -515,7 +600,10 @@ export class Shader { const uniforms: UniformDefinition[] = []; for (let i = 0; i < uniformCount; i++) { const uniform = gl.getActiveUniform(this.program, i)!; - const uniformLocation = gl.getUniformLocation(this.program, uniform.name)!; + const uniformLocation = gl.getUniformLocation(this.program, uniform.name); + if (uniformLocation === null) { + continue; + } uniforms.push({ name: uniform.name, glType: uniform.type, @@ -574,35 +662,77 @@ export class Shader { gl.bindTexture(gl.TEXTURE_2D, texture); } + private _getUniformBlockIndex(name: string): number { + if (this._uniformBlockIndexCache[name] !== undefined) { + return this._uniformBlockIndexCache[name]; + } + const index = this._gl.getUniformBlockIndex(this.program, name); + this._uniformBlockIndexCache[name] = index; + return index; + } + /** * Set a uniform buffer block with a Float32Array * @param name The of the binding block * @param data Float32Array * @param [bindingPoint] */ - setUniformBuffer(name: string, data: Float32Array, bindingPoint: number = 0): void { + setUniformBuffer(name: string, data: Float32Array, bindingPoint?: number): void { + if (!this._compiled) { + throw Error(`Must compile shader before setting a uniform block ${name}`); + } + if (!this.isCurrentlyBound()) { + throw Error( + 'Currently accessed shader instance is not the current active shader in WebGL,' + + ' must call `shader.use()` before setting uniforms' + ); + } const gl = this._gl; - const index = gl.getUniformBlockIndex(this.program, name); + const index = this._getUniformBlockIndex(name); if (index === gl.INVALID_INDEX) { this._logger.warnOnce(`Invalid block name ${name}`); + return; + } + if (bindingPoint === undefined) { + if (this._uniformBufferBindingPoints[name] !== undefined) { + bindingPoint = this._uniformBufferBindingPoints[name]; + } else { + bindingPoint = this._nextUniformBufferBindingPoint++; + this._uniformBufferBindingPoints[name] = bindingPoint; + } } let uniformBuffer: UniformBuffer; if (this._uniformBuffers[name]) { uniformBuffer = this._uniformBuffers[name]; - uniformBuffer.bufferData.set(data); - uniformBuffer.upload(); + if (data.length > uniformBuffer.bufferData.length) { + uniformBuffer.dispose(); + uniformBuffer = new UniformBuffer({ + gl, + data: new Float32Array(data) + }); + this._uniformBuffers[name] = uniformBuffer; + } else { + uniformBuffer.bufferData.set(data); + uniformBuffer.upload(data.length); + } } else { uniformBuffer = new UniformBuffer({ gl, - data + data: new Float32Array(data) }); this._uniformBuffers[name] = uniformBuffer; } - gl.uniformBlockBinding(this.program, index, bindingPoint); - gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, uniformBuffer.buffer); + if (!this._uniformBlockBindingSet[name]) { + gl.uniformBlockBinding(this.program, index, bindingPoint); + this._uniformBlockBindingSet[name] = true; + } + if (this._boundBufferBase.get(bindingPoint) !== uniformBuffer.buffer) { + gl.bindBufferBase(gl.UNIFORM_BUFFER, bindingPoint, uniformBuffer.buffer); + this._boundBufferBase.set(bindingPoint, uniformBuffer.buffer); + } } - trySetUniformBuffer(name: string, data: Float32Array, bindingPoint: number = 0): boolean { + trySetUniformBuffer(name: string, data: Float32Array, bindingPoint?: number): boolean { if (!this._compiled) { this._logger.warn(`Must compile shader before setting a uniform block ${name} at binding point ${bindingPoint}`); return false; @@ -615,8 +745,8 @@ export class Shader { return false; } const gl = this._gl; - const index = gl.getUniformBlockIndex(this.program, name); - if (index) { + const index = this._getUniformBlockIndex(name); + if (index !== gl.INVALID_INDEX) { this.setUniformBuffer(name, data, bindingPoint); return true; } @@ -718,7 +848,7 @@ export class Shader { * @param name * @param value */ - setUniformFloatArray(name: string, value: number[]): void { + setUniformFloatArray(name: string, value: Float32Array | number[]): void { this.setUniform('uniform1fv', name, value); } /** @@ -728,7 +858,7 @@ export class Shader { * @param name * @param value */ - trySetUniformFloatArray(name: string, value: number[]): boolean { + trySetUniformFloatArray(name: string, value: Float32Array | number[]): boolean { return this.trySetUniform('uniform1fv', name, value); } @@ -788,13 +918,15 @@ export class Shader { } setUniformAffineMatrix(name: string, value: AffineMatrix): void { + const m = this._affineMatrixCache; // prettier-ignore - this.setUniform('uniformMatrix4fv', name, false, [ - value.data[0], value.data[1], 0, 0, - value.data[2], value.data[3], 0, 0, - 0, 0, 1, 0, - value.data[4], value.data[5], 0, 1 - ]); + { + m[0] = value.data[0]; m[1] = value.data[1]; m[2] = 0; m[3] = 0; + m[4] = value.data[2]; m[5] = value.data[3]; m[6] = 0; m[7] = 0; + m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0; + m[12] = value.data[4]; m[13] = value.data[5]; m[14] = 0; m[15] = 1; + } + this.setUniform('uniformMatrix4fv', name, false, m); } /** @@ -828,8 +960,9 @@ export class Shader { ); } const gl = this._gl; - const location = gl.getUniformLocation(this.program, name); - if (location) { + const cached = this._uniforms[name]; + const location = cached?.location ?? gl.getUniformLocation(this.program, name); + if (location !== null) { const args = [location, ...value]; (this._gl as any)[uniformType].apply(this._gl, args); } else { @@ -866,8 +999,9 @@ export class Shader { return false; } const gl = this._gl; - const location = gl.getUniformLocation(this.program, name); - if (location) { + const cached = this._uniforms[name]; + const location = cached?.location ?? gl.getUniformLocation(this.program, name); + if (location !== null) { const args = [location, ...value]; (this._gl as any)[uniformType].apply(this._gl, args); } else { @@ -895,9 +1029,14 @@ export class Shader { const success = gl.getProgramParameter(program, gl.LINK_STATUS); if (!success) { + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); throw Error(`Could not link the program: [${gl.getProgramInfoLog(program)}]`); } + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return program; } @@ -924,10 +1063,14 @@ export class Shader { return errorInfo; } const lines = source.split('\n'); - const errorLineStart = errorInfo.search(/\d:\d/); + const errorLineStart = errorInfo.search(/\d+:\d+/); + if (errorLineStart === -1) { + return '\n\nSource:\n' + lines.map((l, i) => `${i + 1}: ${l}`).join('\n'); + } const errorLineEnd = errorInfo.indexOf(' ', errorLineStart); + const endIdx = errorLineEnd === -1 ? errorInfo.length : errorLineEnd; const [_, error2] = errorInfo - .slice(errorLineStart, errorLineEnd) + .slice(errorLineStart, endIdx) .split(':') .map((v) => Number(v)); for (let i = 0; i < lines.length; i++) { diff --git a/src/engine/graphics/context/webgl-util.ts b/src/engine/graphics/context/webgl-util.ts index 1a26f57c..858597cb 100644 --- a/src/engine/graphics/context/webgl-util.ts +++ b/src/engine/graphics/context/webgl-util.ts @@ -85,7 +85,7 @@ export function getGLTypeFromSource(gl: WebGLRenderingContext, source: string, v * @param gl * @param type */ -export function getAttributeComponentSize(gl: WebGLRenderingContext, type: number): number { +export function getAttributeComponentSize(gl: WebGL2RenderingContext, type: number): number { switch (type) { case gl.LOW_FLOAT: case gl.HIGH_FLOAT: @@ -97,13 +97,26 @@ export function getAttributeComponentSize(gl: WebGLRenderingContext, type: numbe return 3; case gl.FLOAT_VEC4: return 4; + case gl.INT: + case gl.UNSIGNED_INT: + case gl.BOOL: case gl.BYTE: - return 1; case gl.UNSIGNED_BYTE: - return 1; case gl.UNSIGNED_SHORT: case gl.SHORT: return 1; + case gl.INT_VEC2: + case gl.UNSIGNED_INT_VEC2: + case gl.BOOL_VEC2: + return 2; + case gl.INT_VEC3: + case gl.UNSIGNED_INT_VEC3: + case gl.BOOL_VEC3: + return 3; + case gl.INT_VEC4: + case gl.UNSIGNED_INT_VEC4: + case gl.BOOL_VEC4: + return 4; default: return 1; } @@ -115,7 +128,7 @@ export function getAttributeComponentSize(gl: WebGLRenderingContext, type: numbe * @param gl * @param type */ -export function getAttributePointerType(gl: WebGLRenderingContext, type: number) { +export function getAttributePointerType(gl: WebGL2RenderingContext, type: number) { switch (type) { case gl.LOW_FLOAT: case gl.HIGH_FLOAT: @@ -124,6 +137,20 @@ export function getAttributePointerType(gl: WebGLRenderingContext, type: number) case gl.FLOAT_VEC3: case gl.FLOAT_VEC4: return gl.FLOAT; + case gl.INT: + case gl.INT_VEC2: + case gl.INT_VEC3: + case gl.INT_VEC4: + case gl.BOOL: + case gl.BOOL_VEC2: + case gl.BOOL_VEC3: + case gl.BOOL_VEC4: + return gl.INT; + case gl.UNSIGNED_INT: + case gl.UNSIGNED_INT_VEC2: + case gl.UNSIGNED_INT_VEC3: + case gl.UNSIGNED_INT_VEC4: + return gl.UNSIGNED_INT; case gl.BYTE: return gl.BYTE; case gl.UNSIGNED_BYTE: diff --git a/src/spec/vitest/shader-spec.ts b/src/spec/vitest/shader-spec.ts index 05044d28..6b600157 100644 --- a/src/spec/vitest/shader-spec.ts +++ b/src/spec/vitest/shader-spec.ts @@ -16,6 +16,51 @@ describe('A Shader', () => { expect(ex.Shader).toBeDefined(); }); + it('maps all sampler types to uniform1i in glTypeToUniformTypeName', () => { + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_2D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_CUBE)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_2D_ARRAY)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_2D_ARRAY_SHADOW)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_CUBE_SHADOW)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.INT_SAMPLER_2D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.INT_SAMPLER_3D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.INT_SAMPLER_CUBE)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.INT_SAMPLER_2D_ARRAY)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.UNSIGNED_INT_SAMPLER_2D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_3D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.SAMPLER_2D_SHADOW)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.UNSIGNED_INT_SAMPLER_3D)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.UNSIGNED_INT_SAMPLER_CUBE)).toBe('uniform1i'); + expect(ex.glTypeToUniformTypeName(gl, gl.UNSIGNED_INT_SAMPLER_2D_ARRAY)).toBe('uniform1i'); + }); + + it('skips uniform block members in getUniformDefinitions (null location)', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform float u_regular; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data + vec4(u_regular); + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + const defs = sut.getUniformDefinitions(); + const names = defs.map((d) => d.name); + expect(names).toContain('u_regular'); + expect(defs.every((d) => d.location !== null)).toBe(true); + }); + it('can be constructed & compiled with shader source', () => { const sut = new ex.Shader({ graphicsContext, @@ -288,4 +333,936 @@ describe('A Shader', () => { expect(gl.activeTexture).toHaveBeenCalledWith(gl.TEXTURE0 + 5); expect(gl.bindTexture).toHaveBeenCalledWith(gl.TEXTURE_2D, tex); }); + + it('setUniformBuffer returns early for invalid block name without crashing', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const logger = ex.Logger.getInstance(); + const warnSpy = vi.spyOn(logger, 'warnOnce'); + + expect(() => { + sut.setUniformBuffer('NonExistentBlock', new Float32Array([1.0, 2.0, 3.0, 4.0])); + }).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledWith('Invalid block name NonExistentBlock'); + }); + + it('setUniformBuffer recreates buffer if new data is larger', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const smallData = new Float32Array([1.0, 2.0, 3.0, 4.0]); + sut.setUniformBuffer('TestBlock', smallData); + + const largeData = new Float32Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]); + expect(() => { + sut.setUniformBuffer('TestBlock', largeData); + }).not.toThrow(); + }); + + it('_setUniforms uses cached uniform definitions instead of querying WebGL', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform float u_test; + void main() { + gl_Position = a_position + vec4(u_test); + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + const getUniformDefsSpy = vi.spyOn(sut, 'getUniformDefinitions'); + + sut.uniforms = { u_test: 1.0 }; + sut.use(); + + sut.uniforms = { u_test: 2.0 }; + sut.use(); + + expect(getUniformDefsSpy).not.toHaveBeenCalled(); + }); + + it('_setImages skips null textures without binding or setting uniforms', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform sampler2D u_texture; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + const bindTextureSpy = vi.spyOn(gl, 'bindTexture'); + const setUniformSpy = vi.spyOn(sut, 'trySetUniformInt'); + + const fakeImage = { + isLoaded: () => true, + image: { src: 'fake-image.png' } + } as any; + vi.spyOn(sut as any, '_loadImageSource').mockReturnValue(null); + + sut.images = { u_texture: fakeImage }; + sut.use(); + + expect(bindTextureSpy).not.toHaveBeenCalledWith(gl.TEXTURE_2D, null); + expect(setUniformSpy).not.toHaveBeenCalledWith('u_texture', expect.any(Number)); + }); + + it('dispose() cleans up uniform buffers and texture cache', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.uniforms = { + TestBlock: new Float32Array([1.0, 2.0, 3.0, 4.0]) + }; + + sut.compile(); + sut.use(); + + const fakeTexture = gl.createTexture(); + const fakeImage = {} as any; + (sut as any)._textures.set(fakeImage, fakeTexture); + + const deleteBufferSpy = vi.spyOn(gl, 'deleteBuffer'); + const deleteTextureSpy = vi.spyOn(gl, 'deleteTexture'); + + sut.dispose(); + + expect(deleteBufferSpy).toHaveBeenCalled(); + // Textures are owned by TextureLoader, not deleted by Shader + expect(deleteTextureSpy).not.toHaveBeenCalledWith(fakeTexture); + // But the texture cache should be cleared + expect((sut as any)._textures.size).toBe(0); + }); + + it('setUniform and trySetUniform use cached uniform locations', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform float u_test; + void main() { + gl_Position = a_position + vec4(u_test); + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const getLocationSpy = vi.spyOn(gl, 'getUniformLocation'); + getLocationSpy.mockClear(); + + sut.setUniformFloat('u_test', 1.0); + sut.trySetUniformFloat('u_test', 2.0); + + expect(getLocationSpy).not.toHaveBeenCalled(); + }); + + it('multiple uniform blocks get unique binding points automatically', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform Block1 { + vec4 data1; + }; + layout(std140) uniform Block2 { + vec4 data2; + }; + void main() { + gl_Position = a_position + data1 + data2; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.uniforms = { + Block1: new Float32Array([1.0, 2.0, 3.0, 4.0]), + Block2: new Float32Array([5.0, 6.0, 7.0, 8.0]) + }; + + sut.compile(); + sut.use(); + + const bindingPoints = (sut as any)._uniformBufferBindingPoints; + expect(bindingPoints.Block1).toBeDefined(); + expect(bindingPoints.Block2).toBeDefined(); + expect(bindingPoints.Block1).not.toBe(bindingPoints.Block2); + }); + + it('setUniformAffineMatrix reuses cached Float32Array', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform mat4 u_affine; + void main() { + gl_Position = u_affine * a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const cache = (sut as any)._affineMatrixCache; + const affine = new ex.AffineMatrix(); + + sut.setUniformAffineMatrix('u_affine', affine); + const firstRef = cache; + + affine.data[0] = 99; + sut.setUniformAffineMatrix('u_affine', affine); + const secondRef = cache; + + expect(firstRef).toBe(secondRef); + }); + + it('use() throws clear error after dispose()', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.dispose(); + + expect(() => sut.use()).toThrowError(/has been disposed/); + }); + + it('_setImages stops binding textures when max slots exceeded', () => { + const sut = new ex.Shader({ + graphicsContext, + startingTextureSlot: 0, + vertexSource: `#version 300 es + in vec4 a_position; + uniform sampler2D u_tex0; + uniform sampler2D u_tex1; + uniform sampler2D u_tex2; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + // Force max texture slots to 2 + (sut as any)._maxTextureSlots = 2; + + const fakeImage = { + isLoaded: () => true, + image: { + src: 'fake.png', + getAttribute: () => null, + // eslint-disable-next-line @typescript-eslint/no-empty-function + removeAttribute: () => {} + } + } as any; + + const fakeTexture = gl.createTexture(); + vi.spyOn(sut as any, '_loadImageSource').mockReturnValue(fakeTexture); + + sut.images = { + u_tex0: fakeImage, + u_tex1: fakeImage, + u_tex2: fakeImage // This one should be skipped + }; + + const activeTextureSpy = vi.spyOn(gl, 'activeTexture'); + const logger = ex.Logger.getInstance(); + const warnSpy = vi.spyOn(logger, 'warnOnce'); + + sut.compile(); + sut.use(); + + // Should only have called activeTexture for slots 0 and 1, not slot 2 + expect(activeTextureSpy).toHaveBeenCalledWith(gl.TEXTURE0 + 0); + expect(activeTextureSpy).toHaveBeenCalledWith(gl.TEXTURE0 + 1); + expect(activeTextureSpy).not.toHaveBeenCalledWith(gl.TEXTURE0 + 2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Max number texture slots')); + }); + + it('_processSourceForError handles multi-digit line numbers', () => { + // Create a shader with an error on line 12 (multi-digit) + const vertexSource = `#version 300 es +in vec4 a_position; +void main() { + // line 4 + // line 5 + // line 6 + // line 7 + // line 8 + // line 9 + // line 10 + // line 11 + gl_Position = a_position + INVALID_SYNTAX_HERE; +}`; + + const sut = new ex.Shader({ + graphicsContext, + vertexSource, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + try { + sut.compile(); + expect.fail('Should have thrown'); + } catch (e: any) { + const errorMessage = e.message; + // The error should correctly identify line 12 (where INVALID_SYNTAX_HERE is) + // Line 12 should be marked with "<----- ERROR!" + expect(errorMessage).toContain('12:'); + expect(errorMessage).toContain('<----- ERROR!'); + // The ERROR marker should be on line 12, not line 1 or 2 + const lines = errorMessage.split('\n'); + const errorLine = lines.find((l: string) => l.includes('<----- ERROR!')); + expect(errorLine).toBeDefined(); + expect(errorLine).toMatch(/^12:/); + } + }); + + it('setUniformBuffer throws if shader is not currently bound', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + // Note: NOT calling sut.use() + + expect(() => { + sut.setUniformBuffer('TestBlock', new Float32Array([1.0, 2.0, 3.0, 4.0])); + }).toThrowError(/must call `shader.use\(\)` before setting uniforms/); + }); + + it('_loadImageSource does not cache null textures', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + const fakeImage = { + isLoaded: () => true, + image: { + src: 'null-texture.png', + getAttribute: () => null, + // eslint-disable-next-line @typescript-eslint/no-empty-function + removeAttribute: () => {} + } + } as any; + + vi.spyOn((sut as any)._textureLoader, 'load').mockReturnValue(null); + + const result = (sut as any)._loadImageSource(fakeImage); + expect(result).toBeNull(); + expect((sut as any)._textures.has(fakeImage)).toBe(false); + }); + + it('compile() always deletes shader objects', () => { + const deleteShaderSpy = vi.spyOn(gl, 'deleteShader'); + + const sut1 = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut1.compile(); + expect(deleteShaderSpy).toHaveBeenCalledTimes(2); + + deleteShaderSpy.mockClear(); + + const sut2 = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + out float v_badType; + void main() { + v_badType = 1.0; + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + in vec2 v_badType; + void main() { + color = vec4(v_badType, 0.0, 1.0); + }` + }); + + expect(() => sut2.compile()).toThrow(); + expect(deleteShaderSpy).toHaveBeenCalledTimes(2); + }); + + it('setUniformBuffer uses bufferSubData instead of bufferData when updating existing buffer', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const data1 = new Float32Array([1.0, 2.0, 3.0, 4.0]); + sut.setUniformBuffer('TestBlock', data1); + + const bufferDataSpy = vi.spyOn(gl, 'bufferData'); + const bufferSubDataSpy = vi.spyOn(gl, 'bufferSubData'); + bufferDataSpy.mockClear(); + bufferSubDataSpy.mockClear(); + + const data2 = new Float32Array([5.0, 6.0, 7.0, 8.0]); + sut.setUniformBuffer('TestBlock', data2); + + expect(bufferDataSpy).not.toHaveBeenCalled(); + expect(bufferSubDataSpy).toHaveBeenCalled(); + }); + + it("setUniformBuffer does not mutate caller's Float32Array", () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const data1 = new Float32Array([1.0, 2.0, 3.0, 4.0]); + const originalData1 = new Float32Array(data1); + sut.setUniformBuffer('TestBlock', data1); + + const data2 = new Float32Array([5.0, 6.0, 7.0, 8.0]); + sut.setUniformBuffer('TestBlock', data2); + + expect(data1).toEqual(originalData1); + }); + + it('sampler uniform set via number uses uniform1i not uniform1f', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + uniform sampler2D u_texture; + void main() { + color = texture(u_texture, vec2(0.5)); + }` + }); + + sut.compile(); + + const uniform1fSpy = vi.spyOn(gl, 'uniform1f'); + const uniform1iSpy = vi.spyOn(gl, 'uniform1i'); + uniform1fSpy.mockClear(); + uniform1iSpy.mockClear(); + + sut.uniforms.u_texture = 3; + sut.flagUniformsDirty(); + sut.use(); + + expect(uniform1iSpy).toHaveBeenCalledWith(expect.anything(), 3); + expect(uniform1fSpy).not.toHaveBeenCalled(); + }); + + it('getAttributeDefinitions handles WebGL2 integer attribute types', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + in int a_int; + in ivec2 a_ivec2; + in ivec3 a_ivec3; + in ivec4 a_ivec4; + in uint a_uint; + in uvec2 a_uvec2; + in uvec3 a_uvec3; + in uvec4 a_uvec4; + void main() { + gl_Position = a_position + vec4(float(a_int), float(a_ivec2.x), float(a_ivec3.x), float(a_ivec4.x)) + vec4(float(a_uint), float(a_uvec2.x), float(a_uvec3.x), float(a_uvec4.x)); + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + expect(sut.attributes.a_int).toBeDefined(); + expect(sut.attributes.a_int.glType).toBe(gl.INT); + expect(sut.attributes.a_int.size).toBe(1); + + expect(sut.attributes.a_ivec2).toBeDefined(); + expect(sut.attributes.a_ivec2.glType).toBe(gl.INT); + expect(sut.attributes.a_ivec2.size).toBe(2); + + expect(sut.attributes.a_ivec3).toBeDefined(); + expect(sut.attributes.a_ivec3.glType).toBe(gl.INT); + expect(sut.attributes.a_ivec3.size).toBe(3); + + expect(sut.attributes.a_ivec4).toBeDefined(); + expect(sut.attributes.a_ivec4.glType).toBe(gl.INT); + expect(sut.attributes.a_ivec4.size).toBe(4); + + expect(sut.attributes.a_uint).toBeDefined(); + expect(sut.attributes.a_uint.glType).toBe(gl.UNSIGNED_INT); + expect(sut.attributes.a_uint.size).toBe(1); + + expect(sut.attributes.a_uvec2).toBeDefined(); + expect(sut.attributes.a_uvec2.glType).toBe(gl.UNSIGNED_INT); + expect(sut.attributes.a_uvec2.size).toBe(2); + + expect(sut.attributes.a_uvec3).toBeDefined(); + expect(sut.attributes.a_uvec3.glType).toBe(gl.UNSIGNED_INT); + expect(sut.attributes.a_uvec3.size).toBe(3); + + expect(sut.attributes.a_uvec4).toBeDefined(); + expect(sut.attributes.a_uvec4.glType).toBe(gl.UNSIGNED_INT); + expect(sut.attributes.a_uvec4.size).toBe(4); + }); + + it('setUniformBuffer caches getUniformBlockIndex results', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const getBlockIndexSpy = vi.spyOn(gl, 'getUniformBlockIndex'); + getBlockIndexSpy.mockClear(); + + const data = new Float32Array([1.0, 2.0, 3.0, 4.0]); + sut.setUniformBuffer('TestBlock', data); + sut.setUniformBuffer('TestBlock', data); + sut.setUniformBuffer('TestBlock', data); + + expect(getBlockIndexSpy).toHaveBeenCalledTimes(1); + }); + + it('setUniformBuffer skips redundant uniformBlockBinding and bindBufferBase calls', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const data = new Float32Array([1.0, 2.0, 3.0, 4.0]); + sut.setUniformBuffer('TestBlock', data); + + const uniformBlockBindingSpy = vi.spyOn(gl, 'uniformBlockBinding'); + const bindBufferBaseSpy = vi.spyOn(gl, 'bindBufferBase'); + uniformBlockBindingSpy.mockClear(); + bindBufferBaseSpy.mockClear(); + + sut.setUniformBuffer('TestBlock', data); + sut.setUniformBuffer('TestBlock', data); + + expect(uniformBlockBindingSpy).not.toHaveBeenCalled(); + expect(bindBufferBaseSpy).not.toHaveBeenCalled(); + }); + + it('addImageSource adds image and removeImageSource removes it', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform sampler2D u_tex0; + uniform sampler2D u_tex1; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + const fakeImage = { + isLoaded: () => true, + image: { src: 'fake.png' } + } as any; + + sut.addImageSource('u_tex0', fakeImage); + expect(sut.images.u_tex0).toBe(fakeImage); + + sut.addImageSource('u_tex1', fakeImage); + expect(sut.images.u_tex1).toBe(fakeImage); + + sut.removeImageSource('u_tex0'); + expect(sut.images.u_tex0).toBeUndefined(); + }); + + it('compile() returns early if already compiled', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + const program1 = sut.compile(); + const program2 = sut.compile(); + + expect(program1).toBe(program2); + }); + + it('onPreLink and onPostCompile callbacks are called', () => { + const onPreLink = vi.fn(); + const onPostCompile = vi.fn(); + + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }`, + onPreLink, + onPostCompile + }); + + sut.compile(); + + expect(onPreLink).toHaveBeenCalledTimes(1); + expect(onPreLink).toHaveBeenCalledWith(expect.any(WebGLProgram)); + expect(onPostCompile).toHaveBeenCalledTimes(1); + expect(onPostCompile).toHaveBeenCalledWith(sut); + }); + + it('setUniformBuffer uses explicit bindingPoint when provided', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + sut.use(); + + const uniformBlockBindingSpy = vi.spyOn(gl, 'uniformBlockBinding'); + const bindBufferBaseSpy = vi.spyOn(gl, 'bindBufferBase'); + + const data = new Float32Array([1.0, 2.0, 3.0, 4.0]); + sut.setUniformBuffer('TestBlock', data, 5); + + expect(uniformBlockBindingSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), 5); + expect(bindBufferBaseSpy).toHaveBeenCalledWith(gl.UNIFORM_BUFFER, 5, expect.anything()); + }); + + it('uniforms dictionary with [Float32Array, bindingPoint] tuple sets explicit binding', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + layout(std140) uniform TestBlock { + vec4 data; + }; + void main() { + gl_Position = a_position + data; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + const bindBufferBaseSpy = vi.spyOn(gl, 'bindBufferBase'); + + sut.uniforms = { + TestBlock: [new Float32Array([1.0, 2.0, 3.0, 4.0]), 7] + }; + + sut.compile(); + sut.use(); + + expect(bindBufferBaseSpy).toHaveBeenCalledWith(gl.UNIFORM_BUFFER, 7, expect.anything()); + }); + + it('_setImages warns for unloaded images', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform sampler2D u_texture; + void main() { + gl_Position = a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + sut.compile(); + + const fakeImage = { + isLoaded: () => false, + image: { src: 'unloaded.png' } + } as any; + + const logger = ex.Logger.getInstance(); + const warnSpy = vi.spyOn(logger, 'warnOnce'); + + sut.images = { u_texture: fakeImage }; + sut.use(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('in not loaded')); + }); + + it('setUniformAffineMatrix works via uniforms dictionary', () => { + const sut = new ex.Shader({ + graphicsContext, + vertexSource: `#version 300 es + in vec4 a_position; + uniform mat4 u_affine; + void main() { + gl_Position = u_affine * a_position; + }`, + fragmentSource: `#version 300 es + precision mediump float; + out vec4 color; + void main() { + color = vec4(1.0); + }` + }); + + const affine = new ex.AffineMatrix(); + sut.uniforms = { u_affine: affine }; + + sut.compile(); + sut.use(); + + expect(sut.uniforms.u_affine).toBe(affine); + }); });