diff --git a/src/rendering/webgl2/WebGl2TextRenderer.ts b/src/rendering/webgl2/WebGl2TextRenderer.ts index 929f0ec9..48488bf5 100644 --- a/src/rendering/webgl2/WebGl2TextRenderer.ts +++ b/src/rendering/webgl2/WebGl2TextRenderer.ts @@ -476,7 +476,6 @@ export class WebGl2TextRenderer extends AbstractWebGl2Renderer ({ + spriteVert: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; v_color = a_color; v_textureSlot = a_textureSlot; +}`, + + spriteFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { outColor = sampleTexture(v_textureSlot, v_uv) * v_color; }`, + + meshVert: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in vec4 a_color; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; out vec4 v_color; out vec4 v_tint; +void main() { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 t = mat3(m0.x,m0.z,0.0, m0.y,m0.w,0.0, m1.x,m1.y,1.0); + vec3 world = t * vec3(a_position, 1.0); + vec3 clip = u_projection * world; + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; in vec4 v_color; in vec4 v_tint; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv) * v_color * v_tint; }`, + + textVert: `#version 300 es +precision mediump float; +in vec2 a_position; in vec2 a_texcoord; in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float ni = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(ni * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); v_uv = a_texcoord; +}`, + + textFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv); }`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVert })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFrag })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVert })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFrag })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVert })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFrag })); + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; +const cellSize = 16; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app: Application = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, + }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const buf = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); + + return [buf[0], buf[1], buf[2], buf[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +/** + * Builds a horizontal N-cell spritesheet texture, each cell filled with a + * distinct solid color, so a frame swap is provably a different sub-rect + * rather than a coincidentally-similar sample. + */ +const createSpritesheetTexture = (colors: readonly string[], size = cellSize): Texture => { + const src = document.createElement('canvas'); + + src.width = size * colors.length; + src.height = size; + + const ctx = src.getContext('2d')!; + + colors.forEach((color, index) => { + ctx.fillStyle = color; + ctx.fillRect(index * size, 0, size, size); + }); + + return new Texture(src); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGL2 AnimatedSprite — frame-region UV swap', () => { + test('frame 0 samples the first spritesheet cell', async () => { + const backend = await createBackend(); + const texture = createSpritesheetTexture(['#ff0000', '#0000ff']); + const root = new Container(); + const sprite = new AnimatedSprite(texture, { + cells: { frames: [new Rectangle(0, 0, cellSize, cellSize), new Rectangle(cellSize, 0, cellSize, cellSize)], fps: 10 }, + }); + + try { + sprite.play('cells'); + sprite.setPosition(8, 8); + root.addChild(sprite); + + render(backend, root); + + // Interior of the sprite (16x16 at 8,8 → covers 8..24) shows cell 0 (red) + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); + // Outside the sprite's bounds remains the clear color (black) + expectPixelNear(readPixel(backend, 40, 40), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('advancing playback swaps to the second spritesheet cell', async () => { + const backend = await createBackend(); + const texture = createSpritesheetTexture(['#ff0000', '#0000ff']); + const root = new Container(); + const sprite = new AnimatedSprite(texture, { + cells: { frames: [new Rectangle(0, 0, cellSize, cellSize), new Rectangle(cellSize, 0, cellSize, cellSize)], fps: 10 }, + }); + + try { + sprite.play('cells'); + sprite.setPosition(8, 8); + root.addChild(sprite); + + render(backend, root); + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); + + // Advance exactly one frame's worth of time (fps 10 → 100ms/frame) + sprite.update(100); + expect(sprite.currentFrame).toBe(1); + + render(backend, root); + + // Same screen position now samples cell 1 (blue) — proves the UV + // sub-rect swap, not just a re-render of the same frame. + expectPixelNear(readPixel(backend, 16, 16), [0, 0, 255, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('play() restart returns playback to the first spritesheet cell', async () => { + const backend = await createBackend(); + const texture = createSpritesheetTexture(['#ff0000', '#0000ff']); + const root = new Container(); + const sprite = new AnimatedSprite(texture, { + cells: { frames: [new Rectangle(0, 0, cellSize, cellSize), new Rectangle(cellSize, 0, cellSize, cellSize)], fps: 10 }, + }); + + try { + sprite.play('cells'); + sprite.setPosition(8, 8); + root.addChild(sprite); + + sprite.update(100); + expect(sprite.currentFrame).toBe(1); + + // Restart (the default) rewinds to frame 0 + sprite.play('cells'); + expect(sprite.currentFrame).toBe(0); + + render(backend, root); + + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-bitmap-text.test.ts b/test/rendering/browser/webgl2-bitmap-text.test.ts new file mode 100644 index 00000000..d56a2d34 --- /dev/null +++ b/test/rendering/browser/webgl2-bitmap-text.test.ts @@ -0,0 +1,356 @@ +/** + * WebGL2 BitmapText browser tests. + * + * Closes a coverage gap in the renderer matrix: the WebGPU backend already + * exercises the BitmapText / BmFont-adapter code path with a real pixel + * readback (see the "BitmapText renders inside a Geometry stencil clip" test + * in `webgpu-stencil-clip.test.ts`), but no WebGL2 browser test constructed a + * `BitmapText` at all — the WebGL2 text browser suite + * (`webgl2-text-layout.test.ts`, `webgl2-glyph-sdf.test.ts`) only drives the + * runtime Canvas 2D / SDF `Text` node. `BitmapText` and `Text` share the same + * renderer class (`WebGl2TextRenderer`), but BitmapText runs an entirely + * different collection path (`_collectBitmapText` → the "color" shader, + * `text-color.frag`, sampling an offline BMFont atlas page directly — no + * runtime rasterisation, no shared `GlyphAtlasPool`). + * + * This file renders `BitmapText` nodes backed by a programmatically built + * `BmFont` whose atlas page is a single solid-colour texture, so each glyph's + * quad paints a deterministic, exactly-known colour — the same technique + * `webgpu-stencil-clip.test.ts`'s `createSolidBitmapText` helper uses. + * + * ## Regression guard: first-flush uniforms (WebGl2TextRenderer) + * + * This test originally uncovered a real engine bug: `WebGl2TextRenderer + * ._drawBatches()` called `shader.sync()` *before* setting that flush's + * `u_projection` / `u_texture` / `u_nodeData` / `u_pageSize` uniforms. Because + * `ShaderUniform.setValue()` only marks a uniform dirty for the *next* `sync()`, + * the first flush of each text shaderType drew with a stale zero `u_projection` + * — degenerate, so nothing rasterized. It self-healed from the second frame on + * (the values are frame-constant), so no continuous-rendering test caught it, + * but any genuine single-shot render (screenshot / render-to-texture pre-bake / + * first frame) drew nothing. Fixed by moving `sync()` after the uniform writes, + * matching every other WebGL2 renderer (uniforms first, `sync()` last). These + * tests render exactly once (no warm-up) so they fail if that ordering regresses. + * + * Run via: pnpm test:browser:webgl2 + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; +import { BitmapText, type BmFontData } from '#rendering/text/BitmapText'; +import { BmFont } from '#rendering/text/BmFont'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { wireCoreRenderers } from './_coreRenderers'; + +// --------------------------------------------------------------------------- +// Shader mocks +// +// The vitest shaderPlugin replaces every .vert/.frag import with +// `export default ""`. Replace the stubs with minimal but valid GLSL so +// renderer.connect() can compile the Sprite + Mesh + Text shaders that +// wireCoreRenderers() registers alongside our inline-GLSL renderer. +// +// textFrag directly samples `u_texture` (no tint/nodeData lookup): the real +// text-color.frag multiplies by a fillColor tint read from the node-data +// texture, but BitmapText's default fillColor is white (identity multiply), +// so a bare sample is pixel-equivalent for every assertion below. +// --------------------------------------------------------------------------- + +const shaderSources = vi.hoisted(() => ({ + spriteVert: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; v_color = a_color; v_textureSlot = a_textureSlot; +}`, + + spriteFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { outColor = sampleTexture(v_textureSlot, v_uv) * v_color; }`, + + meshVert: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in vec4 a_color; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; out vec4 v_color; out vec4 v_tint; +void main() { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 t = mat3(m0.x,m0.z,0.0, m0.y,m0.w,0.0, m1.x,m1.y,1.0); + vec3 world = t * vec3(a_position, 1.0); + vec3 clip = u_projection * world; + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; in vec4 v_color; in vec4 v_tint; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv) * v_color * v_tint; }`, + + // Explicit layout locations are load-bearing: WebGl2TextRenderer links this + // vertex source into THREE separate programs (sdf/msdf/color shaders), but + // wires its single shared VAO's attribute pointers from only one of them + // (the sdf shader). Without matching explicit locations, a GLSL linker is + // free to assign a_position/a_texcoord/a_nodeIndex to different locations + // per program even from identical source, desyncing the VAO from whichever + // program is actually active when a "color" (BitmapText) batch draws. + textVert: `#version 300 es +precision mediump float; +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texcoord; +layout(location = 2) in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float ni = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(ni * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); v_uv = a_texcoord; +}`, + + textFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv); }`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVert })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFrag })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVert })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFrag })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVert })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFrag })); + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app: Application = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, + }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const buf = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); + + return [buf[0], buf[1], buf[2], buf[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const createSolidTexture = (color: string, size: number): Texture => { + const src = document.createElement('canvas'); + + src.width = size; + src.height = size; + + const ctx = src.getContext('2d')!; + + ctx.fillStyle = color; + ctx.fillRect(0, 0, size, size); + + return new Texture(src); +}; + +// A BitmapText whose single glyph 'A' fills the whole `size`×`size` atlas page, +// placed at the line origin so its quad covers (0,0)–(size,size) before any +// node transform. The atlas page is a solid-colour texture, so the +// colour-atlas shader (msdf = false) emits that colour directly — deterministic +// pixels with no runtime font rasterisation or atlas-upload timing. +const createSolidBitmapText = (color: string, size: number): { text: BitmapText; texture: Texture } => { + const texture = createSolidTexture(color, size); + const fontData: BmFontData = { + pages: ['atlas_0.png'], + chars: new Map([[65, { x: 0, y: 0, width: size, height: size, xOffset: 0, yOffset: 0, xAdvance: size, page: 0 }]]), + kernings: new Map(), + // base === lineHeight ⇒ yBearing 0 ⇒ the glyph top sits at the line origin. + lineHeight: size, + base: size, + }; + + return { text: new BitmapText('A', new BmFont(fontData, [texture])), texture }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('BitmapText WebGL2 browser', () => { + test('a solid-color glyph paints its atlas color at the glyph position, clear color elsewhere', async () => { + const backend = await createBackend(); + const { text, texture } = createSolidBitmapText('#ff0000', 32); + + try { + text.setPosition(8, 8); // covers (8,8)-(40,40) + + render(backend, text); + + expect(backend.stats.drawCalls).toBeGreaterThan(0); + + // Inside the 32×32 glyph quad, anchored at (8, 8). + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); + expectPixelNear(readPixel(backend, 38, 38), [255, 0, 0, 255]); + // Outside the glyph quad — untouched clear color. + expectPixelNear(readPixel(backend, 2, 2), [0, 0, 0, 255]); + expectPixelNear(readPixel(backend, 56, 56), [0, 0, 0, 255]); + } finally { + text.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('two BitmapText nodes render distinct colors without bleeding into the gap between them', async () => { + const backend = await createBackend(); + const { text: redText, texture: redTexture } = createSolidBitmapText('#ff0000', 24); + const { text: greenText, texture: greenTexture } = createSolidBitmapText('#00ff00', 24); + const root = new Container(); + + try { + redText.setPosition(4, 4); // covers (4,4)-(28,28) + greenText.setPosition(36, 4); // covers (36,4)-(60,28) + root.addChild(redText, greenText); + + render(backend, root); + + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); // inside red glyph + expectPixelNear(readPixel(backend, 48, 16), [0, 255, 0, 255]); // inside green glyph + expectPixelNear(readPixel(backend, 32, 16), [0, 0, 0, 255]); // gap between them + } finally { + root.destroy(); + redTexture.destroy(); + greenTexture.destroy(); + backend.destroy(); + } + }); + + test('node transform (position) is applied to the glyph quad', async () => { + const backend = await createBackend(); + const { text, texture } = createSolidBitmapText('#ff0000', 16); + + try { + text.setPosition(40, 40); // covers (40,40)-(56,56) + + render(backend, text); + + expectPixelNear(readPixel(backend, 48, 48), [255, 0, 0, 255]); + // The origin — where the glyph would sit without the transform — stays clear. + expectPixelNear(readPixel(backend, 8, 8), [0, 0, 0, 255]); + } finally { + text.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-graphics-solid-fill.test.ts b/test/rendering/browser/webgl2-graphics-solid-fill.test.ts new file mode 100644 index 00000000..6ef2572a --- /dev/null +++ b/test/rendering/browser/webgl2-graphics-solid-fill.test.ts @@ -0,0 +1,336 @@ +/** + * WebGL2 Graphics plain solid-fill browser tests. + * + * The Graphics gradient/stencil suites cover gradient rasterization and + * clipping, but not an isolated plain solid-color fill (the simplest + * `fillStyle = Color` path, `Graphics._createSolidMesh`). These tests assert + * that a solid-filled rectangle and circle render the exact fill color + * inside the shape and the clear color outside it. + * + * Run via: pnpm test:browser:webgl2 + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Graphics } from '#rendering/primitives/Graphics'; +import type { RenderNode } from '#rendering/RenderNode'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { wireCoreRenderers } from './_coreRenderers'; + +// The browser project rewrites `.vert`/`.frag` imports to empty strings, so the +// default engine shaders the backend compiles on connect must be mocked with +// valid sources. The mesh sources are the REAL instanced default path (pinned +// attribute locations 0/1/2/6, transform-texture tint), which is the path +// Graphics solid-fill meshes render through. Every default renderer is +// connected on backend.initialize() and extracts its declared attributes, so +// each default shader needs valid sources with the exact attributes its +// renderer expects. +const shaderSources = vi.hoisted(() => ({ + spriteVertexSource: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; + v_color = a_color; + v_textureSlot = a_textureSlot; +}`, + + spriteFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { + outColor = sampleTexture(v_textureSlot, v_uv) * v_color; +}`, + + meshVertexSource: `#version 300 es +precision lowp float; +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texcoord; +layout(location = 2) in vec4 a_color; +layout(location = 6) in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_texcoord; +out vec4 v_color; +out vec4 v_tint; +void main(void) { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 transform = mat3( + m0.x, m0.z, 0.0, + m0.y, m0.w, 0.0, + m1.x, m1.y, 1.0 + ); + gl_Position = vec4((u_projection * transform * vec3(a_position, 1.0)).xy, 0.0, 1.0); + v_texcoord = a_texcoord; + v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFragmentSource: `#version 300 es +precision lowp float; +uniform sampler2D u_texture; +in vec2 v_texcoord; +in vec4 v_color; +in vec4 v_tint; +layout(location = 0) out vec4 fragColor; +void main(void) { + vec4 base = texture(u_texture, v_texcoord) * v_color * v_tint; + fragColor = vec4(base.rgb * base.a, base.a); +}`, + + particleVertexSource: `#version 300 es +precision mediump float; +in vec2 a_translation; +in vec2 a_scale; +in float a_rotation; +in vec4 a_color; +in vec2 a_uvMin; +in vec2 a_uvMax; +uniform mat3 u_projection; +uniform mat3 u_systemTransform; +uniform vec4 u_localBounds; +out vec2 v_uv; +out vec4 v_color; +void main() { + vec2 corner; + if (gl_VertexID == 0) corner = vec2(0.0, 0.0); + else if (gl_VertexID == 1) corner = vec2(1.0, 0.0); + else if (gl_VertexID == 2) corner = vec2(1.0, 1.0); + else corner = vec2(0.0, 1.0); + + vec2 local = mix(u_localBounds.xy, u_localBounds.zw, corner); + local *= a_scale; + float angle = radians(a_rotation); + mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)); + vec2 worldPos = (u_systemTransform * vec3(rotationMatrix * local + a_translation, 1.0)).xy; + vec3 clip = u_projection * vec3(worldPos, 1.0); + + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = mix(a_uvMin, a_uvMax, corner); + v_color = a_color; +}`, + + particleFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { + outColor = texture(u_texture, v_uv) * v_color; +}`, + + textVertexSource: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float nodeIndex = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(nodeIndex * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; +}`, + + textFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { + outColor = texture(u_texture, v_uv); +}`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVertexSource })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFragmentSource })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVertexSource })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFragmentSource })); +vi.mock('#rendering/webgl2/glsl/particle.vert', () => ({ default: shaderSources.particleVertexSource })); +vi.mock('#rendering/webgl2/glsl/particle.frag', () => ({ default: shaderSources.particleFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVertexSource })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFragmentSource })); + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; +const defaultWebGlAttributes: WebGLContextAttributes = { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, +}; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: defaultWebGlAttributes, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const pixel = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + + return [pixel[0], pixel[1], pixel[2], pixel[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 5): void => { + for (let index = 0; index < 4; index++) { + expect(Math.abs(actual[index] - expected[index])).toBeLessThanOrEqual(tolerance); + } +}; + +describe('Graphics solid fill WebGL2 browser', () => { + test('solid-color rectangle fill renders the fill color inside and the clear color outside', async () => { + const backend = await createBackend(); + const graphics = new Graphics(); + + graphics.fillStyle = Color.green; + graphics.drawRectangle(8, 8, 48, 48); + + try { + render(backend, graphics); + + // Inside the rectangle: solid fill color (Color.green is (0, 128, 0)). + expectPixelNear(readPixel(backend, 32, 32), [0, 128, 0, 255]); + expectPixelNear(readPixel(backend, 10, 10), [0, 128, 0, 255]); + // Outside the rectangle: clear color. + expectPixelNear(readPixel(backend, 2, 2), [0, 0, 0, 255]); + expectPixelNear(readPixel(backend, 62, 62), [0, 0, 0, 255]); + } finally { + graphics.destroy(); + backend.destroy(); + } + }); + + test('solid-color circle fill renders the fill color inside and the clear color outside', async () => { + const backend = await createBackend(); + const graphics = new Graphics(); + + graphics.fillStyle = Color.red; + graphics.drawCircle(32, 32, 20); + + try { + render(backend, graphics); + + // Center of the circle: solid fill color. + expectPixelNear(readPixel(backend, 32, 32), [255, 0, 0, 255]); + // Corner well outside the circle's radius: clear color. + expectPixelNear(readPixel(backend, 4, 4), [0, 0, 0, 255]); + expectPixelNear(readPixel(backend, 60, 60), [0, 0, 0, 255]); + } finally { + graphics.destroy(); + backend.destroy(); + } + }); + + test('transformed solid-color fill appears at the translated location', async () => { + const backend = await createBackend(); + const graphics = new Graphics(); + + graphics.fillStyle = Color.blue; + graphics.drawRectangle(0, 0, 24, 24); + graphics.setPosition(20, 20); + + try { + render(backend, graphics); + + // Untouched region before the translated rectangle stays clear. + expectPixelNear(readPixel(backend, 4, 4), [0, 0, 0, 255]); + // Inside the translated rectangle: solid fill color. + expectPixelNear(readPixel(backend, 32, 32), [0, 0, 255, 255]); + } finally { + graphics.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-mesh-tint.test.ts b/test/rendering/browser/webgl2-mesh-tint.test.ts new file mode 100644 index 00000000..fa54b0fc --- /dev/null +++ b/test/rendering/browser/webgl2-mesh-tint.test.ts @@ -0,0 +1,459 @@ +/** + * WebGL2 mesh tint / texture-sampling browser tests — mirrors + * webgpu-mesh-tint.test.ts on the WebGL2 backend. + * + * Regression coverage for a mesh-renderer bug where the per-mesh tint Color + * must be normalized to 0..1 before the shader multiplies `sample * color * + * tint` (the mesh fragment shader below reads the tint from the transform + * texture's third row, already normalized by the backend's transform upload, + * matching WebGl2MeshRenderer/mesh.vert in `src/rendering/webgl2/glsl`). + * + * These tests cover DataTexture (grayscale levels, 2x2 quadrants), a + * canvas-sourced Texture, a rasterized Gradient, and a fractional tint, all + * through the default mesh path. + * + * Run via: pnpm test:browser:webgl2 + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { LinearGradient } from '#rendering/gradient/LinearGradient'; +import { Mesh } from '#rendering/mesh/Mesh'; +import { DataTexture } from '#rendering/texture/DataTexture'; +import { Texture } from '#rendering/texture/Texture'; +import { ScaleModes } from '#rendering/types'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { wireCoreRenderers } from './_coreRenderers'; + +// The browser project rewrites `.vert`/`.frag` imports to empty strings, so the +// default engine shaders the backend compiles on connect must be mocked with +// valid sources. The mesh sources are the REAL instanced default path (pinned +// attribute locations 0/1/2/6, transform-texture tint), which is the path +// Mesh renders through. Every default renderer is connected on +// backend.initialize() and extracts its declared attributes, so each default +// shader needs valid sources with the exact attributes its renderer expects. +const shaderSources = vi.hoisted(() => ({ + spriteVertexSource: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; + v_color = a_color; + v_textureSlot = a_textureSlot; +}`, + + spriteFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { + outColor = sampleTexture(v_textureSlot, v_uv) * v_color; +}`, + + meshVertexSource: `#version 300 es +precision lowp float; +layout(location = 0) in vec2 a_position; +layout(location = 1) in vec2 a_texcoord; +layout(location = 2) in vec4 a_color; +layout(location = 6) in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_texcoord; +out vec4 v_color; +out vec4 v_tint; +void main(void) { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 transform = mat3( + m0.x, m0.z, 0.0, + m0.y, m0.w, 0.0, + m1.x, m1.y, 1.0 + ); + gl_Position = vec4((u_projection * transform * vec3(a_position, 1.0)).xy, 0.0, 1.0); + v_texcoord = a_texcoord; + v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFragmentSource: `#version 300 es +precision lowp float; +uniform sampler2D u_texture; +in vec2 v_texcoord; +in vec4 v_color; +in vec4 v_tint; +layout(location = 0) out vec4 fragColor; +void main(void) { + vec4 base = texture(u_texture, v_texcoord) * v_color * v_tint; + fragColor = vec4(base.rgb * base.a, base.a); +}`, + + particleVertexSource: `#version 300 es +precision mediump float; +in vec2 a_translation; +in vec2 a_scale; +in float a_rotation; +in vec4 a_color; +in vec2 a_uvMin; +in vec2 a_uvMax; +uniform mat3 u_projection; +uniform mat3 u_systemTransform; +uniform vec4 u_localBounds; +out vec2 v_uv; +out vec4 v_color; +void main() { + vec2 corner; + if (gl_VertexID == 0) corner = vec2(0.0, 0.0); + else if (gl_VertexID == 1) corner = vec2(1.0, 0.0); + else if (gl_VertexID == 2) corner = vec2(1.0, 1.0); + else corner = vec2(0.0, 1.0); + + vec2 local = mix(u_localBounds.xy, u_localBounds.zw, corner); + local *= a_scale; + float angle = radians(a_rotation); + mat2 rotationMatrix = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)); + vec2 worldPos = (u_systemTransform * vec3(rotationMatrix * local + a_translation, 1.0)).xy; + vec3 clip = u_projection * vec3(worldPos, 1.0); + + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = mix(a_uvMin, a_uvMax, corner); + v_color = a_color; +}`, + + particleFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { + outColor = texture(u_texture, v_uv) * v_color; +}`, + + textVertexSource: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float nodeIndex = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(nodeIndex * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; +}`, + + textFragmentSource: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { + outColor = texture(u_texture, v_uv); +}`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVertexSource })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFragmentSource })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVertexSource })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFragmentSource })); +vi.mock('#rendering/webgl2/glsl/particle.vert', () => ({ default: shaderSources.particleVertexSource })); +vi.mock('#rendering/webgl2/glsl/particle.frag', () => ({ default: shaderSources.particleFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVertexSource })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFragmentSource })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFragmentSource })); + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; +const defaultWebGlAttributes: WebGLContextAttributes = { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, +}; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: defaultWebGlAttributes, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const renderMesh = (backend: WebGl2Backend, mesh: Mesh): void => { + backend.resetStats(); + backend.clear(Color.black); + mesh.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const pixel = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + + return [pixel[0], pixel[1], pixel[2], pixel[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let index = 0; index < 4; index++) { + expect(Math.abs(actual[index] - expected[index]), `channel ${index}: got [${actual.join(', ')}] expected [${expected.join(', ')}]`).toBeLessThanOrEqual( + tolerance, + ); + } +}; + +// A full-canvas quad in pixel space with UVs spanning the whole texture. +const fullQuadVertices = (): Float32Array => new Float32Array([0, 0, canvasSize, 0, canvasSize, canvasSize, 0, 0, canvasSize, canvasSize, 0, canvasSize]); +const fullQuadUvs = (): Float32Array => new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]); + +describe('WebGL2 mesh tint and texture sampling', () => { + test('samples intermediate DataTexture grayscale levels without saturating', async () => { + const backend = await createBackend(); + const levels = [32, 96, 160, 224]; + const width = levels.length; + const data = new Uint8Array(width * 4); + + for (let i = 0; i < width; i++) { + data.set([levels[i], levels[i], levels[i], 255], i * 4); + } + + const texture = new DataTexture({ width, height: 1, format: 'rgba8', data, samplerOptions: { scaleMode: ScaleModes.Nearest } }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + renderMesh(backend, mesh); + + levels.forEach((level, i) => { + const x = Math.floor(((i + 0.5) * canvasSize) / width); + + expectPixelNear(readPixel(backend, x, 32), [level, level, level, 255]); + }); + } finally { + mesh.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('samples a 2x2 rgba8 DataTexture into the correct quadrants', async () => { + const backend = await createBackend(); + // Row-major, top-left origin: row 0 = red, green; row 1 = blue, white. + const texture = new DataTexture({ + width: 2, + height: 2, + format: 'rgba8', + data: new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + renderMesh(backend, mesh); + + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); // top-left + expectPixelNear(readPixel(backend, 48, 16), [0, 255, 0, 255]); // top-right + expectPixelNear(readPixel(backend, 16, 48), [0, 0, 255, 255]); // bottom-left + expectPixelNear(readPixel(backend, 48, 48), [255, 255, 255, 255]); // bottom-right + } finally { + mesh.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('samples an intermediate canvas-sourced Texture without saturating', async () => { + const backend = await createBackend(); + const source = document.createElement('canvas'); + + source.width = 2; + source.height = 1; + + const context = source.getContext('2d'); + + if (!context) { + throw new Error('2D context is required to create the test texture.'); + } + + context.fillStyle = 'rgb(96, 96, 96)'; + context.fillRect(0, 0, 2, 1); + + const texture = new Texture(source, { scaleMode: ScaleModes.Nearest }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + renderMesh(backend, mesh); + + expectPixelNear(readPixel(backend, 32, 32), [96, 96, 96, 255]); + } finally { + mesh.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('renders a rasterized linear gradient DataTexture across the quad', async () => { + const backend = await createBackend(); + const gradient = new LinearGradient( + [ + { offset: 0, color: Color.red }, + { offset: 1, color: Color.blue }, + ], + [0, 0], + [1, 0], + ); + const texture = gradient.toTexture(256, 256, { samplerOptions: { scaleMode: ScaleModes.Linear } }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + renderMesh(backend, mesh); + + // Endpoints resolve near the pure stops; the middle is the blend (magenta). + expectPixelNear(readPixel(backend, 2, 32), [255, 0, 0, 255], 20); // left ≈ red + expectPixelNear(readPixel(backend, 32, 32), [128, 0, 128, 255], 20); // middle ≈ magenta + expectPixelNear(readPixel(backend, 62, 32), [0, 0, 255, 255], 20); // right ≈ blue + } finally { + mesh.destroy(); + gradient.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('applies a fractional mesh tint to a white texture without saturating', async () => { + const backend = await createBackend(); + const texture = new DataTexture({ + width: 1, + height: 1, + format: 'rgba8', + data: new Uint8Array([255, 255, 255, 255]), + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + // Tint RGB is stored 0..255; the renderer must normalize it to 0..1 before + // the shader multiply, otherwise 96 → 96× saturates the white texel. + mesh.tint = new Color(96, 160, 224); + + try { + renderMesh(backend, mesh); + + expectPixelNear(readPixel(backend, 32, 32), [96, 160, 224, 255]); + } finally { + mesh.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + // Regression: the mesh renderer caches the texture bind group per Texture. + // Resolving the binding is what syncs a DataTexture's dirty region to the GPU, + // so a plain cache hit that skipped it froze a mutated DataTexture on its + // first-frame contents — e.g. an audio spectrogram updated every frame would + // never change after the first draw. The fix re-resolves the binding each draw + // (reusing the bind group only while the view is unchanged). + test('re-uploads a DataTexture mutated between draws (no stale mesh bind-group cache)', async () => { + const backend = await createBackend(); + const texture = new DataTexture({ + width: 1, + height: 1, + format: 'rgba8', + data: new Uint8Array([255, 0, 0, 255]), // start red + samplerOptions: { scaleMode: ScaleModes.Nearest }, + }); + const mesh = new Mesh({ vertices: fullQuadVertices(), uvs: fullQuadUvs(), texture }); + + try { + renderMesh(backend, mesh); + + expectPixelNear(readPixel(backend, 32, 32), [255, 0, 0, 255]); // first draw: red + + // Mutate the texel and flush the dirty region; the next draw must show it. + texture.buffer.set([0, 0, 255, 255]); // blue + texture.commitRect(0, 0, 1, 1); + + renderMesh(backend, mesh); + + expectPixelNear(readPixel(backend, 32, 32), [0, 0, 255, 255]); // second draw: blue + } finally { + mesh.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-nine-slice.test.ts b/test/rendering/browser/webgl2-nine-slice.test.ts new file mode 100644 index 00000000..2a9f9f9a --- /dev/null +++ b/test/rendering/browser/webgl2-nine-slice.test.ts @@ -0,0 +1,416 @@ +/** + * WebGL2 NineSliceSprite browser tests. + * + * Validates the 9-region UV mapping that {@link NineSliceSprite} builds via + * `buildNineSliceQuads`: a source texture is divided into 9 colour-coded + * regions (4 corners, 4 edges, 1 center) and rendered at a destination size + * much larger than the source. Correct UV mapping means: + * - Corners land at their exact destination position, unstretched. + * - Edges/center fill the remaining space (default `'stretch'` mode). + * - Each side's border can be sized independently (asymmetric borders), + * proving edges are scaled per-axis rather than uniformly. + * + * Run via: pnpm test:browser:webgl2 + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; +import { NineSliceSprite } from '#rendering/sprite/NineSliceSprite'; +import { Texture } from '#rendering/texture/Texture'; +import { ScaleModes } from '#rendering/types'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { wireCoreRenderers } from './_coreRenderers'; + +// --------------------------------------------------------------------------- +// Shader mocks +// +// The vitest shaderPlugin replaces every .vert/.frag import with +// `export default ""`. `WebGl2Backend#initialize` connects the renderer +// registry eagerly (compiling every registered renderer's program, not just +// the ones a given test renders), so the Sprite + Mesh + Text shaders that +// `wireCoreRenderers()` registers all need valid GLSL sources even though +// this file only ever renders a NineSliceSprite (whose renderer uses inline +// GLSL directly, not `.vert`/`.frag` imports, so it needs no mock). +// --------------------------------------------------------------------------- + +const shaderSources = vi.hoisted(() => ({ + spriteVert: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; v_color = a_color; v_textureSlot = a_textureSlot; +}`, + + spriteFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { outColor = sampleTexture(v_textureSlot, v_uv) * v_color; }`, + + meshVert: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in vec4 a_color; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; out vec4 v_color; out vec4 v_tint; +void main() { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 t = mat3(m0.x,m0.z,0.0, m0.y,m0.w,0.0, m1.x,m1.y,1.0); + vec3 world = t * vec3(a_position, 1.0); + vec3 clip = u_projection * world; + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; in vec4 v_color; in vec4 v_tint; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv) * v_color * v_tint; }`, + + textVert: `#version 300 es +precision mediump float; +in vec2 a_position; in vec2 a_texcoord; in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float ni = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(ni * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); v_uv = a_texcoord; +}`, + + textFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv); }`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVert })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFrag })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVert })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFrag })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVert })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFrag })); + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app: Application = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, + }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const buf = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); + + return [buf[0], buf[1], buf[2], buf[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +// --------------------------------------------------------------------------- +// Fixture: a 16x16 texture divided into 9 colour-coded regions with a uniform +// 4px slice inset on every side. +// +// +----+--------+----+ +// | TL | top | TR | +// +----+--------+----+ +// |left| center |right +// +----+--------+----+ +// | BL | bottom | BR | +// +----+--------+----+ +// +// Nearest-neighbour sampling keeps region boundaries pixel-sharp: with the +// default linear filter, magnifying a 4px source region to a 12-24px +// destination bleeds a couple of screen pixels of the adjacent region's +// colour across every slice seam (expected GPU behaviour, not a bug), which +// would make boundary-pixel assertions flaky. +// --------------------------------------------------------------------------- + +const colors = { + tl: [255, 0, 0, 255], + tr: [0, 255, 0, 255], + bl: [0, 0, 255, 255], + br: [255, 255, 0, 255], + top: [255, 0, 255, 255], + bottom: [0, 255, 255, 255], + left: [255, 128, 0, 255], + right: [128, 0, 255, 255], + center: [255, 255, 255, 255], +} as const satisfies Record; + +const createNineSliceTexture = (): Texture => { + const src = document.createElement('canvas'); + + src.width = 16; + src.height = 16; + + const ctx = src.getContext('2d')!; + + ctx.fillStyle = 'rgb(255, 0, 0)'; + ctx.fillRect(0, 0, 4, 4); // TL corner + + ctx.fillStyle = 'rgb(0, 255, 0)'; + ctx.fillRect(12, 0, 4, 4); // TR corner + + ctx.fillStyle = 'rgb(0, 0, 255)'; + ctx.fillRect(0, 12, 4, 4); // BL corner + + ctx.fillStyle = 'rgb(255, 255, 0)'; + ctx.fillRect(12, 12, 4, 4); // BR corner + + ctx.fillStyle = 'rgb(255, 0, 255)'; + ctx.fillRect(4, 0, 8, 4); // top edge + + ctx.fillStyle = 'rgb(0, 255, 255)'; + ctx.fillRect(4, 12, 8, 4); // bottom edge + + ctx.fillStyle = 'rgb(255, 128, 0)'; + ctx.fillRect(0, 4, 4, 8); // left edge + + ctx.fillStyle = 'rgb(128, 0, 255)'; + ctx.fillRect(12, 4, 4, 8); // right edge + + ctx.fillStyle = 'rgb(255, 255, 255)'; + ctx.fillRect(4, 4, 8, 8); // center + + return new Texture(src, { scaleMode: ScaleModes.Nearest }); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGL2 NineSliceSprite', () => { + test('9-region UV mapping: corners, edges, and center land at their correct destination area', async () => { + const backend = await createBackend(); + const texture = createNineSliceTexture(); + const root = new Container(); + // Destination 48x48 at (8,8) → occupies [8,56) on both axes. + // Uniform border of 12 → columns/rows at abs 8, 20, 44, 56. + const sprite = new NineSliceSprite(texture, { slices: 4, border: 12, width: 48, height: 48 }); + + try { + sprite.setPosition(8, 8); + root.addChild(sprite); + + render(backend, root); + + // Corner centers + expectPixelNear(readPixel(backend, 14, 14), colors.tl); + expectPixelNear(readPixel(backend, 50, 14), colors.tr); + expectPixelNear(readPixel(backend, 14, 50), colors.bl); + expectPixelNear(readPixel(backend, 50, 50), colors.br); + + // Edge centers + expectPixelNear(readPixel(backend, 32, 14), colors.top); + expectPixelNear(readPixel(backend, 32, 50), colors.bottom); + expectPixelNear(readPixel(backend, 14, 32), colors.left); + expectPixelNear(readPixel(backend, 50, 32), colors.right); + + // Center + expectPixelNear(readPixel(backend, 32, 32), colors.center); + + // Corner/edge boundary sharpness (horizontal): the TL corner column + // ends exactly at dest x=20 — one pixel inside remains corner colour, + // one pixel past the boundary is already the (stretched) edge colour. + expectPixelNear(readPixel(backend, 19, 14), colors.tl); + expectPixelNear(readPixel(backend, 21, 14), colors.top); + + // Corner/edge boundary sharpness (vertical), same column. + expectPixelNear(readPixel(backend, 14, 19), colors.tl); + expectPixelNear(readPixel(backend, 14, 21), colors.left); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('asymmetric borders scale each edge independently per axis', async () => { + const backend = await createBackend(); + const texture = createNineSliceTexture(); + const root = new Container(); + // Destination fills the whole 64x64 canvas at (0,0). + // border: left=8, top=16, right=24, bottom=32 (all different) → + // columns at abs x = 0, 8, 40, 64; rows at abs y = 0, 16, 32, 64. + const sprite = new NineSliceSprite(texture, { + slices: 4, + border: { left: 8, top: 16, right: 24, bottom: 32 }, + width: 64, + height: 64, + }); + + try { + sprite.setPosition(0, 0); + root.addChild(sprite); + + render(backend, root); + + // Corner centers — each corner's footprint matches its own border size. + expectPixelNear(readPixel(backend, 4, 8), colors.tl); // 8x16 + expectPixelNear(readPixel(backend, 52, 8), colors.tr); // 24x16 + expectPixelNear(readPixel(backend, 4, 48), colors.bl); // 8x32 + expectPixelNear(readPixel(backend, 52, 48), colors.br); // 24x32 + + // Edge centers + expectPixelNear(readPixel(backend, 24, 8), colors.top); + expectPixelNear(readPixel(backend, 24, 48), colors.bottom); + expectPixelNear(readPixel(backend, 4, 24), colors.left); + expectPixelNear(readPixel(backend, 52, 24), colors.right); + + // Center + expectPixelNear(readPixel(backend, 24, 24), colors.center); + + // Boundary sanity, at a margin clear of GPU raster rounding right on an + // exact integer seam: still inside the top-left corner just before its + // row boundary (y=16), then already past the (smaller) left border + // (x=8) into the top edge, then already past the top border into the + // left edge — proving left/top were honoured independently rather than + // forced symmetric with right/bottom. + expectPixelNear(readPixel(backend, 4, 12), colors.tl); + expectPixelNear(readPixel(backend, 12, 8), colors.top); + expectPixelNear(readPixel(backend, 4, 20), colors.left); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('tint is applied uniformly across all nine regions', async () => { + const backend = await createBackend(); + const texture = createNineSliceTexture(); + const root = new Container(); + const sprite = new NineSliceSprite(texture, { slices: 4, border: 12, width: 48, height: 48 }); + + try { + sprite.setPosition(8, 8); + sprite.tint = new Color(255, 0, 0); + root.addChild(sprite); + + render(backend, root); + + // Center source colour is white; tinted red, it should render pure red. + expectPixelNear(readPixel(backend, 32, 32), [255, 0, 0, 255]); + // TR corner source colour is green; tinted red, the green channel must + // be crushed out (red tint multiplies green/blue channels to 0). + const trPixel = readPixel(backend, 50, 14); + + expect(trPixel[1]).toBeLessThan(32); + expect(trPixel[2]).toBeLessThan(32); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-particles.test.ts b/test/rendering/browser/webgl2-particles.test.ts new file mode 100644 index 00000000..ea3d2958 --- /dev/null +++ b/test/rendering/browser/webgl2-particles.test.ts @@ -0,0 +1,380 @@ +/** + * WebGL2 ParticleSystem browser tests. + * + * Validates the `@codexo/exojs-particles` WebGl2ParticleRenderer end-to-end: + * a particle spawned with a fixed slot, position, scale and packed color is + * rendered to a real WebGL2 canvas and read back with `gl.readPixels`. + * + * Determinism note: `ParticleSystem` has no built-in RNG — spawn/update + * modules (which may use distributions) are entirely optional. These tests + * bypass spawn modules altogether and write the SoA arrays + * (`posX`/`posY`/`scaleX`/`scaleY`/`color`/`lifetime`) directly after calling + * `system.spawn()`, then render without ever calling `system.update()` — so + * `elapsed` stays at 0 and the particle never expires. This yields fully + * deterministic, seed-free particle placement across runs. + * + * Run via: pnpm test:browser:webgl2 + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { materializeRendererBindings } from '#extensions/materialize'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { particlesExtension, ParticleSystem } from '../../../packages/exojs-particles/src/index'; +import { wireCoreRenderers } from './_coreRenderers'; + +// --------------------------------------------------------------------------- +// Shader mocks +// +// The vitest shaderPlugin replaces every .vert/.frag import with +// `export default ""`. `WebGl2Backend#initialize` connects the renderer +// registry eagerly (compiling every registered renderer's program), and +// `materializeRendererBindings` connects a renderer immediately when it is +// registered on an already-initialised backend. Both the core renderers +// (Sprite/Mesh/Text, registered by `wireCoreRenderers`) and the particle +// renderer (registered below) therefore need valid GLSL sources even though +// this file only ever renders a ParticleSystem. The particle shader mocks +// below are verbatim copies of `packages/exojs-particles/src/renderers/glsl/particle.{vert,frag}`. +// --------------------------------------------------------------------------- + +const shaderSources = vi.hoisted(() => ({ + spriteVert: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; v_color = a_color; v_textureSlot = a_textureSlot; +}`, + + spriteFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { outColor = sampleTexture(v_textureSlot, v_uv) * v_color; }`, + + meshVert: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in vec4 a_color; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; out vec4 v_color; out vec4 v_tint; +void main() { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 t = mat3(m0.x,m0.z,0.0, m0.y,m0.w,0.0, m1.x,m1.y,1.0); + vec3 world = t * vec3(a_position, 1.0); + vec3 clip = u_projection * world; + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; in vec4 v_color; in vec4 v_tint; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv) * v_color * v_tint; }`, + + textVert: `#version 300 es +precision mediump float; +in vec2 a_position; in vec2 a_texcoord; in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float ni = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(ni * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); v_uv = a_texcoord; +}`, + + textFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv); }`, + + // Verbatim copy of packages/exojs-particles/src/renderers/glsl/particle.vert + particleVert: `#version 300 es +precision lowp float; +precision lowp int; + +layout(location = 0) in vec2 a_translation; +layout(location = 1) in vec2 a_scale; +layout(location = 2) in float a_rotation; +layout(location = 3) in vec4 a_color; +layout(location = 4) in vec2 a_uvMin; +layout(location = 5) in vec2 a_uvMax; + +uniform mat3 u_projection; +uniform mat3 u_systemTransform; +uniform vec4 u_localBounds; + +out vec2 v_texcoord; +out vec4 v_color; + +void main(void) { + int vid = gl_VertexID; + int cornerX = ((vid + 1) >> 1) & 1; + int cornerY = vid >> 1; + + float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z; + float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w; + + vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation))); + vec2 transformed = vec2( + (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)), + (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y)) + ); + + vec3 worldPos = vec3(transformed + a_translation, 1.0); + + gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0); + + float u = (cornerX == 0) ? a_uvMin.x : a_uvMax.x; + float v = (cornerY == 0) ? a_uvMin.y : a_uvMax.y; + v_texcoord = vec2(u, v); + + v_color = vec4(a_color.rgb * a_color.a, a_color.a); +}`, + + // Verbatim copy of packages/exojs-particles/src/renderers/glsl/particle.frag + particleFrag: `#version 300 es +precision lowp float; + +uniform sampler2D u_texture; + +in vec2 v_texcoord; +in vec4 v_color; + +layout(location = 0) out vec4 fragColor; + +void main(void) { + fragColor = texture(u_texture, v_texcoord) * v_color; +}`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVert })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFrag })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVert })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFrag })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVert })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('../../../packages/exojs-particles/src/renderers/glsl/particle.vert', () => ({ default: shaderSources.particleVert })); +vi.mock('../../../packages/exojs-particles/src/renderers/glsl/particle.frag', () => ({ default: shaderSources.particleFrag })); + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app: Application = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, + }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + // The particle renderer is not part of the core renderer bindings — the + // `@codexo/exojs-particles` package materialises it itself via its + // Extension descriptor. Browser tests construct a bare backend (bypassing + // Application), so the particle binding must be wired explicitly, same as + // `wireCoreRenderers` does for Sprite/Mesh/Text. + materializeRendererBindings(backend, particlesExtension.renderers); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const buf = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); + + return [buf[0], buf[1], buf[2], buf[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const createSolidTexture = (color: string, width = 16, height = 16): Texture => { + const src = document.createElement('canvas'); + + src.width = width; + src.height = height; + + const ctx = src.getContext('2d')!; + + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + + return new Texture(src); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGL2 ParticleSystem — solid color', () => { + test('a spawned particle renders at its fixed position, clear color elsewhere', async () => { + const backend = await createBackend(); + const texture = createSolidTexture('#ff0000', 16, 16); + const root = new Container(); + const system = new ParticleSystem(texture, { capacity: 4 }); + + try { + // Deterministic placement: bypass spawn/update modules entirely and + // write the SoA slot directly. `lifetime` only matters if `update()` + // is called — it never is here, so the particle can't expire. + const slot = system.spawn(); + + system.posX[slot] = 0; + system.posY[slot] = 0; + system.scaleX[slot] = 1; + system.scaleY[slot] = 1; + system.rotations[slot] = 0; + system.color[slot] = 0xffffffff; // opaque white — no tint, texture color passes through + system.lifetime[slot] = 1; + + // Position the system itself so the particle (system-local quad + // centered on 0,0, half-extent 8px for a 16x16 texture) lands at + // (32, 32), well clear of the canvas edges. + system.setPosition(32, 32); + root.addChild(system); + + render(backend, root); + + // Interior of the particle quad (32,32 ± 8px) should be red. + expectPixelNear(readPixel(backend, 32, 32), [255, 0, 0, 255]); + expectPixelNear(readPixel(backend, 28, 28), [255, 0, 0, 255]); + // A safely particle-free corner remains the clear color (black). + expectPixelNear(readPixel(backend, 4, 4), [0, 0, 0, 255]); + expectPixelNear(readPixel(backend, 60, 60), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('particle color channel tints a white texture', async () => { + const backend = await createBackend(); + const texture = createSolidTexture('#ffffff', 16, 16); + const root = new Container(); + const system = new ParticleSystem(texture, { capacity: 4 }); + + try { + const slot = system.spawn(); + + system.posX[slot] = 0; + system.posY[slot] = 0; + system.scaleX[slot] = 1; + system.scaleY[slot] = 1; + system.color[slot] = new Color(0, 255, 0).toRgba(); + system.lifetime[slot] = 1; + + system.setPosition(32, 32); + root.addChild(system); + + render(backend, root); + + expectPixelNear(readPixel(backend, 32, 32), [0, 255, 0, 255]); + expectPixelNear(readPixel(backend, 4, 4), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-sprite-solid-color.test.ts b/test/rendering/browser/webgl2-sprite-solid-color.test.ts new file mode 100644 index 00000000..114c2784 --- /dev/null +++ b/test/rendering/browser/webgl2-sprite-solid-color.test.ts @@ -0,0 +1,276 @@ +/** + * WebGL2 Sprite browser test — v0.16 renderer-matrix proof entry. + * + * The simplest possible matrix case: a single opaque {@link Sprite} over a + * solid-color {@link Texture}, asserting pixel colour inside the sprite's + * bounds, outside its bounds, and after a tint is applied. Establishes the + * paired webgl2/webgpu pixel-assertion pattern for the drawable-matrix + * follow-up work (see `.workspace/specs/v0.16-render-matrix/00-plan.md`). + * + * Run via: pnpm test:browser:webgl + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; +import { Sprite } from '#rendering/sprite/Sprite'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGl2Backend } from '#rendering/webgl2/WebGl2Backend'; + +import { wireCoreRenderers } from './_coreRenderers'; + +// --------------------------------------------------------------------------- +// Shader mocks +// +// The vitest shaderPlugin replaces every .vert/.frag import with +// `export default ""`. `WebGl2Backend#initialize` connects the renderer +// registry eagerly (compiling every registered renderer's program, not just +// the ones a given test renders), so the Sprite + Mesh + Text shaders that +// `wireCoreRenderers()` registers all need valid GLSL sources even though +// this file only ever renders a Sprite. +// --------------------------------------------------------------------------- + +const shaderSources = vi.hoisted(() => ({ + spriteVert: `#version 300 es +precision mediump float; +in vec4 a_localBounds; +in vec4 a_uvBounds; +in vec4 a_color; +in uint a_textureSlot; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; +out vec4 v_color; +flat out uint v_textureSlot; +void main() { + vec2 local; + if (gl_VertexID == 0) local = vec2(a_localBounds.x, a_localBounds.y); + else if (gl_VertexID == 1) local = vec2(a_localBounds.z, a_localBounds.y); + else if (gl_VertexID == 2) local = vec2(a_localBounds.x, a_localBounds.w); + else local = vec2(a_localBounds.z, a_localBounds.w); + vec2 uv; + if (gl_VertexID == 0) uv = vec2(a_uvBounds.x, a_uvBounds.y); + else if (gl_VertexID == 1) uv = vec2(a_uvBounds.z, a_uvBounds.y); + else if (gl_VertexID == 2) uv = vec2(a_uvBounds.x, a_uvBounds.w); + else uv = vec2(a_uvBounds.z, a_uvBounds.w); + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + vec2 world = vec2(m0.x * local.x + m0.y * local.y + m1.x, m0.z * local.x + m0.w * local.y + m1.y); + vec3 clip = u_projection * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = uv; v_color = a_color; v_textureSlot = a_textureSlot; +}`, + + spriteFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +in vec4 v_color; +flat in uint v_textureSlot; +uniform sampler2D u_texture0; +uniform sampler2D u_texture1; +uniform sampler2D u_texture2; +uniform sampler2D u_texture3; +uniform sampler2D u_texture4; +uniform sampler2D u_texture5; +uniform sampler2D u_texture6; +uniform sampler2D u_texture7; +out vec4 outColor; +vec4 sampleTexture(uint slot, vec2 uv) { + if (slot == uint(0)) return texture(u_texture0, uv); + if (slot == uint(1)) return texture(u_texture1, uv); + if (slot == uint(2)) return texture(u_texture2, uv); + if (slot == uint(3)) return texture(u_texture3, uv); + if (slot == uint(4)) return texture(u_texture4, uv); + if (slot == uint(5)) return texture(u_texture5, uv); + if (slot == uint(6)) return texture(u_texture6, uv); + return texture(u_texture7, uv); +} +void main() { outColor = sampleTexture(v_textureSlot, v_uv) * v_color; }`, + + meshVert: `#version 300 es +precision mediump float; +in vec2 a_position; +in vec2 a_texcoord; +in vec4 a_color; +in uint a_nodeIndex; +uniform mat3 u_projection; +uniform sampler2D u_transforms; +out vec2 v_uv; out vec4 v_color; out vec4 v_tint; +void main() { + int row = int(a_nodeIndex); + vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); + vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); + mat3 t = mat3(m0.x,m0.z,0.0, m0.y,m0.w,0.0, m1.x,m1.y,1.0); + vec3 world = t * vec3(a_position, 1.0); + vec3 clip = u_projection * world; + gl_Position = vec4(clip.xy, 0.0, 1.0); + v_uv = a_texcoord; v_color = a_color; + v_tint = texelFetch(u_transforms, ivec2(2, row), 0); +}`, + + meshFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; in vec4 v_color; in vec4 v_tint; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv) * v_color * v_tint; }`, + + textVert: `#version 300 es +precision mediump float; +in vec2 a_position; in vec2 a_texcoord; in float a_nodeIndex; +uniform mat3 u_projection; +out vec2 v_uv; +void main() { + float ni = a_nodeIndex; + vec3 clip = u_projection * vec3(a_position + vec2(ni * 0.0), 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); v_uv = a_texcoord; +}`, + + textFrag: `#version 300 es +precision mediump float; +in vec2 v_uv; +uniform sampler2D u_texture; +out vec4 outColor; +void main() { outColor = texture(u_texture, v_uv); }`, +})); + +vi.mock('#rendering/webgl2/glsl/sprite.vert', () => ({ default: shaderSources.spriteVert })); +vi.mock('#rendering/webgl2/glsl/sprite.frag', () => ({ default: shaderSources.spriteFrag })); +vi.mock('#rendering/webgl2/glsl/mesh.vert', () => ({ default: shaderSources.meshVert })); +vi.mock('#rendering/webgl2/glsl/mesh.frag', () => ({ default: shaderSources.meshFrag })); +vi.mock('#rendering/webgl2/glsl/text.vert', () => ({ default: shaderSources.textVert })); +vi.mock('#rendering/webgl2/glsl/text-color.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-msdf.frag', () => ({ default: shaderSources.textFrag })); +vi.mock('#rendering/webgl2/glsl/text-sdf.frag', () => ({ default: shaderSources.textFrag })); + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const createBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const app: Application = { + canvas, + options: { + clearColor: Color.black, + canvas: { width: canvasSize, height: canvasSize }, + rendering: { + debug: false, + webglAttributes: { + alpha: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + depth: false, + }, + spriteRendererBatchSize: 1024, + particleRendererBatchSize: 1024, + }, + }, + } as unknown as Application; + + const backend = new WebGl2Backend(app); + + await backend.initialize(); + wireCoreRenderers(backend, app.options.rendering); + + return backend; +}; + +const render = (backend: WebGl2Backend, node: RenderNode): void => { + backend.resetStats(); + backend.clear(Color.black); + node.render(backend); + backend.flush(); +}; + +const readPixel = (backend: WebGl2Backend, x: number, y: number): RgbaTuple => { + const buf = new Uint8Array(4); + const gl = backend.context; + + gl.readPixels(Math.floor(x), backend.renderTarget.height - Math.floor(y) - 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); + + return [buf[0], buf[1], buf[2], buf[3]]; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 8): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const createSolidTexture = (color: string, width = 16, height = 16): Texture => { + const src = document.createElement('canvas'); + + src.width = width; + src.height = height; + + const ctx = src.getContext('2d')!; + + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + + return new Texture(src); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGL2 Sprite — solid color', () => { + test('solid-color texture fills sprite bounds, clear color remains outside', async () => { + const backend = await createBackend(); + const texture = createSolidTexture('#ff0000', 16, 16); + const root = new Container(); + const sprite = new Sprite(texture); + + try { + sprite.setPosition(8, 8); + root.addChild(sprite); + + render(backend, root); + + // Interior of the sprite (16x16 at 8,8 → covers 8..24) should be red + expectPixelNear(readPixel(backend, 16, 16), [255, 0, 0, 255]); + // Outside the sprite's bounds remains the clear color (black) + expectPixelNear(readPixel(backend, 40, 40), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('tint is applied to rendered output', async () => { + const backend = await createBackend(); + const texture = createSolidTexture('#ffffff', 16, 16); + const root = new Container(); + const sprite = new Sprite(texture); + + try { + sprite.setPosition(8, 8); + sprite.tint = new Color(0, 255, 0); + root.addChild(sprite); + + render(backend, root); + + expectPixelNear(readPixel(backend, 16, 16), [0, 255, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); diff --git a/test/rendering/browser/webgl2-video.test.ts b/test/rendering/browser/webgl2-video.test.ts new file mode 100644 index 00000000..002d5b88 --- /dev/null +++ b/test/rendering/browser/webgl2-video.test.ts @@ -0,0 +1,336 @@ +/** + * WebGL2 Video browser test — v0.16 renderer-matrix drawable entry. + * + * {@link Video} wraps an `HTMLVideoElement` as a live-texture {@link Sprite} + * (see `src/rendering/video/Video.ts`): its `Texture` holds the video element + * directly as `source`, and `updateTexture()` calls `texture.updateSource()` + * to bump the texture version whenever the decoded frame changes, which makes + * the backend re-upload via the same generic `texImage2D(..., source)` path + * used for any `TexImageSource` (canvas/image/video) — there is no + * video-specific upload code in `WebGl2Backend`. + * + * Fixture strategy: a `` painted a solid colour is turned into a + * `MediaStream` via `captureStream()`, assigned to a `