Skip to content

Commit

Permalink
feat: Implement image wrapping configuration (#2963)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
eonarheim committed Apr 7, 2024
1 parent 88594e2 commit 470439f
Show file tree
Hide file tree
Showing 14 changed files with 398 additions and 40 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions sandbox/tests/imagewrapping/index.html
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Wrapping</title>
</head>
<body>
<canvas id="game"></canvas>
<script src="../../lib/excalibur.js"></script>
<script src="index.js"></script>
</body>
</html>
59 changes: 59 additions & 0 deletions sandbox/tests/imagewrapping/index.ts
@@ -0,0 +1,59 @@
/// <reference path="../../lib/excalibur.d.ts" />

// 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);
Binary file added sandbox/tests/imagewrapping/noise.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 14 additions & 8 deletions 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';
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 14 additions & 9 deletions 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';
Expand Down Expand Up @@ -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) {
Expand All @@ -228,5 +234,4 @@ export class MaterialRenderer implements RendererPlugin {
flush(): void {
// flush does not do anything, material renderer renders immediately per draw
}

}
23 changes: 14 additions & 9 deletions src/engine/Graphics/Context/material.ts
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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)) {
Expand Down
54 changes: 49 additions & 5 deletions 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';

/**
Expand All @@ -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;

Expand All @@ -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)) {
Expand All @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/engine/Graphics/Filtering.ts
Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions src/engine/Graphics/FontTextInstance.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 470439f

Please sign in to comment.