From cdde335b618db4f65b3a2cfc15f8ea52c29737a1 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 9 Jul 2026 05:11:39 +0200 Subject: [PATCH 1/9] test(rendering): add solid-color Sprite pixel-assertion proof for the renderer-matrix suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the paired WebGL2/WebGPU pixel-assertion pattern for the v0.16 Renderer-Matrix Browser Suite track: a single opaque Sprite over a solid-color texture, asserting interior color, out-of-bounds clear color, and tint, on both backends. Follows the existing per-file helper pattern used by webgl2/webgpu-repeating-sprite.test.ts verbatim — no new shared helper needed yet. --- .../browser/webgl2-sprite-solid-color.test.ts | 276 ++++++++++++++++++ .../browser/webgpu-sprite-solid-color.test.ts | 185 ++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 test/rendering/browser/webgl2-sprite-solid-color.test.ts create mode 100644 test/rendering/browser/webgpu-sprite-solid-color.test.ts 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/webgpu-sprite-solid-color.test.ts b/test/rendering/browser/webgpu-sprite-solid-color.test.ts new file mode 100644 index 00000000..00c76814 --- /dev/null +++ b/test/rendering/browser/webgpu-sprite-solid-color.test.ts @@ -0,0 +1,185 @@ +/** + * WebGPU 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`). + * + * All WebGPU renderers use inline WGSL — no shader file mocks are needed. + * CI guarantees a real WebGPU adapter (the required Chromium-WebGPU lane runs + * against Mesa lavapipe); `renderScene` only skips when the software adapter + * drops the device mid-test. + * + * Run via: pnpm test:browser:webgpu + */ + +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 { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDevice } from './webgpu-test-helpers'; + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const setupBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + + return backend; +}; + +// Read the presented WebGPU canvas back through a 2D canvas. +const readCanvas = (backend: WebGpuBackend): ((x: number, y: number) => RgbaTuple) => { + const source = backend.context.canvas as HTMLCanvasElement; + const readback = document.createElement('canvas'); + + readback.width = canvasSize; + readback.height = canvasSize; + + const ctx = readback.getContext('2d')!; + + ctx.drawImage(source, 0, 0); + + return (x: number, y: number): RgbaTuple => { + const { data } = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1); + + return [data[0], data[1], data[2], data[3]]; + }; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 16): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const createSolidTexture = (color: string, size = 16): 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); +}; + +const isDeviceLoss = (error: unknown): boolean => error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError'); + +const renderScene = async (ctx: { skip: (reason: string) => void }, backend: WebGpuBackend, root: RenderNode): Promise => { + const device = getBackendDevice(backend); + + device.pushErrorScope('validation'); + + let validationError: GPUError | null; + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + validationError = await device.popErrorScope(); + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return false; + } + + throw error; + } + + expect(validationError).toBeNull(); + + return true; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGPU Sprite — solid color', () => { + test('solid-color texture fills sprite bounds, clear color remains outside', async ctx => { + const backend = await setupBackend(); + + const texture = createSolidTexture('#ff0000', 16); + const root = new Container(); + const sprite = new Sprite(texture); + + try { + sprite.setPosition(8, 8); + root.addChild(sprite); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + expectPixelNear(readPixel(16, 16), [255, 0, 0, 255]); + expectPixelNear(readPixel(40, 40), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('tint is applied to rendered output', async ctx => { + const backend = await setupBackend(); + + const texture = createSolidTexture('#ffffff', 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + expectPixelNear(readPixel(16, 16), [0, 255, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); From 1923fedeee7e7c4f326fbec0a8ee55f81c473809 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 10 Jul 2026 04:33:21 +0200 Subject: [PATCH 2/9] test(rendering): add particles pixel-matrix tests (both backends) Covers @codexo/exojs-particles WebGl2ParticleRenderer/WebGpuParticleRenderer end-to-end: a deterministically-placed particle (SoA slot written directly, no spawn modules, no update() call) renders at a known pixel position with the expected texture/tint color, while a particle-free region shows the clear color. Particle renderer bindings are not part of wireCoreRenderers, so both files materialize them explicitly via particlesExtension.renderers. --- .../browser/webgl2-particles.test.ts | 380 ++++++++++++++++++ .../browser/webgpu-particles.test.ts | 229 +++++++++++ 2 files changed, 609 insertions(+) create mode 100644 test/rendering/browser/webgl2-particles.test.ts create mode 100644 test/rendering/browser/webgpu-particles.test.ts 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/webgpu-particles.test.ts b/test/rendering/browser/webgpu-particles.test.ts new file mode 100644 index 00000000..42e62d36 --- /dev/null +++ b/test/rendering/browser/webgpu-particles.test.ts @@ -0,0 +1,229 @@ +/** + * WebGPU ParticleSystem browser tests — opt-in, capability-aware. + * + * Validates the `@codexo/exojs-particles` WebGpuParticleRenderer end-to-end: + * a particle spawned with a fixed slot, position, scale and packed color is + * rendered to a real WebGPU canvas and read back via a 2D-canvas snapshot. + * + * 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. + * + * The particle renderer uses inline WGSL (no shader file imports), so no + * shader mocks are needed here — same as the other WebGPU browser tests. + * + * CI guarantees a real WebGPU adapter (the required Chromium-WebGPU lane runs + * against Mesa lavapipe); `renderScene` only skips when the software adapter + * drops the device mid-test. + * + * Run via: pnpm test:browser:webgpu + */ + +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 { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { particlesExtension, ParticleSystem } from '../../../packages/exojs-particles/src/index'; +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDevice } from './webgpu-test-helpers'; + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const setupBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + // 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; +}; + +// Read the presented WebGPU canvas back through a 2D canvas. +const readCanvas = (backend: WebGpuBackend): ((x: number, y: number) => RgbaTuple) => { + const source = backend.context.canvas as HTMLCanvasElement; + const readback = document.createElement('canvas'); + + readback.width = canvasSize; + readback.height = canvasSize; + + const ctx = readback.getContext('2d')!; + + ctx.drawImage(source, 0, 0); + + return (x: number, y: number): RgbaTuple => { + const { data } = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1); + + return [data[0], data[1], data[2], data[3]]; + }; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 16): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const createSolidTexture = (color: string, size = 16): 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); +}; + +const isDeviceLoss = (error: unknown): boolean => error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError'); + +const renderScene = async (ctx: { skip: (reason: string) => void }, backend: WebGpuBackend, root: RenderNode): Promise => { + const device = getBackendDevice(backend); + + device.pushErrorScope('validation'); + + let validationError: GPUError | null; + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + validationError = await device.popErrorScope(); + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return false; + } + + throw error; + } + + expect(validationError).toBeNull(); + + return true; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGPU ParticleSystem — solid color', () => { + test('a spawned particle renders at its fixed position, clear color elsewhere', async ctx => { + const backend = await setupBackend(); + + const texture = createSolidTexture('#ff0000'); + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + // Interior of the particle quad (32,32 ± 8px) should be red. + expectPixelNear(readPixel(32, 32), [255, 0, 0, 255]); + expectPixelNear(readPixel(28, 28), [255, 0, 0, 255]); + // A safely particle-free corner remains the clear color (black). + expectPixelNear(readPixel(4, 4), [0, 0, 0, 255]); + expectPixelNear(readPixel(60, 60), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('particle color channel tints a white texture', async ctx => { + const backend = await setupBackend(); + + const texture = createSolidTexture('#ffffff'); + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + expectPixelNear(readPixel(32, 32), [0, 255, 0, 255]); + expectPixelNear(readPixel(4, 4), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); From 742f0f1f7377b6b13e2a8d0db3b1226e09c9e9be Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 10 Jul 2026 04:37:41 +0200 Subject: [PATCH 3/9] test(rendering): add nine-slice sprite pixel-matrix tests (both backends) --- .../browser/webgl2-nine-slice.test.ts | 416 ++++++++++++++++++ .../browser/webgpu-nine-slice.test.ts | 331 ++++++++++++++ 2 files changed, 747 insertions(+) create mode 100644 test/rendering/browser/webgl2-nine-slice.test.ts create mode 100644 test/rendering/browser/webgpu-nine-slice.test.ts 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/webgpu-nine-slice.test.ts b/test/rendering/browser/webgpu-nine-slice.test.ts new file mode 100644 index 00000000..5bca1e62 --- /dev/null +++ b/test/rendering/browser/webgpu-nine-slice.test.ts @@ -0,0 +1,331 @@ +/** + * WebGPU NineSliceSprite browser tests — opt-in, capability-aware. + * + * 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. + * + * All WebGPU renderers use inline WGSL — no shader file mocks are needed. + * CI guarantees a real WebGPU adapter (the required Chromium-WebGPU lane runs + * against Mesa lavapipe); `renderScene` only skips when the software adapter + * drops the device mid-test. + * + * Run via: pnpm test:browser:webgpu + */ + +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 { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDevice } from './webgpu-test-helpers'; + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const setupBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + + return backend; +}; + +// Read the presented WebGPU canvas back through a 2D canvas. +const readCanvas = (backend: WebGpuBackend): ((x: number, y: number) => RgbaTuple) => { + const source = backend.context.canvas as HTMLCanvasElement; + const readback = document.createElement('canvas'); + + readback.width = canvasSize; + readback.height = canvasSize; + + const ctx = readback.getContext('2d')!; + + ctx.drawImage(source, 0, 0); + + return (x: number, y: number): RgbaTuple => { + const { data } = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1); + + return [data[0], data[1], data[2], data[3]]; + }; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 16): void => { + for (let i = 0; i < 4; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(tolerance); + } +}; + +const isDeviceLoss = (error: unknown): boolean => error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError'); + +const renderScene = async (ctx: { skip: (reason: string) => void }, backend: WebGpuBackend, root: RenderNode): Promise => { + const device = getBackendDevice(backend); + + device.pushErrorScope('validation'); + + let validationError: GPUError | null; + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + validationError = await device.popErrorScope(); + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return false; + } + + throw error; + } + + expect(validationError).toBeNull(); + + return true; +}; + +// --------------------------------------------------------------------------- +// 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('WebGPU NineSliceSprite', () => { + test('9-region UV mapping: corners, edges, and center land at their correct destination area', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + // Corner centers + expectPixelNear(readPixel(14, 14), colors.tl); + expectPixelNear(readPixel(50, 14), colors.tr); + expectPixelNear(readPixel(14, 50), colors.bl); + expectPixelNear(readPixel(50, 50), colors.br); + + // Edge centers + expectPixelNear(readPixel(32, 14), colors.top); + expectPixelNear(readPixel(32, 50), colors.bottom); + expectPixelNear(readPixel(14, 32), colors.left); + expectPixelNear(readPixel(50, 32), colors.right); + + // Center + expectPixelNear(readPixel(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(19, 14), colors.tl); + expectPixelNear(readPixel(21, 14), colors.top); + + // Corner/edge boundary sharpness (vertical), same column. + expectPixelNear(readPixel(14, 19), colors.tl); + expectPixelNear(readPixel(14, 21), colors.left); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('asymmetric borders scale each edge independently per axis', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + // Corner centers — each corner's footprint matches its own border size. + expectPixelNear(readPixel(4, 8), colors.tl); // 8x16 + expectPixelNear(readPixel(52, 8), colors.tr); // 24x16 + expectPixelNear(readPixel(4, 48), colors.bl); // 8x32 + expectPixelNear(readPixel(52, 48), colors.br); // 24x32 + + // Edge centers + expectPixelNear(readPixel(24, 8), colors.top); + expectPixelNear(readPixel(24, 48), colors.bottom); + expectPixelNear(readPixel(4, 24), colors.left); + expectPixelNear(readPixel(52, 24), colors.right); + + // Center + expectPixelNear(readPixel(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(4, 12), colors.tl); + expectPixelNear(readPixel(12, 8), colors.top); + expectPixelNear(readPixel(4, 20), colors.left); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('tint is applied uniformly across all nine regions', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + // Center source colour is white; tinted red, it should render pure red. + expectPixelNear(readPixel(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(50, 14); + + expect(trPixel[1]).toBeLessThan(32); + expect(trPixel[2]).toBeLessThan(32); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); From a9e9867f87d947c60a763ac9067b27a5926bd67e Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 10 Jul 2026 04:29:28 +0200 Subject: [PATCH 4/9] test(rendering): add animated sprite pixel-matrix tests (both backends) --- .../browser/webgl2-animated-sprite.test.ts | 331 ++++++++++++++++++ .../browser/webgpu-animated-sprite.test.ts | 249 +++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 test/rendering/browser/webgl2-animated-sprite.test.ts create mode 100644 test/rendering/browser/webgpu-animated-sprite.test.ts diff --git a/test/rendering/browser/webgl2-animated-sprite.test.ts b/test/rendering/browser/webgl2-animated-sprite.test.ts new file mode 100644 index 00000000..0cc3bfdc --- /dev/null +++ b/test/rendering/browser/webgl2-animated-sprite.test.ts @@ -0,0 +1,331 @@ +/** + * WebGL2 AnimatedSprite browser test — v0.16 renderer-matrix follow-up. + * + * {@link AnimatedSprite} reuses the normal Sprite renderer but swaps the + * texture-frame UV sub-region per animation frame. This asserts that swap + * actually samples the correct sub-rect of a shared spritesheet texture: a + * two-cell spritesheet (each cell a distinct solid color) is rendered at + * frame 0, then advanced to frame 1, with pixel reads proving the sampled + * color changes to match the new cell. + * + * 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 { Rectangle } from '#math/Rectangle'; +import { AnimatedSprite } from '#rendering/sprite/AnimatedSprite'; +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 an AnimatedSprite (which uses the Sprite +// renderer). +// --------------------------------------------------------------------------- + +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 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/webgpu-animated-sprite.test.ts b/test/rendering/browser/webgpu-animated-sprite.test.ts new file mode 100644 index 00000000..fac8bba1 --- /dev/null +++ b/test/rendering/browser/webgpu-animated-sprite.test.ts @@ -0,0 +1,249 @@ +/** + * WebGPU AnimatedSprite browser test — v0.16 renderer-matrix follow-up. + * + * {@link AnimatedSprite} reuses the normal Sprite renderer but swaps the + * texture-frame UV sub-region per animation frame. This asserts that swap + * actually samples the correct sub-rect of a shared spritesheet texture: a + * two-cell spritesheet (each cell a distinct solid color) is rendered at + * frame 0, then advanced to frame 1, with pixel reads proving the sampled + * color changes to match the new cell. + * + * All WebGPU renderers use inline WGSL — no shader file mocks are needed. + * CI guarantees a real WebGPU adapter (the required Chromium-WebGPU lane runs + * against Mesa lavapipe); `renderScene` only skips when the software adapter + * drops the device mid-test. + * + * Run via: pnpm test:browser:webgpu + */ + +import type { Application } from '#core/Application'; +import { Color } from '#core/Color'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; +import { Rectangle } from '#math/Rectangle'; +import { AnimatedSprite } from '#rendering/sprite/AnimatedSprite'; +import { Texture } from '#rendering/texture/Texture'; +import { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; + +import { wireCoreRenderers } from './_coreRenderers'; +import { getBackendDevice } from './webgpu-test-helpers'; + +// --------------------------------------------------------------------------- +// Infrastructure helpers +// --------------------------------------------------------------------------- + +type RgbaTuple = readonly [number, number, number, number]; + +const canvasSize = 64; +const cellSize = 16; + +const makeApp = (canvas: HTMLCanvasElement): Application => + ({ + canvas, + options: { + canvas: { width: canvasSize, height: canvasSize }, + clearColor: Color.black, + }, + }) as unknown as Application; + +const setupBackend = async (): Promise => { + const canvas = document.createElement('canvas'); + + canvas.width = canvasSize; + canvas.height = canvasSize; + + const backend = new WebGpuBackend(makeApp(canvas)); + + await backend.initialize(); + wireCoreRenderers(backend); + + return backend; +}; + +// Read the presented WebGPU canvas back through a 2D canvas. +const readCanvas = (backend: WebGpuBackend): ((x: number, y: number) => RgbaTuple) => { + const source = backend.context.canvas as HTMLCanvasElement; + const readback = document.createElement('canvas'); + + readback.width = canvasSize; + readback.height = canvasSize; + + const ctx = readback.getContext('2d')!; + + ctx.drawImage(source, 0, 0); + + return (x: number, y: number): RgbaTuple => { + const { data } = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1); + + return [data[0], data[1], data[2], data[3]]; + }; +}; + +const expectPixelNear = (actual: RgbaTuple, expected: RgbaTuple, tolerance = 16): 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); +}; + +const isDeviceLoss = (error: unknown): boolean => error instanceof DOMException && (error.name === 'OperationError' || error.name === 'AbortError'); + +const renderScene = async (ctx: { skip: (reason: string) => void }, backend: WebGpuBackend, root: RenderNode): Promise => { + const device = getBackendDevice(backend); + + device.pushErrorScope('validation'); + + let validationError: GPUError | null; + + try { + backend.resetStats(); + backend.clear(Color.black); + root.render(backend); + backend.flush(); + validationError = await device.popErrorScope(); + } catch (error) { + if (isDeviceLoss(error)) { + ctx.skip('WebGPU device lost mid-test — unstable software adapter'); + + return false; + } + + throw error; + } + + expect(validationError).toBeNull(); + + return true; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WebGPU AnimatedSprite — frame-region UV swap', () => { + test('frame 0 samples the first spritesheet cell', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + expectPixelNear(readPixel(16, 16), [255, 0, 0, 255]); + expectPixelNear(readPixel(40, 40), [0, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('advancing playback swaps to the second spritesheet cell', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + let readPixel = readCanvas(backend); + + expectPixelNear(readPixel(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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + readPixel = readCanvas(backend); + + // 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(16, 16), [0, 0, 255, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); + + test('play() restart returns playback to the first spritesheet cell', async ctx => { + const backend = await setupBackend(); + + 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); + + if (!(await renderScene(ctx, backend, root))) { + return; + } + + const readPixel = readCanvas(backend); + + expectPixelNear(readPixel(16, 16), [255, 0, 0, 255]); + } finally { + root.destroy(); + texture.destroy(); + backend.destroy(); + } + }); +}); From 7bc430ab99bf53f8656d8a110ffa3f43c65296b8 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Fri, 10 Jul 2026 04:36:02 +0200 Subject: [PATCH 5/9] test(rendering): add video pixel-matrix tests (both backends) --- test/rendering/browser/webgl2-video.test.ts | 336 ++++++++++++++++++++ test/rendering/browser/webgpu-video.test.ts | 246 ++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 test/rendering/browser/webgl2-video.test.ts create mode 100644 test/rendering/browser/webgpu-video.test.ts 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 `