From 470439f0be2eca9ea18465b6853bc2cefca65e6c Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sat, 6 Apr 2024 22:18:36 -0500 Subject: [PATCH] feat: Implement image wrapping configuration (#2963) This PR allows users to configure the ImageSource wrapping mode: Clamp, Repeat, or Mirror. Example of using the Repeat mode to repeat noise textures over time https://github.com/excaliburjs/Excalibur/assets/612071/ca199c17-569e-4f38-9810-72227e2ebb52 --- CHANGELOG.md | 10 ++ sandbox/tests/imagewrapping/index.html | 13 ++ sandbox/tests/imagewrapping/index.ts | 59 +++++++++ sandbox/tests/imagewrapping/noise.png | Bin 0 -> 8068 bytes .../Context/image-renderer/image-renderer.ts | 22 ++-- .../material-renderer/material-renderer.ts | 23 ++-- src/engine/Graphics/Context/material.ts | 23 ++-- src/engine/Graphics/Context/texture-loader.ts | 54 +++++++- src/engine/Graphics/Filtering.ts | 11 ++ src/engine/Graphics/FontTextInstance.ts | 4 +- src/engine/Graphics/ImageSource.ts | 71 +++++++++- src/engine/Graphics/Wrapping.ts | 24 ++++ src/engine/Graphics/index.ts | 1 + src/spec/ImageSourceSpec.ts | 123 +++++++++++++++++- 14 files changed, 398 insertions(+), 40 deletions(-) create mode 100644 sandbox/tests/imagewrapping/index.html create mode 100644 sandbox/tests/imagewrapping/index.ts create mode 100644 sandbox/tests/imagewrapping/noise.png create mode 100644 src/engine/Graphics/Wrapping.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2227735e..20b14aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added ability to configure image wrapping on `ex.ImageSource` with the new `ex.ImageWrapping.Clamp` (default), `ex.ImageWrapping.Repeat`, and `ex.ImageWrapping.Mirror`. + ```typescript + const image = new ex.ImageSource('path/to/image.png', { + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Repeat, + } + }); + ``` - Added pointer event support to `ex.TileMap`'s and individual `ex.Tile`'s - Added pointer event support to `ex.IsometricMap`'s and individual `ex.IsometricTile`'s - Added `useAnchor` parameter to `ex.GraphicsGroup` to allow users to opt out of anchor based positioning, if set to false all graphics members diff --git a/sandbox/tests/imagewrapping/index.html b/sandbox/tests/imagewrapping/index.html new file mode 100644 index 00000000..45b4bf43 --- /dev/null +++ b/sandbox/tests/imagewrapping/index.html @@ -0,0 +1,13 @@ + + + + + + Image Wrapping + + + + + + + \ No newline at end of file diff --git a/sandbox/tests/imagewrapping/index.ts b/sandbox/tests/imagewrapping/index.ts new file mode 100644 index 00000000..d7cbcfe4 --- /dev/null +++ b/sandbox/tests/imagewrapping/index.ts @@ -0,0 +1,59 @@ +/// + +// identity tagged template literal lights up glsl-literal vscode plugin +var glsl = x => x[0]; +var game = new ex.Engine({ + canvasElementId: 'game', + width: 800, + height: 800 +}); + +var fireShader = glsl`#version 300 es + precision mediump float; + uniform float animation_speed; + uniform float offset; + uniform float u_time_ms; + uniform sampler2D u_graphic; + uniform sampler2D noise; + in vec2 v_uv; + out vec4 fragColor; + + void main() { + vec2 animatedUV = vec2(v_uv.x, v_uv.y + (u_time_ms / 1000.) * 0.5); + vec4 color = texture(noise, animatedUV); + color.rgb += (v_uv.y - 0.5); + color.rgb = step(color.rgb, vec3(0.5)); + color.rgb = vec3(1.0) - color.rgb; + + fragColor.rgb = mix(vec3(1.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), v_uv.y); + fragColor.a = color.r; + fragColor.rgb = fragColor.rgb * fragColor.a; + } +` + +var noiseImage = new ex.ImageSource('./noise.png', { + filtering: ex.ImageFiltering.Blended, + wrapping: ex.ImageWrapping.Repeat +}); + +var material = game.graphicsContext.createMaterial({ + name: 'fire', + fragmentSource: fireShader, + images: { + 'noise': noiseImage + } +}) + +var actor = new ex.Actor({ + pos: ex.vec(0, 200), + anchor: ex.vec(0, 0), + width: 800, + height: 600, + color: ex.Color.Red +}); +actor.graphics.material = material; +game.add(actor); + +var loader = new ex.Loader([noiseImage]); + +game.start(loader); \ No newline at end of file diff --git a/sandbox/tests/imagewrapping/noise.png b/sandbox/tests/imagewrapping/noise.png new file mode 100644 index 0000000000000000000000000000000000000000..98ab46077a94bd74d1ab03a94ba56023f9a9d8cf GIT binary patch literal 8068 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 literal 0 HcmV?d00001 diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.ts b/src/engine/Graphics/Context/image-renderer/image-renderer.ts index ca4b9f07..68d07745 100644 --- a/src/engine/Graphics/Context/image-renderer/image-renderer.ts +++ b/src/engine/Graphics/Context/image-renderer/image-renderer.ts @@ -1,6 +1,8 @@ import { sign } from '../../../Math/util'; -import { ImageFiltering } from '../../Filtering'; +import { parseImageFiltering } from '../../Filtering'; import { GraphicsDiagnostics } from '../../GraphicsDiagnostics'; +import { ImageSourceAttributeConstants } from '../../ImageSource'; +import { parseImageWrapping } from '../../Wrapping'; import { HTMLImageSource } from '../ExcaliburGraphicsContext'; import { ExcaliburGraphicsContextWebGL, pixelSnapEpsilon } from '../ExcaliburGraphicsContextWebGL'; import { QuadIndexBuffer } from '../quad-index-buffer'; @@ -124,15 +126,19 @@ export class ImageRenderer implements RendererPlugin { if (this._images.has(image)) { return; } - const maybeFiltering = image.getAttribute('filtering'); - let filtering: ImageFiltering = null; - if (maybeFiltering === ImageFiltering.Blended || - maybeFiltering === ImageFiltering.Pixel) { - filtering = maybeFiltering; - } + const maybeFiltering = image.getAttribute(ImageSourceAttributeConstants.Filtering); + const filtering = maybeFiltering ? parseImageFiltering(maybeFiltering) : null; + const wrapX = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingX)); + const wrapY = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingY)); const force = image.getAttribute('forceUpload') === 'true' ? true : false; - const texture = this._context.textureLoader.load(image, filtering, force); + const texture = this._context.textureLoader.load( + image, + { + filtering, + wrapping: { x: wrapX, y: wrapY } + }, + force); // remove force attribute after upload image.removeAttribute('forceUpload'); if (this._textures.indexOf(texture) === -1) { diff --git a/src/engine/Graphics/Context/material-renderer/material-renderer.ts b/src/engine/Graphics/Context/material-renderer/material-renderer.ts index c23af65c..627e1b34 100644 --- a/src/engine/Graphics/Context/material-renderer/material-renderer.ts +++ b/src/engine/Graphics/Context/material-renderer/material-renderer.ts @@ -1,6 +1,8 @@ import { vec } from '../../../Math/vector'; -import { ImageFiltering } from '../../Filtering'; +import { parseImageFiltering } from '../../Filtering'; import { GraphicsDiagnostics } from '../../GraphicsDiagnostics'; +import { ImageSourceAttributeConstants } from '../../ImageSource'; +import { parseImageWrapping } from '../../Wrapping'; import { HTMLImageSource } from '../ExcaliburGraphicsContext'; import { ExcaliburGraphicsContextWebGL } from '../ExcaliburGraphicsContextWebGL'; import { QuadIndexBuffer } from '../quad-index-buffer'; @@ -204,15 +206,19 @@ export class MaterialRenderer implements RendererPlugin { } private _addImageAsTexture(image: HTMLImageSource) { - const maybeFiltering = image.getAttribute('filtering'); - let filtering: ImageFiltering = null; - if (maybeFiltering === ImageFiltering.Blended || - maybeFiltering === ImageFiltering.Pixel) { - filtering = maybeFiltering; - } + const maybeFiltering = image.getAttribute(ImageSourceAttributeConstants.Filtering); + const filtering = maybeFiltering ? parseImageFiltering(maybeFiltering) : null; + const wrapX = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingX)); + const wrapY = parseImageWrapping(image.getAttribute(ImageSourceAttributeConstants.WrappingY)); const force = image.getAttribute('forceUpload') === 'true' ? true : false; - const texture = this._context.textureLoader.load(image, filtering, force); + const texture = this._context.textureLoader.load( + image, + { + filtering, + wrapping: { x: wrapX, y: wrapY } + }, + force); // remove force attribute after upload image.removeAttribute('forceUpload'); if (this._textures.indexOf(texture) === -1) { @@ -228,5 +234,4 @@ export class MaterialRenderer implements RendererPlugin { flush(): void { // flush does not do anything, material renderer renders immediately per draw } - } \ No newline at end of file diff --git a/src/engine/Graphics/Context/material.ts b/src/engine/Graphics/Context/material.ts index 8d212551..2d1bf836 100644 --- a/src/engine/Graphics/Context/material.ts +++ b/src/engine/Graphics/Context/material.ts @@ -3,8 +3,9 @@ import { ExcaliburGraphicsContext } from './ExcaliburGraphicsContext'; import { ExcaliburGraphicsContextWebGL } from './ExcaliburGraphicsContextWebGL'; import { Shader } from './shader'; import { Logger } from '../../Util/Log'; -import { ImageSource } from '../ImageSource'; -import { ImageFiltering } from '../Filtering'; +import { ImageSource, ImageSourceAttributeConstants } from '../ImageSource'; +import { ImageFiltering, parseImageFiltering } from '../Filtering'; +import { parseImageWrapping } from '../Wrapping'; export interface MaterialOptions { /** @@ -191,15 +192,19 @@ export class Material { private _loadImageSource(image: ImageSource) { const imageElement = image.image; - const maybeFiltering = imageElement.getAttribute('filtering'); - let filtering: ImageFiltering = null; - if (maybeFiltering === ImageFiltering.Blended || - maybeFiltering === ImageFiltering.Pixel) { - filtering = maybeFiltering; - } + const maybeFiltering = imageElement.getAttribute(ImageSourceAttributeConstants.Filtering); + const filtering = maybeFiltering ? parseImageFiltering(maybeFiltering) : null; + const wrapX = parseImageWrapping(imageElement.getAttribute(ImageSourceAttributeConstants.WrappingX)); + const wrapY = parseImageWrapping(imageElement.getAttribute(ImageSourceAttributeConstants.WrappingY)); const force = imageElement.getAttribute('forceUpload') === 'true' ? true : false; - const texture = this._graphicsContext.textureLoader.load(imageElement, filtering, force); + const texture = this._graphicsContext.textureLoader.load( + imageElement, + { + filtering, + wrapping: { x: wrapX, y: wrapY } + }, + force); // remove force attribute after upload imageElement.removeAttribute('forceUpload'); if (!this._textures.has(image)) { diff --git a/src/engine/Graphics/Context/texture-loader.ts b/src/engine/Graphics/Context/texture-loader.ts index e7e9a331..c437255a 100644 --- a/src/engine/Graphics/Context/texture-loader.ts +++ b/src/engine/Graphics/Context/texture-loader.ts @@ -1,5 +1,7 @@ import { Logger } from '../../Util/Log'; import { ImageFiltering } from '../Filtering'; +import { ImageSourceOptions, ImageWrapConfiguration } from '../ImageSource'; +import { ImageWrapping } from '../Wrapping'; import { HTMLImageSource } from './ExcaliburGraphicsContext'; /** @@ -25,6 +27,7 @@ export class TextureLoader { * Sets the default filtering for the Excalibur texture loader, default [[ImageFiltering.Blended]] */ public static filtering: ImageFiltering = ImageFiltering.Blended; + public static wrapping: ImageWrapConfiguration = {x: ImageWrapping.Clamp, y: ImageWrapping.Clamp}; private _gl: WebGL2RenderingContext; @@ -51,16 +54,18 @@ export class TextureLoader { /** * Loads a graphic into webgl and returns it's texture info, a webgl context must be previously registered * @param image Source graphic - * @param filtering {ImageFiltering} The ImageFiltering mode to apply to the loaded texture + * @param options {ImageSourceOptions} Optionally configure the ImageFiltering and ImageWrapping mode to apply to the loaded texture * @param forceUpdate Optionally force a texture to be reloaded, useful if the source graphic has changed */ - public load(image: HTMLImageSource, filtering?: ImageFiltering, forceUpdate = false): WebGLTexture { + public load(image: HTMLImageSource, options?: ImageSourceOptions, forceUpdate = false): WebGLTexture { // Ignore loading if webgl is not registered const gl = this._gl; if (!gl) { return null; } + const { filtering, wrapping } = {...options}; + let tex: WebGLTexture = null; // If reuse the texture if it's from the same source if (this.has(image)) { @@ -85,9 +90,48 @@ export class TextureLoader { gl.bindTexture(gl.TEXTURE_2D, tex); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); - // TODO make configurable - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + let wrappingConfig: ImageWrapConfiguration; + if (wrapping) { + if (typeof wrapping === 'string') { + wrappingConfig = { + x: wrapping, + y: wrapping + }; + } else { + wrappingConfig = { + x: wrapping.x, + y: wrapping.y + }; + } + } + const { x: xWrap, y: yWrap} = (wrappingConfig ?? TextureLoader.wrapping); + switch (xWrap) { + case ImageWrapping.Clamp: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + break; + case ImageWrapping.Repeat: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + break; + case ImageWrapping.Mirror: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT); + break; + default: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + } + switch (yWrap) { + case ImageWrapping.Clamp: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + break; + case ImageWrapping.Repeat: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); + break; + case ImageWrapping.Mirror: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); + break; + default: + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } // NEAREST for pixel art, LINEAR for hi-res const filterMode = filtering ?? TextureLoader.filtering; diff --git a/src/engine/Graphics/Filtering.ts b/src/engine/Graphics/Filtering.ts index 67333df1..0801692e 100644 --- a/src/engine/Graphics/Filtering.ts +++ b/src/engine/Graphics/Filtering.ts @@ -15,4 +15,15 @@ export enum ImageFiltering { * Blended is useful when you have high resolution artwork and would like it blended and smoothed */ Blended = 'Blended' +} + +/** + * Parse the image filtering attribute value, if it doesn't match returns null + */ +export function parseImageFiltering(val: string): ImageFiltering | null { + switch (val) { + case ImageFiltering.Pixel: return ImageFiltering.Pixel; + case ImageFiltering.Blended: return ImageFiltering.Blended; + default: return null; + } } \ No newline at end of file diff --git a/src/engine/Graphics/FontTextInstance.ts b/src/engine/Graphics/FontTextInstance.ts index 5593f732..62c6e8eb 100644 --- a/src/engine/Graphics/FontTextInstance.ts +++ b/src/engine/Graphics/FontTextInstance.ts @@ -40,7 +40,7 @@ export class FontTextInstance { const metrics = this.ctx.measureText(maxWidthLine); let textHeight = Math.abs(metrics.actualBoundingBoxAscent) + Math.abs(metrics.actualBoundingBoxDescent); - // TODO lineheight makes the text bounds wonky + // TODO line height makes the text bounds wonky const lineAdjustedHeight = textHeight * lines.length; textHeight = lineAdjustedHeight; const bottomBounds = lineAdjustedHeight - Math.abs(metrics.actualBoundingBoxAscent); @@ -209,7 +209,7 @@ export class FontTextInstance { if (ex instanceof ExcaliburGraphicsContextWebGL) { for (const frag of this._textFragments) { - ex.textureLoader.load(frag.canvas, this.font.filtering, true); + ex.textureLoader.load(frag.canvas, { filtering: this.font.filtering }, true); } } this._lastHashCode = hashCode; diff --git a/src/engine/Graphics/ImageSource.ts b/src/engine/Graphics/ImageSource.ts index aa0ebde2..083c5108 100644 --- a/src/engine/Graphics/ImageSource.ts +++ b/src/engine/Graphics/ImageSource.ts @@ -5,16 +5,30 @@ import { Logger } from '../Util/Log'; import { ImageFiltering } from './Filtering'; import { Future } from '../Util/Future'; import { TextureLoader } from '../Graphics/Context/texture-loader'; +import { ImageWrapping } from './Wrapping'; export interface ImageSourceOptions { filtering?: ImageFiltering; + wrapping?: ImageWrapConfiguration | ImageWrapping; bustCache?: boolean; } +export interface ImageWrapConfiguration { + x: ImageWrapping; + y: ImageWrapping; +} + +export const ImageSourceAttributeConstants = { + Filtering: 'filtering', + WrappingX: 'wrapping-x', + WrappingY: 'wrapping-y' +} as const; + export class ImageSource implements Loadable { private _logger = Logger.getInstance(); private _resource: Resource; public filtering: ImageFiltering; + public wrapping: ImageWrapConfiguration; /** * The original size of the source image in pixels @@ -57,15 +71,40 @@ export class ImageSource implements Loadable { */ public ready: Promise = this._readyFuture.promise; + public readonly path: string; + + /** + * The path to the image, can also be a data url like 'data:image/' + * @param path {string} Path to the image resource relative from the HTML document hosting the game, or absolute + * @param options + */ + constructor(path: string, options?: ImageSourceOptions); /** * The path to the image, can also be a data url like 'data:image/' * @param path {string} Path to the image resource relative from the HTML document hosting the game, or absolute * @param bustCache {boolean} Should excalibur add a cache busting querystring? * @param filtering {ImageFiltering} Optionally override the image filtering set by [[EngineOptions.antialiasing]] */ - constructor(public readonly path: string, bustCache: boolean = false, filtering?: ImageFiltering) { + constructor(path: string, bustCache: boolean, filtering?: ImageFiltering); + constructor(path: string, bustCacheOrOptions: boolean | ImageSourceOptions, filtering?: ImageFiltering) { + this.path = path; + let bustCache = false; + let wrapping: ImageWrapConfiguration | ImageWrapping; + if (typeof bustCacheOrOptions === 'boolean') { + bustCache = bustCacheOrOptions; + } else { + ({ filtering, wrapping, bustCache } = {...bustCacheOrOptions}); + } this._resource = new Resource(path, 'blob', bustCache); - this.filtering = filtering; + this.filtering = filtering ?? this.filtering; + if (typeof wrapping === 'string') { + this.wrapping = { + x: wrapping, + y: wrapping + }; + } else { + this.wrapping = wrapping ?? this.wrapping; + } if (path.endsWith('.svg') || path.endsWith('.gif')) { this._logger.warn(`Image type is not fully supported, you may have mixed results ${path}. Fully supported: jpg, bmp, and png`); } @@ -82,9 +121,29 @@ export class ImageSource implements Loadable { imageSource.data.setAttribute('data-original-src', 'image-element'); if (options?.filtering) { - imageSource.data.setAttribute('filtering', options?.filtering); + imageSource.data.setAttribute(ImageSourceAttributeConstants.Filtering, options?.filtering); + } else { + imageSource.data.setAttribute(ImageSourceAttributeConstants.Filtering, ImageFiltering.Blended); + } + + if (options?.wrapping) { + let wrapping: ImageWrapConfiguration; + if (typeof options.wrapping === 'string') { + wrapping = { + x: options.wrapping, + y: options.wrapping + }; + } else { + wrapping = { + x: options.wrapping.x, + y: options.wrapping.y + }; + } + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingX, wrapping.x); + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingY, wrapping.y); } else { - imageSource.data.setAttribute('filtering', ImageFiltering.Blended); + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingX, ImageWrapping.Clamp); + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingY, ImageWrapping.Clamp); } TextureLoader.checkImageSizeSupportedAndLog(image); @@ -145,7 +204,9 @@ export class ImageSource implements Loadable { throw `Error loading ImageSource from path '${this.path}' with error [${error.message}]`; } // Do a bad thing to pass the filtering as an attribute - this.data.setAttribute('filtering', this.filtering); + this.data.setAttribute(ImageSourceAttributeConstants.Filtering, this.filtering); + this.data.setAttribute(ImageSourceAttributeConstants.WrappingX, this.wrapping?.x ?? ImageWrapping.Clamp); + this.data.setAttribute(ImageSourceAttributeConstants.WrappingY, this.wrapping?.y ?? ImageWrapping.Clamp); // todo emit complete this._readyFuture.resolve(this.data); diff --git a/src/engine/Graphics/Wrapping.ts b/src/engine/Graphics/Wrapping.ts new file mode 100644 index 00000000..324c21a3 --- /dev/null +++ b/src/engine/Graphics/Wrapping.ts @@ -0,0 +1,24 @@ + +/** + * Describes the different image wrapping modes + */ +export enum ImageWrapping { + + Clamp = 'Clamp', + + Repeat = 'Repeat', + + Mirror = 'Mirror' +} + +/** + * + */ +export function parseImageWrapping(val: string): ImageWrapping { + switch (val) { + case ImageWrapping.Clamp: return ImageWrapping.Clamp; + case ImageWrapping.Repeat: return ImageWrapping.Repeat; + case ImageWrapping.Mirror: return ImageWrapping.Mirror; + default: return ImageWrapping.Clamp; + } +} \ No newline at end of file diff --git a/src/engine/Graphics/index.ts b/src/engine/Graphics/index.ts index 2a23e1fa..b3707a31 100644 --- a/src/engine/Graphics/index.ts +++ b/src/engine/Graphics/index.ts @@ -40,6 +40,7 @@ export * from './PostProcessor/ColorBlindnessPostProcessor'; export * from './Context/texture-loader'; export * from './Filtering'; +export * from './Wrapping'; // Rendering diff --git a/src/spec/ImageSourceSpec.ts b/src/spec/ImageSourceSpec.ts index c0caea5d..000edaac 100644 --- a/src/spec/ImageSourceSpec.ts +++ b/src/spec/ImageSourceSpec.ts @@ -99,7 +99,15 @@ describe('A ImageSource', () => { expect(image.src).not.toBeNull(); expect(whenLoaded).toHaveBeenCalledTimes(1); - expect(webgl.textureLoader.load).toHaveBeenCalledWith(image, ex.ImageFiltering.Blended, false); + expect(webgl.textureLoader.load).toHaveBeenCalledWith( + image, + { + filtering: ex.ImageFiltering.Blended, + wrapping: { + x: ex.ImageWrapping.Clamp, + y: ex.ImageWrapping.Clamp + } + }, false); }); it('can load images with an image filtering Pixel', async () => { @@ -120,7 +128,118 @@ describe('A ImageSource', () => { expect(image.src).not.toBeNull(); expect(whenLoaded).toHaveBeenCalledTimes(1); - expect(webgl.textureLoader.load).toHaveBeenCalledWith(image, ex.ImageFiltering.Pixel, false); + expect(webgl.textureLoader.load).toHaveBeenCalledWith( + image, + { + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Clamp, + y: ex.ImageWrapping.Clamp + } + }, false); + }); + + it('can load images with an image wrap repeat', async () => { + const canvas = document.createElement('canvas'); + const webgl = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement: canvas + }); + const imageRenderer = new ImageRenderer({pixelArtSampler: false, uvPadding: 0}); + imageRenderer.initialize(webgl.__gl, webgl); + spyOn(webgl.textureLoader, 'load').and.callThrough(); + + const spriteFontImage = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png',{ + filtering: ex.ImageFiltering.Pixel, + wrapping: ex.ImageWrapping.Repeat + }); + const whenLoaded = jasmine.createSpy('whenLoaded'); + const image = await spriteFontImage.load(); + await spriteFontImage.ready.then(whenLoaded); + + imageRenderer.draw(image, 0, 0); + + expect(image.src).not.toBeNull(); + expect(whenLoaded).toHaveBeenCalledTimes(1); + expect(webgl.textureLoader.load).toHaveBeenCalledWith( + image, + { + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Repeat + } + }, false); + }); + + it('can load images with an image wrap repeat', async () => { + const canvas = document.createElement('canvas'); + const webgl = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement: canvas + }); + const imageRenderer = new ImageRenderer({pixelArtSampler: false, uvPadding: 0}); + imageRenderer.initialize(webgl.__gl, webgl); + spyOn(webgl.textureLoader, 'load').and.callThrough(); + + const spriteFontImage = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png',{ + filtering: ex.ImageFiltering.Pixel, + wrapping: ex.ImageWrapping.Mirror + }); + const whenLoaded = jasmine.createSpy('whenLoaded'); + const image = await spriteFontImage.load(); + await spriteFontImage.ready.then(whenLoaded); + + imageRenderer.draw(image, 0, 0); + + expect(image.src).not.toBeNull(); + expect(whenLoaded).toHaveBeenCalledTimes(1); + expect(webgl.textureLoader.load).toHaveBeenCalledWith( + image, + { + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Mirror, + y: ex.ImageWrapping.Mirror + } + }, false); + }); + + it('can load images with an image wrap mixed', async () => { + const canvas = document.createElement('canvas'); + const webgl = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement: canvas + }); + const imageRenderer = new ImageRenderer({pixelArtSampler: false, uvPadding: 0}); + imageRenderer.initialize(webgl.__gl, webgl); + spyOn(webgl.textureLoader, 'load').and.callThrough(); + const texParameteri = spyOn(webgl.__gl, 'texParameteri').and.callThrough(); + const gl = webgl.__gl; + + const spriteFontImage = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png',{ + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Mirror, + y: ex.ImageWrapping.Clamp + } + }); + const whenLoaded = jasmine.createSpy('whenLoaded'); + const image = await spriteFontImage.load(); + await spriteFontImage.ready.then(whenLoaded); + + imageRenderer.draw(image, 0, 0); + + expect(image.src).not.toBeNull(); + expect(whenLoaded).toHaveBeenCalledTimes(1); + expect(texParameteri.calls.argsFor(0)).toEqual([gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT]); + expect(texParameteri.calls.argsFor(1)).toEqual([gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]); + expect(webgl.textureLoader.load).toHaveBeenCalledWith( + image, + { + filtering: ex.ImageFiltering.Pixel, + wrapping: { + x: ex.ImageWrapping.Mirror, + y: ex.ImageWrapping.Clamp + } + }, false); }); it('can convert to a Sprite', async () => { -- 2.51.2