diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8800b2fa..4f38b916 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,21 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
+- Added new `new ex.Engine({uvPadding: .25})` option to allow users using texture atlases in their sprite sheets to configure this to avoid texture bleed. This can happen if you're sampling from images meant for pixel art
+- Added new antialias settings for pixel art! This allows for smooth subpixel rendering of pixel art without shimmer/fat-pixel artifacts.
+ - Use `new ex.Engine({pixelArt: true})` to opt in to all the right defaults to make this work!
+- Added new antialias configuration options to deeply configure how Excalibur does any antialiasing, or you can provide `antialiasing: true`/`antialiasing: false` to use the old defaults.
+ - Example;
+ ```typescript
+ const game = new ex.Engine({
+ antialiasing: {
+ pixelArtSampler: false,
+ filtering: ex.ImageFiltering.Pixel,
+ nativeContextAntialiasing: false,
+ canvasImageRendering: 'pixelated'
+ }
+ })
+ ```
- Added new `lineHeight` property on `SpriteFont` and `Font` to manually adjust the line height when rendering text.
- Added missing dual of `ex.GraphicsComponent.add()`, you can now `ex.GraphicsComponent.remove(name)`;
- Added additional options to `ex.Animation.fromSpriteSheetCoordinates()` you can now pass any valid `ex.GraphicOptions` to influence the sprite per frame
@@ -156,6 +171,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
+- Fixed issue with `snapToPixel` where the `ex.Camera` was not snapping correctly
- Fixed issue where using CSS transforms on the canvas confused Excalibur pointers
- Fixed issue with *AndFill suffixed [[DisplayModes]]s where content area offset was not accounted for in world space
- Fixed issue where `ex.Sound.getTotalPlaybackDuration()` would crash if not loaded, now logs friendly warning
diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts
index 26be9c51..e4553eaf 100644
--- a/sandbox/src/game.ts
+++ b/sandbox/src/game.ts
@@ -52,13 +52,13 @@ var game = new ex.Engine({
snapToPixel: false,
fixedUpdateFps: 30,
maxFps: 60,
+ antialiasing: false,
configurePerformanceCanvas2DFallback: {
allow: true,
showPlayerMessage: true,
threshold: { fps: 20, numberOfFrames: 100 }
}
});
-game.setAntialiasing(false);
game.screen.events.on('fullscreen', (evt) => {
console.log('fullscreen', evt);
});
diff --git a/sandbox/tests/graphicscontext/index.html b/sandbox/tests/graphicscontext/index.html
new file mode 100644
index 00000000..cfde743e
--- /dev/null
+++ b/sandbox/tests/graphicscontext/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Graphics Context
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sandbox/tests/graphicscontext/index.ts b/sandbox/tests/graphicscontext/index.ts
new file mode 100644
index 00000000..b9d33203
--- /dev/null
+++ b/sandbox/tests/graphicscontext/index.ts
@@ -0,0 +1,30 @@
+var canvasElement = document.createElement('canvas');
+document.body.appendChild(canvasElement);
+canvasElement.width = 100;
+canvasElement.height = 100;
+var sut = new ex.ExcaliburGraphicsContextWebGL({
+ canvasElement: canvasElement,
+ enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: true,
+ backgroundColor: ex.Color.White
+});
+sut.updateViewport({
+ width: 100,
+ height: 100
+});
+
+var rect = new ex.Rectangle({
+ width: 50,
+ height: 50,
+ color: ex.Color.Blue
+});
+sut.beginDrawLifecycle();
+sut.clear();
+
+sut.save()
+sut.drawImage(rect._bitmap, 20, 20);
+sut.restore();
+
+sut.flush();
+sut.endDrawLifecycle();
diff --git a/sandbox/tests/raycast/main.ts b/sandbox/tests/raycast/main.ts
index 0c7b3469..74e3502b 100644
--- a/sandbox/tests/raycast/main.ts
+++ b/sandbox/tests/raycast/main.ts
@@ -13,7 +13,7 @@ var notPlayers2 = playerGroup.invert();
var player = new ex.Actor({
name: 'player',
- pos: ex.vec(100, 100),
+ pos: ex.vec(70, 320),
width: 40,
height: 40,
collisionGroup: playerGroup,
diff --git a/sandbox/tests/sprite/sprite.ts b/sandbox/tests/sprite/sprite.ts
index 21ced233..36faeb27 100644
--- a/sandbox/tests/sprite/sprite.ts
+++ b/sandbox/tests/sprite/sprite.ts
@@ -3,9 +3,13 @@
var game = new ex.Engine({
canvasElementId: 'game',
width: 600,
- height: 400
+ height: 400,
+ displayMode: ex.DisplayMode.FitScreenAndFill,
+ pixelArt: true,
+ // antialiasing: false
});
+
var tex = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png');
var loader = new ex.Loader([tex]);
@@ -23,3 +27,7 @@ actor.onInitialize = () => {
};
game.add(actor);
game.start(loader);
+
+game.currentScene.camera.pos = actor.pos;
+game.currentScene.camera.zoom = 7;
+actor.angularVelocity = .1;
diff --git a/src/engine/Camera.ts b/src/engine/Camera.ts
index 2524f8c6..5d05cf6e 100644
--- a/src/engine/Camera.ts
+++ b/src/engine/Camera.ts
@@ -12,6 +12,8 @@ import { ExcaliburGraphicsContext } from './Graphics/Context/ExcaliburGraphicsCo
import { watchAny } from './Util/Watch';
import { AffineMatrix } from './Math/affine-matrix';
import { EventEmitter, EventKey, Handler, Subscription } from './EventEmitter';
+import { pixelSnapEpsilon } from './Graphics';
+import { sign } from './Math/util';
/**
* Interface that describes a custom camera strategy for tracking targets
@@ -776,6 +778,7 @@ export class Camera implements CanUpdate, CanInitialize {
this._postupdate(engine, delta);
}
+ private _snapPos = vec(0, 0);
/**
* Applies the relevant transformations to the game canvas to "move" or apply effects to the Camera
* @param ctx Canvas context to apply transformations
@@ -794,7 +797,14 @@ export class Camera implements CanUpdate, CanInitialize {
interpolatedPos.clone(this.drawPos);
this.updateTransform(interpolatedPos);
}
-
+ // Snap camera to pixel
+ if (ctx.snapToPixel) {
+ const snapPos = this.drawPos.clone(this._snapPos);
+ snapPos.x = ~~(snapPos.x + pixelSnapEpsilon * sign(snapPos.x));
+ snapPos.y = ~~(snapPos.y + pixelSnapEpsilon * sign(snapPos.y));
+ snapPos.clone(this.drawPos);
+ this.updateTransform(snapPos);
+ }
ctx.multiply(this.transform);
}
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts
index f09737c1..487328dc 100644
--- a/src/engine/Engine.ts
+++ b/src/engine/Engine.ts
@@ -35,7 +35,15 @@ import { Scene, SceneConstructor, isSceneConstructor } from './Scene';
import { Entity } from './EntityComponentSystem/Entity';
import { Debug, DebugStats } from './Debug/Debug';
import { BrowserEvents } from './Util/Browser';
-import { ExcaliburGraphicsContext, ExcaliburGraphicsContext2DCanvas, ExcaliburGraphicsContextWebGL, TextureLoader } from './Graphics';
+import {
+ AntialiasOptions,
+ DefaultAntialiasOptions,
+ DefaultPixelArtOptions,
+ ExcaliburGraphicsContext,
+ ExcaliburGraphicsContext2DCanvas,
+ ExcaliburGraphicsContextWebGL,
+ TextureLoader
+} from './Graphics';
import { Clock, StandardClock } from './Util/Clock';
import { ImageFiltering } from './Graphics/Filtering';
import { GraphicsDiagnostics } from './Graphics/GraphicsDiagnostics';
@@ -74,6 +82,8 @@ export const EngineEvents = {
PostDraw: 'postdraw'
} as const;
+
+
/**
* Enum representing the different mousewheel event bubble prevention
*/
@@ -122,12 +132,49 @@ export interface EngineOptions {
* Optionally specify antialiasing (smoothing), by default true (smooth pixels)
*
* * `true` - useful for high resolution art work you would like smoothed, this also hints excalibur to load images
- * with [[ImageFiltering.Blended]]
+ * with default blending [[ImageFiltering.Blended]]
*
* * `false` - useful for pixel art style art work you would like sharp, this also hints excalibur to load images
- * with [[ImageFiltering.Pixel]]
+ * with default blending [[ImageFiltering.Pixel]]
+ *
+ * * [[AntialiasOptions]] Optionally deeply configure the different antialiasing settings, **WARNING** thar be dragons here.
+ * It is recommended you stick to `true` or `false` unless you understand what you're doing and need to control rendering to
+ * a high degree.
+ */
+ antialiasing?: boolean | AntialiasOptions
+
+ /**
+ * Quick convenience property to configure Excalibur to use special settings for "pretty" anti-aliased pixel art
+ *
+ * 1. Turns on special shader condition to blend for pixel art and enables various antialiasing settings,
+ * notice blending is ON for this special mode.
+ *
+ * Equivalent to:
+ * ```javascript
+ * antialiasing: {
+ * pixelArtSampler: true,
+ * canvasImageRendering: 'auto',
+ * filtering: ImageFiltering.Blended,
+ * webglAntialiasing: true
+ * }
+ * ```
+ */
+ pixelArt?: boolean;
+
+ /**
+ * Specify any UV padding you want use in pixels, this brings sampling into the texture if you're using
+ * a sprite sheet in one image to prevent sampling bleed.
+ *
+ * By default .01 pixels, and .25 pixels if `pixelArt: true`
*/
- antialiasing?: boolean;
+ uvPadding?: number;
+
+ /**
+ * Optionally hint the graphics context into a specific power profile
+ *
+ * Default "high-performance"
+ */
+ powerPreference?: 'default' | 'high-performance' | 'low-power';
/**
* Optionally upscale the number of pixels in the canvas. Normally only useful if you need a smoother look to your assets, especially
@@ -601,6 +648,9 @@ export class Engine implements CanInitialize,
canvasElementId: '',
canvasElement: undefined,
snapToPixel: false,
+ antialiasing: true,
+ pixelArt: false,
+ powerPreference: 'high-performance',
pointerScope: PointerScope.Canvas,
suppressConsoleBootMessage: null,
suppressMinimumBrowserFeatureDetection: null,
@@ -735,6 +785,43 @@ O|===|* >________________>\n\
this._originalDisplayMode = displayMode;
+ let pixelArtSampler: boolean;
+ let uvPadding: number;
+ let nativeContextAntialiasing: boolean;
+ let canvasImageRendering: 'pixelated' | 'auto';
+ let filtering: ImageFiltering;
+ let multiSampleAntialiasing: boolean | { samples: number };
+ if (typeof options.antialiasing === 'object') {
+ ({
+ pixelArtSampler,
+ nativeContextAntialiasing,
+ multiSampleAntialiasing,
+ filtering,
+ canvasImageRendering
+ } = {
+ ...(options.pixelArt ? DefaultPixelArtOptions : DefaultAntialiasOptions),
+ ...options.antialiasing
+ });
+
+ } else {
+ pixelArtSampler = !!options.pixelArt;
+ nativeContextAntialiasing = false;
+ multiSampleAntialiasing = options.antialiasing;
+ canvasImageRendering = options.antialiasing ? 'auto' : 'pixelated';
+ filtering = options.antialiasing ? ImageFiltering.Blended : ImageFiltering.Pixel;
+ }
+
+ if (nativeContextAntialiasing && multiSampleAntialiasing) {
+ this._logger.warnOnce(`Cannot use antialias setting nativeContextAntialiasing and multiSampleAntialiasing` +
+ ` at the same time, they are incompatible settings. If you aren\'t sure use multiSampleAntialiasing`);
+ }
+
+ if (options.pixelArt) {
+ uvPadding = .25;
+ }
+ // Override with any user option, if non default to .25 for pixel art, 0.01 for everything else
+ uvPadding = options.uvPadding ?? uvPadding ?? 0.01;
+
// Canvas 2D fallback can be flagged on
let useCanvasGraphicsContext = Flags.isEnabled('use-canvas-context');
if (!useCanvasGraphicsContext) {
@@ -743,7 +830,11 @@ O|===|* >________________>\n\
this.graphicsContext = new ExcaliburGraphicsContextWebGL({
canvasElement: this.canvas,
enableTransparency: this.enableCanvasTransparency,
- smoothing: options.antialiasing,
+ pixelArtSampler: pixelArtSampler,
+ antialiasing: nativeContextAntialiasing,
+ multiSampleAntialiasing: multiSampleAntialiasing,
+ uvPadding: uvPadding,
+ powerPreference: options.powerPreference,
backgroundColor: options.backgroundColor,
snapToPixel: options.snapToPixel,
useDrawSorting: options.useDrawSorting
@@ -763,7 +854,7 @@ O|===|* >________________>\n\
this.graphicsContext = new ExcaliburGraphicsContext2DCanvas({
canvasElement: this.canvas,
enableTransparency: this.enableCanvasTransparency,
- smoothing: options.antialiasing,
+ antialiasing: nativeContextAntialiasing,
backgroundColor: options.backgroundColor,
snapToPixel: options.snapToPixel,
useDrawSorting: options.useDrawSorting
@@ -773,7 +864,8 @@ O|===|* >________________>\n\
this.screen = new Screen({
canvas: this.canvas,
context: this.graphicsContext,
- antialiasing: options.antialiasing ?? true,
+ antialiasing: nativeContextAntialiasing,
+ canvasImageRendering: canvasImageRendering,
browser: this.browser,
viewport: options.viewport ?? (options.width && options.height ? { width: options.width, height: options.height } : Resolution.SVGA),
resolution: options.resolution,
@@ -783,7 +875,7 @@ O|===|* >________________>\n\
// TODO REMOVE STATIC!!!
// Set default filtering based on antialiasing
- TextureLoader.filtering = options.antialiasing ? ImageFiltering.Blended : ImageFiltering.Pixel;
+ TextureLoader.filtering = filtering;
if (options.backgroundColor) {
this.backgroundColor = options.backgroundColor.clone();
@@ -883,7 +975,7 @@ O|===|* >________________>\n\
this.graphicsContext = new ExcaliburGraphicsContext2DCanvas({
canvasElement: this.canvas,
enableTransparency: this.enableCanvasTransparency,
- smoothing: options.antialiasing,
+ antialiasing: options.antialiasing,
backgroundColor: options.backgroundColor,
snapToPixel: options.snapToPixel,
useDrawSorting: options.useDrawSorting
@@ -1189,6 +1281,7 @@ O|===|* >________________>\n\
* canvas. Set this to `false` if you want a 'jagged' pixel art look to your
* image resources.
* @param isSmooth Set smoothing to true or false
+ * @deprecated Set in engine constructor, will be removed in v0.30
*/
public setAntialiasing(isSmooth: boolean) {
this.screen.antialiasing = isSmooth;
@@ -1196,6 +1289,7 @@ O|===|* >________________>\n\
/**
* Return the current smoothing status of the canvas
+ * @deprecated Set in engine constructor, will be removed in v0.30
*/
public getAntialiasing(): boolean {
return this.screen.antialiasing;
diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts
index 7fb2e3d3..56032bad 100644
--- a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts
+++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts
@@ -4,15 +4,122 @@ import { ScreenDimension } from '../../Screen';
import { PostProcessor } from '../PostProcessor/PostProcessor';
import { AffineMatrix } from '../../Math/affine-matrix';
import { Material, MaterialOptions } from './material';
+import { ImageFiltering } from '../Filtering';
export type HTMLImageSource = HTMLImageElement | HTMLCanvasElement;
+export interface AntialiasOptions {
+ /**
+ * Turns on the special pixel art sampler in excalibur's image shader for sub pixel
+ * anti-aliasing
+ *
+ * Default false
+ */
+ pixelArtSampler?: boolean;
+ /**
+ * Configures the webgl's getContext('webgl2', {antialias: true | false}) or configures
+ * Canvas2D imageSmoothing = true;
+ *
+ * **Note** this option is incompatible with `multiSampleAntialiasing`
+ *
+ * Default false
+ */
+ nativeContextAntialiasing?: boolean;
+ /**
+ * Configures the internal render buffer multi-sampling settings
+ *
+ * Default true, with max samples that the platform supports
+ */
+ multiSampleAntialiasing?: boolean | {
+ /**
+ * Optionally specify number of samples (will be clamped to the max the platform supports)
+ *
+ * Default most platforms are 16 samples
+ */
+ samples: number;
+ };
+ /**
+ * Sets the default image filtering for excalibur
+ *
+ * Default [[ImageFiltering.Blended]]
+ */
+ filtering?: ImageFiltering;
+ /**
+ * Sets the canvas image rendering CSS style
+ *
+ * Default 'auto'
+ */
+ canvasImageRendering?: 'pixelated' | 'auto';
+}
+
+export const DefaultAntialiasOptions: Required = {
+ pixelArtSampler: false,
+ nativeContextAntialiasing: false,
+ multiSampleAntialiasing: true,
+ filtering: ImageFiltering.Blended,
+ canvasImageRendering: 'auto'
+};
+
+export const DefaultPixelArtOptions: Required = {
+ pixelArtSampler: true,
+ nativeContextAntialiasing: false,
+ multiSampleAntialiasing: true,
+ filtering: ImageFiltering.Blended,
+ canvasImageRendering: 'auto'
+};
+
export interface ExcaliburGraphicsContextOptions {
+ /**
+ * Target existing html canvas element
+ */
canvasElement: HTMLCanvasElement;
- smoothing?: boolean;
+ /**
+ * Enables antialiasing on the canvas context (smooths pixels with default canvas sampling)
+ */
+ antialiasing?: boolean;
+ /**
+ * Enable the sub pixel antialiasing pixel art sampler for nice looking pixel art
+ */
+ pixelArtSampler?: boolean;
+ /**
+ * Enable canvas transparency
+ */
enableTransparency?: boolean;
+ /**
+ * Enable or disable multi-sample antialiasing in the internal render buffer.
+ *
+ * If true the max number of samples will be used
+ *
+ * By default enabled
+ */
+ multiSampleAntialiasing?: boolean | {
+ /**
+ * Specify number of samples to use during the multi sample anti-alias, if not specified the max will be used.
+ * Limited by the hardware (usually 16)
+ */
+ samples: number
+ },
+ /**
+ * UV padding in pixels to use in the internal image rendering
+ *
+ * Recommended .25 - .5 of a pixel
+ */
+ uvPadding?: number;
+ /**
+ * Hint the power preference to the graphics context
+ */
+ powerPreference?: 'default' | 'high-performance' | 'low-power';
+ /**
+ * Snaps the pixel to an integer value (floor)
+ */
snapToPixel?: boolean;
+ /**
+ * Current clearing color of the context
+ */
backgroundColor?: Color;
+ /**
+ * Feature flag that enables draw sorting will removed in v0.29
+ */
useDrawSorting?: boolean;
}
@@ -95,7 +202,7 @@ export interface ExcaliburGraphicsContext {
snapToPixel: boolean;
/**
- * Enable smoothed drawing (also known as anti-aliasing), by default false
+ * Enable smoothed drawing (also known as anti-aliasing), by default true
*/
smoothing: boolean;
diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts
index 8e361c83..ea8b4ac3 100644
--- a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts
+++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts
@@ -134,7 +134,7 @@ export class ExcaliburGraphicsContext2DCanvas implements ExcaliburGraphicsContex
}
constructor(options: ExcaliburGraphicsContextOptions) {
- const { canvasElement, enableTransparency, snapToPixel, smoothing, backgroundColor } = options;
+ const { canvasElement, enableTransparency, snapToPixel, antialiasing: smoothing, backgroundColor } = options;
this.__ctx = canvasElement.getContext('2d', {
alpha: enableTransparency ?? true
});
diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
index 5a55ced2..c50f4caa 100644
--- a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
+++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts
@@ -106,6 +106,9 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
// Main render target
private _renderTarget: RenderTarget;
+ // Quad boundary MSAA
+ private _msaaTarget: RenderTarget;
+
// Postprocessing is a tuple with 2 render targets, these are flip-flopped during the postprocessing process
private _postProcessTargets: RenderTarget[] = [];
@@ -122,9 +125,27 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
private _state = new StateStack();
private _ortho!: Matrix;
+ /**
+ * Snaps the drawing x/y coordinate to the nearest whole pixel
+ */
public snapToPixel: boolean = false;
- public smoothing: boolean = false;
+ /**
+ * Native context smoothing
+ */
+ public readonly smoothing: boolean = false;
+
+
+ /**
+ * Whether the pixel art sampler is enabled for smooth sub pixel anti-aliasing
+ */
+ public readonly pixelArtSampler: boolean = false;
+
+ /**
+ * UV padding in pixels to use in internal image rendering to prevent texture bleed
+ *
+ */
+ public uvPadding = .01;
public backgroundColor: Color = Color.ExcaliburBlue;
@@ -181,23 +202,41 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
return supported;
}
+ public readonly multiSampleAntialiasing: boolean = true;
+ public readonly samples?: number;
+ public readonly transparency: boolean = true;
+
constructor(options: ExcaliburGraphicsContextOptions) {
- const { canvasElement, enableTransparency, smoothing, snapToPixel, backgroundColor, useDrawSorting } = options;
+ const {
+ canvasElement,
+ enableTransparency,
+ antialiasing,
+ uvPadding,
+ multiSampleAntialiasing,
+ pixelArtSampler,
+ powerPreference,
+ snapToPixel,
+ backgroundColor,
+ useDrawSorting
+ } = options;
this.__gl = canvasElement.getContext('webgl2', {
- antialias: smoothing ?? this.smoothing,
+ antialias: antialiasing ?? this.smoothing,
premultipliedAlpha: false,
- alpha: enableTransparency ?? true,
- depth: true,
- powerPreference: 'high-performance'
- // TODO Chromium fixed the bug where this didn't work now it breaks CI :(
- // failIfMajorPerformanceCaveat: true
+ alpha: enableTransparency ?? this.transparency,
+ depth: false,
+ powerPreference: powerPreference ?? 'high-performance'
});
if (!this.__gl) {
throw Error('Failed to retrieve webgl context from browser');
}
this.textureLoader = new TextureLoader(this.__gl);
this.snapToPixel = snapToPixel ?? this.snapToPixel;
- this.smoothing = smoothing ?? this.smoothing;
+ this.smoothing = antialiasing ?? this.smoothing;
+ this.transparency = enableTransparency ?? this.transparency;
+ this.pixelArtSampler = pixelArtSampler ?? this.pixelArtSampler;
+ this.uvPadding = uvPadding ?? this.uvPadding;
+ this.multiSampleAntialiasing = typeof multiSampleAntialiasing === 'boolean' ? multiSampleAntialiasing : this.multiSampleAntialiasing;
+ this.samples = typeof multiSampleAntialiasing === 'object' ? multiSampleAntialiasing.samples : undefined;
this.backgroundColor = backgroundColor ?? this.backgroundColor;
this.useDrawSorting = useDrawSorting ?? this.useDrawSorting;
this._drawCallPool.disableWarnings = true;
@@ -224,7 +263,10 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Setup builtin renderers
- this.register(new ImageRenderer());
+ this.register(new ImageRenderer({
+ uvPadding: this.uvPadding,
+ pixelArtSampler: this.pixelArtSampler
+ }));
this.register(new MaterialRenderer());
this.register(new RectangleRenderer());
this.register(new CircleRenderer());
@@ -245,23 +287,34 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
this._renderTarget = new RenderTarget({
gl,
+ transparency: this.transparency,
width: gl.canvas.width,
height: gl.canvas.height
});
-
this._postProcessTargets = [
new RenderTarget({
gl,
+ transparency: this.transparency,
width: gl.canvas.width,
height: gl.canvas.height
}),
new RenderTarget({
gl,
+ transparency: this.transparency,
width: gl.canvas.width,
height: gl.canvas.height
})
];
+
+ this._msaaTarget = new RenderTarget({
+ gl,
+ transparency: this.transparency,
+ width: gl.canvas.width,
+ height: gl.canvas.height,
+ antialias: this.multiSampleAntialiasing,
+ samples: this.samples
+ });
}
public register(renderer: T) {
@@ -341,6 +394,7 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
this._ortho = this._ortho = Matrix.ortho(0, resolution.width, resolution.height, 0, 400, -400);
this._renderTarget.setResolution(gl.canvas.width, gl.canvas.height);
+ this._msaaTarget.setResolution(gl.canvas.width, gl.canvas.height);
this._postProcessTargets[0].setResolution(gl.canvas.width, gl.canvas.height);
this._postProcessTargets[1].setResolution(gl.canvas.width, gl.canvas.height);
}
@@ -514,7 +568,8 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
clear() {
const gl = this.__gl;
- this._renderTarget.use();
+ const currentTarget = this.multiSampleAntialiasing ? this._msaaTarget : this._renderTarget;
+ currentTarget.use();
gl.clearColor(this.backgroundColor.r / 255, this.backgroundColor.g / 255, this.backgroundColor.b / 255, this.backgroundColor.a);
// Clear the context with the newly set color. This is
// the function call that actually does the drawing.
@@ -525,10 +580,9 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
* Flushes all batched rendering to the screen
*/
flush() {
- const gl = this.__gl;
-
// render target captures all draws and redirects to the render target
- this._renderTarget.use();
+ let currentTarget = this.multiSampleAntialiasing ? this._msaaTarget : this._renderTarget;
+ currentTarget.use();
if (this.useDrawSorting) {
// sort draw calls
@@ -572,10 +626,8 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
// ! hack to grab screen texture before materials run because they might want it
if (currentRenderer instanceof MaterialRenderer && this.material.isUsingScreenTexture) {
- const gl = this.__gl;
- gl.bindTexture(gl.TEXTURE_2D, this.materialScreenTexture);
- gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 0, 0, this.width, this.height, 0);
- this._renderTarget.use();
+ currentTarget.copyToTexture(this.materialScreenTexture);
+ currentTarget.use();
}
// If we are still using the same renderer we can add to the current batch
currentRenderer.draw(...this._drawCalls[i].args);
@@ -601,21 +653,23 @@ export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext {
}
}
- this._renderTarget.disable();
+ currentTarget.disable();
// post process step
- const source = this._renderTarget.toRenderSource();
- source.use();
+ if (this._postprocessors.length > 0) {
+ const source = currentTarget.toRenderSource();
+ source.use();
+ }
- // flip flop render targets
+ // flip flop render targets for post processing
for (let i = 0; i < this._postprocessors.length; i++) {
+ currentTarget = this._postProcessTargets[i % 2];
this._postProcessTargets[i % 2].use();
this._screenRenderer.renderWithPostProcessor(this._postprocessors[i]);
this._postProcessTargets[i % 2].toRenderSource().use();
}
- // passing null switches rendering back to the canvas
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
- this._screenRenderer.renderToScreen();
+ // Final blit to the screen
+ currentTarget.blitToScreen();
}
}
diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl b/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl
index e4502dd0..deaec0e9 100644
--- a/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl
+++ b/src/engine/Graphics/Context/image-renderer/image-renderer.frag.glsl
@@ -10,24 +10,39 @@ in lowp float v_textureIndex;
// Textures in the current draw
uniform sampler2D u_textures[%%count%%];
+uniform bool u_pixelart;
+
// Opacity
in float v_opacity;
in vec4 v_tint;
-out vec4 fragColor;
-
-void main() {
- // In order to support the most efficient sprite batching, we have multiple
- // textures loaded into the gpu (usually 8) this picker logic skips over textures
- // that do not apply to a particular sprite.
+in vec2 v_res;
- vec4 color = vec4(1.0, 0, 0, 1.0);
-
- // GLSL is templated out to pick the right texture and set the vec4 color
- %%texture_picker%%
+out vec4 fragColor;
- color.rgb = color.rgb * v_opacity;
- color.a = color.a * v_opacity;
- fragColor = color * v_tint;
+// Inigo Quilez pixel art filter https://jorenjoestar.github.io/post/pixel_art_filtering/
+vec2 uv_iq(in vec2 uv, in vec2 texture_size) {
+ vec2 pixel = uv * texture_size;
+
+ vec2 seam=floor(pixel+.5);
+ vec2 dudv=fwidth(pixel);
+ pixel=seam+clamp((pixel-seam)/dudv,-.5,.5);
+
+ return pixel/texture_size;
+}
+
+void main(){
+ // In order to support the most efficient sprite batching, we have multiple
+ // textures loaded into the gpu (usually 8) this picker logic skips over textures
+ // that do not apply to a particular sprite.
+
+ vec4 color=vec4(1.,0,0,1.);
+
+ // GLSL is templated out to pick the right texture and set the vec4 color
+ %%texture_picker%%
+
+ color.rgb=color.rgb*v_opacity;
+ color.a=color.a*v_opacity;
+ fragColor=color*v_tint;
}
\ No newline at end of file
diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.ts b/src/engine/Graphics/Context/image-renderer/image-renderer.ts
index e822d01c..e0882ad4 100644
--- a/src/engine/Graphics/Context/image-renderer/image-renderer.ts
+++ b/src/engine/Graphics/Context/image-renderer/image-renderer.ts
@@ -1,3 +1,4 @@
+import { sign } from '../../../Math/util';
import { vec } from '../../../Math/vector';
import { ImageFiltering } from '../../Filtering';
import { GraphicsDiagnostics } from '../../GraphicsDiagnostics';
@@ -11,10 +12,18 @@ import { VertexLayout } from '../vertex-layout';
import frag from './image-renderer.frag.glsl';
import vert from './image-renderer.vert.glsl';
+export interface ImageRendererOptions {
+ pixelArtSampler: boolean;
+ uvPadding: number;
+}
+
export class ImageRenderer implements RendererPlugin {
public readonly type = 'ex.image';
public priority: number = 0;
+ public readonly pixelArtSampler: boolean;
+ public readonly uvPadding: number;
+
private _maxImages: number = 10922; // max(uint16) / 6 verts
private _maxTextures: number = 0;
@@ -30,6 +39,11 @@ export class ImageRenderer implements RendererPlugin {
private _textures: WebGLTexture[] = [];
private _vertexIndex: number = 0;
+ constructor(options: ImageRendererOptions) {
+ this.pixelArtSampler = options.pixelArtSampler;
+ this.uvPadding = options.uvPadding;
+ }
+
initialize(gl: WebGL2RenderingContext, context: ExcaliburGraphicsContextWebGL): void {
this._gl = gl;
this._context = context;
@@ -57,7 +71,7 @@ export class ImageRenderer implements RendererPlugin {
// Setup memory layout
this._buffer = new VertexBuffer({
gl,
- size: 10 * 4 * this._maxImages, // 10 components * 4 verts
+ size: 12 * 4 * this._maxImages, // 12 components * 4 verts
type: 'dynamic'
});
this._layout = new VertexLayout({
@@ -67,6 +81,7 @@ export class ImageRenderer implements RendererPlugin {
attributes: [
['a_position', 2],
['a_opacity', 1],
+ ['a_res', 2],
['a_texcoord', 2],
['a_textureIndex', 1],
['a_tint', 4]
@@ -86,7 +101,8 @@ export class ImageRenderer implements RendererPlugin {
} else {
texturePickerBuilder += ` else if (v_textureIndex <= ${i}.5) {\n`;
}
- texturePickerBuilder += ` color = texture(u_textures[${i}], v_texcoord);\n`;
+ texturePickerBuilder += ` vec2 uv = u_pixelart ? uv_iq(v_texcoord, v_res) : v_texcoord;\n`;
+ texturePickerBuilder += ` color = texture(u_textures[${i}], uv);\n`;
texturePickerBuilder += ` }\n`;
}
newSource = newSource.replace('%%texture_picker%%', texturePickerBuilder);
@@ -189,17 +205,17 @@ export class ImageRenderer implements RendererPlugin {
bottomRight = transform.multiply(bottomRight);
if (snapToPixel) {
- topLeft.x = ~~(topLeft.x + pixelSnapEpsilon);
- topLeft.y = ~~(topLeft.y + pixelSnapEpsilon);
+ topLeft.x = ~~(topLeft.x + sign(topLeft.x) * pixelSnapEpsilon);
+ topLeft.y = ~~(topLeft.y + sign(topLeft.y) * pixelSnapEpsilon);
- topRight.x = ~~(topRight.x + pixelSnapEpsilon);
- topRight.y = ~~(topRight.y + pixelSnapEpsilon);
+ topRight.x = ~~(topRight.x + sign(topRight.x) * pixelSnapEpsilon);
+ topRight.y = ~~(topRight.y + sign(topRight.y) * pixelSnapEpsilon);
- bottomLeft.x = ~~(bottomLeft.x + pixelSnapEpsilon);
- bottomLeft.y = ~~(bottomLeft.y + pixelSnapEpsilon);
+ bottomLeft.x = ~~(bottomLeft.x + sign(bottomLeft.x) * pixelSnapEpsilon);
+ bottomLeft.y = ~~(bottomLeft.y + sign(bottomLeft.y) * pixelSnapEpsilon);
- bottomRight.x = ~~(bottomRight.x + pixelSnapEpsilon);
- bottomRight.y = ~~(bottomRight.y + pixelSnapEpsilon);
+ bottomRight.x = ~~(bottomRight.x + sign(bottomRight.x) * pixelSnapEpsilon);
+ bottomRight.y = ~~(bottomRight.y + sign(bottomRight.y) * pixelSnapEpsilon);
}
const tint = this._context.tint;
@@ -208,10 +224,13 @@ export class ImageRenderer implements RendererPlugin {
const imageWidth = image.width || width;
const imageHeight = image.height || height;
- const uvx0 = (sx) / imageWidth;
- const uvy0 = (sy) / imageHeight;
- const uvx1 = (sx + sw - 0.01) / imageWidth;
- const uvy1 = (sy + sh - 0.01) / imageHeight;
+ const uvx0 = (sx + this.uvPadding) / imageWidth;
+ const uvy0 = (sy + this.uvPadding) / imageHeight;
+ const uvx1 = (sx + sw - this.uvPadding) / imageWidth;
+ const uvy1 = (sy + sh - this.uvPadding) / imageHeight;
+
+ const txWidth = image.width;
+ const txHeight = image.height;
// update data
const vertexBuffer = this._layout.vertexBuffer.bufferData;
@@ -220,6 +239,8 @@ export class ImageRenderer implements RendererPlugin {
vertexBuffer[this._vertexIndex++] = topLeft.x;
vertexBuffer[this._vertexIndex++] = topLeft.y;
vertexBuffer[this._vertexIndex++] = opacity;
+ vertexBuffer[this._vertexIndex++] = txWidth;
+ vertexBuffer[this._vertexIndex++] = txHeight;
vertexBuffer[this._vertexIndex++] = uvx0;
vertexBuffer[this._vertexIndex++] = uvy0;
vertexBuffer[this._vertexIndex++] = textureId;
@@ -232,6 +253,8 @@ export class ImageRenderer implements RendererPlugin {
vertexBuffer[this._vertexIndex++] = bottomLeft.x;
vertexBuffer[this._vertexIndex++] = bottomLeft.y;
vertexBuffer[this._vertexIndex++] = opacity;
+ vertexBuffer[this._vertexIndex++] = txWidth;
+ vertexBuffer[this._vertexIndex++] = txHeight;
vertexBuffer[this._vertexIndex++] = uvx0;
vertexBuffer[this._vertexIndex++] = uvy1;
vertexBuffer[this._vertexIndex++] = textureId;
@@ -244,6 +267,8 @@ export class ImageRenderer implements RendererPlugin {
vertexBuffer[this._vertexIndex++] = topRight.x;
vertexBuffer[this._vertexIndex++] = topRight.y;
vertexBuffer[this._vertexIndex++] = opacity;
+ vertexBuffer[this._vertexIndex++] = txWidth;
+ vertexBuffer[this._vertexIndex++] = txHeight;
vertexBuffer[this._vertexIndex++] = uvx1;
vertexBuffer[this._vertexIndex++] = uvy0;
vertexBuffer[this._vertexIndex++] = textureId;
@@ -256,6 +281,8 @@ export class ImageRenderer implements RendererPlugin {
vertexBuffer[this._vertexIndex++] = bottomRight.x;
vertexBuffer[this._vertexIndex++] = bottomRight.y;
vertexBuffer[this._vertexIndex++] = opacity;
+ vertexBuffer[this._vertexIndex++] = txWidth;
+ vertexBuffer[this._vertexIndex++] = txHeight;
vertexBuffer[this._vertexIndex++] = uvx1;
vertexBuffer[this._vertexIndex++] = uvy1;
vertexBuffer[this._vertexIndex++] = textureId;
@@ -280,11 +307,14 @@ export class ImageRenderer implements RendererPlugin {
this._shader.use();
// Bind the memory layout and upload data
- this._layout.use(true, 4 * 10 * this._imageCount);
+ this._layout.use(true, 4 * 12 * this._imageCount); // 4 verts * 12 components
// Update ortho matrix uniform
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
+ // Turn on pixel art aa sampler
+ this._shader.setUniformBoolean('u_pixelart', this.pixelArtSampler);
+
// Bind textures to
this._bindTextures(gl);
diff --git a/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl b/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl
index 43100264..c9f0fc4d 100644
--- a/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl
+++ b/src/engine/Graphics/Context/image-renderer/image-renderer.vert.glsl
@@ -1,7 +1,7 @@
#version 300 es
in vec2 a_position;
-// Opacity
+// Opacity
in float a_opacity;
out float v_opacity;
@@ -9,6 +9,10 @@ out float v_opacity;
in vec2 a_texcoord;
out vec2 v_texcoord;
+// Texture res
+in vec2 a_res;
+out vec2 v_res;
+
// Texture number
in lowp float a_textureIndex;
out lowp float v_textureIndex;
@@ -18,16 +22,19 @@ out vec4 v_tint;
uniform mat4 u_matrix;
-void main() {
- // Set the vertex position using the ortho transform matrix
- gl_Position = u_matrix * vec4(a_position, 0.0, 1.0);
-
- // Pass through the Opacity to the fragment shader
- v_opacity = a_opacity;
- // Pass through the UV coord to the fragment shader
- v_texcoord = a_texcoord;
- // Pass through the texture number to the fragment shader
- v_textureIndex = a_textureIndex;
- // Pass through the tint
- v_tint = a_tint;
+void main(){
+ // Set the vertex position using the ortho transform matrix
+ gl_Position=u_matrix*vec4(a_position,0.,1.);
+
+ // Pass through the Opacity to the fragment shader
+ v_opacity=a_opacity;
+ // Pass through the UV coord to the fragment shader
+ v_texcoord=a_texcoord;
+
+ v_res = a_res;
+
+ // Pass through the texture number to the fragment shader
+ v_textureIndex=a_textureIndex;
+ // Pass through the tint
+ v_tint=a_tint;
}
\ No newline at end of file
diff --git a/src/engine/Graphics/Context/render-target.ts b/src/engine/Graphics/Context/render-target.ts
index f05704e2..0c2d1463 100644
--- a/src/engine/Graphics/Context/render-target.ts
+++ b/src/engine/Graphics/Context/render-target.ts
@@ -1,13 +1,65 @@
import { RenderSource } from './render-source';
+declare global {
+ interface WebGL2RenderingContext {
+ /**
+ * Experimental only in chrome
+ */
+ drawingBufferFormat?: number;
+ }
+}
+
+
+export interface RenderTargetOptions {
+ gl: WebGL2RenderingContext;
+ width: number;
+ height: number;
+ transparency: boolean;
+ /**
+ * Optionally enable render buffer multisample anti-aliasing
+ *
+ * By default false
+ */
+ antialias?: boolean;
+ /**
+ * Optionally specify number of anti-aliasing samples to use
+ *
+ * By default the max for the platform is used if antialias is on.
+ */
+ samples?: number;
+}
+
export class RenderTarget {
width: number;
height: number;
- private _gl: WebGLRenderingContext;
- constructor(options: {gl: WebGLRenderingContext, width: number, height: number}) {
+ transparency: boolean;
+ antialias: boolean = false;
+ samples: number = 1;
+ private _gl: WebGL2RenderingContext;
+ public readonly bufferFormat: number;
+ constructor(options: RenderTargetOptions) {
+ this._gl = options.gl;
this.width = options.width;
this.height = options.height;
- this._gl = options.gl;
+ this.transparency = options.transparency;
+ this.antialias = options.antialias ?? this.antialias;
+ this.samples = options.samples ?? this._gl.getParameter(this._gl.MAX_SAMPLES);
+
+ const gl = this._gl;
+ // Determine current context format for blitting later needs to match
+ if (gl.drawingBufferFormat) {
+ this.bufferFormat = gl.drawingBufferFormat;
+ } else {
+ // Documented in webgl spec
+ // https://registry.khronos.org/webgl/specs/latest/1.0/
+ if (this.transparency) {
+ this.bufferFormat = gl.RGBA8;
+ } else {
+ this.bufferFormat = gl.RGB8;
+ }
+ }
+
+ this._setupRenderBuffer();
this._setupFramebuffer();
}
@@ -15,8 +67,30 @@ export class RenderTarget {
const gl = this._gl;
this.width = width;
this.height = height;
+
+ // update backing texture size
gl.bindTexture(gl.TEXTURE_2D, this._frameTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+
+ // update render buffer size
+ if (this._renderBuffer) {
+ gl.bindRenderbuffer(gl.RENDERBUFFER, this._renderBuffer);
+ gl.renderbufferStorageMultisample(
+ gl.RENDERBUFFER,
+ Math.min(this.samples, gl.getParameter(gl.MAX_SAMPLES)),
+ this.bufferFormat,
+ this.width,
+ this.height);
+ }
+ }
+
+ private _renderBuffer: WebGLRenderbuffer;
+ public get renderBuffer() {
+ return this._renderBuffer;
+ }
+ private _renderFrameBuffer: WebGLFramebuffer;
+ public get renderFrameBuffer() {
+ return this._renderFrameBuffer;
}
private _frameBuffer: WebGLFramebuffer;
@@ -27,6 +101,25 @@ export class RenderTarget {
public get frameTexture() {
return this._frameTexture;
}
+
+ private _setupRenderBuffer() {
+ if (this.antialias) {
+ const gl = this._gl;
+ // Render buffers can be used as an input to a shader
+ this._renderBuffer = gl.createRenderbuffer();
+ this._renderFrameBuffer = gl.createFramebuffer();
+ gl.bindRenderbuffer(gl.RENDERBUFFER, this._renderBuffer);
+ gl.renderbufferStorageMultisample(
+ gl.RENDERBUFFER,
+ Math.min(this.samples, gl.getParameter(gl.MAX_SAMPLES)),
+ this.bufferFormat,
+ this.width,
+ this.height);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._renderFrameBuffer);
+ gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, this._renderBuffer);
+ }
+ }
+
private _setupFramebuffer() {
// Allocates frame buffer
const gl = this._gl;
@@ -48,21 +141,75 @@ export class RenderTarget {
this._frameBuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this._frameBuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, attachmentPoint, gl.TEXTURE_2D, this._frameTexture, 0);
+
// Reset after initialized
this.disable();
}
public toRenderSource() {
+ if (this.renderBuffer) {
+ this.blitRenderBufferToFrameBuffer();
+ }
const source = new RenderSource(this._gl, this._frameTexture);
return source;
}
+ public blitToScreen() {
+ const gl = this._gl;
+ // set to size of canvas's drawingBuffer
+ if (this._renderBuffer) {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.renderFrameBuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ gl.clearBufferfv(gl.COLOR, 0, [0.0, 0.0, 1.0, 1.0]);
+ gl.blitFramebuffer(
+ 0, 0, this.width, this.height,
+ 0, 0, this.width, this.height,
+ gl.COLOR_BUFFER_BIT, gl.LINEAR);
+ } else {
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.frameBuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
+ gl.clearBufferfv(gl.COLOR, 0, [0.0, 0.0, 1.0, 1.0]);
+ gl.blitFramebuffer(
+ 0, 0, this.width, this.height,
+ 0, 0, this.width, this.height,
+ gl.COLOR_BUFFER_BIT, gl.LINEAR);
+ }
+ }
+
+ public blitRenderBufferToFrameBuffer() {
+ if (this._renderBuffer) {
+ const gl = this._gl;
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.renderFrameBuffer);
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.frameBuffer);
+ gl.clearBufferfv(gl.COLOR, 0, [0.0, 0.0, 1.0, 1.0]);
+ gl.blitFramebuffer(
+ 0, 0, this.width, this.height,
+ 0, 0, this.width, this.height,
+ gl.COLOR_BUFFER_BIT, gl.LINEAR);
+ }
+ }
+
+ public copyToTexture(texture: WebGLTexture) {
+ const gl = this._gl;
+ if (this._renderBuffer) {
+ this.blitRenderBufferToFrameBuffer();
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._frameBuffer);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.copyTexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 0, 0, this.width, this.height, 0);
+ }
+
/**
* When called, all drawing gets redirected to this render target
*/
public use() {
const gl = this._gl;
- gl.bindFramebuffer(gl.FRAMEBUFFER, this._frameBuffer);
+ if (this.antialias) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._renderFrameBuffer);
+ } else {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this._frameBuffer);
+ }
+
// very important to set the viewport to the size of the framebuffer texture
gl.viewport(0, 0, this.width, this.height);
}
diff --git a/src/engine/Graphics/Context/texture-loader.ts b/src/engine/Graphics/Context/texture-loader.ts
index 4ae7a134..aef431c4 100644
--- a/src/engine/Graphics/Context/texture-loader.ts
+++ b/src/engine/Graphics/Context/texture-loader.ts
@@ -71,16 +71,19 @@ export class TextureLoader {
// No texture exists create a new one
tex = gl.createTexture();
+ // TODO implement texture gc with weakmap and timer
TextureLoader.checkImageSizeSupportedAndLog(image);
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);
// NEAREST for pixel art, LINEAR for hi-res
const filterMode = filtering ?? TextureLoader.filtering;
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filterMode === ImageFiltering.Pixel ? gl.NEAREST : gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filterMode === ImageFiltering.Pixel ? gl.NEAREST : gl.LINEAR);
diff --git a/src/engine/Graphics/Context/vertex-layout.ts b/src/engine/Graphics/Context/vertex-layout.ts
index 9a070d7d..2ff4c21b 100644
--- a/src/engine/Graphics/Context/vertex-layout.ts
+++ b/src/engine/Graphics/Context/vertex-layout.ts
@@ -114,7 +114,7 @@ export class VertexLayout {
}
if (this._vertexBuffer.bufferData.length % componentsPerVertex !== 0) {
- this._logger.warn(`The vertex component size (${componentsPerVertex}) does divide evenly into the specified vertex buffer`
+ this._logger.warn(`The vertex component size (${componentsPerVertex}) does NOT divide evenly into the specified vertex buffer`
+` (${this._vertexBuffer.bufferData.length})`);
}
}
diff --git a/src/engine/Screen.ts b/src/engine/Screen.ts
index 89f5bf7c..f471faa3 100644
--- a/src/engine/Screen.ts
+++ b/src/engine/Screen.ts
@@ -164,6 +164,11 @@ export interface ScreenOptions {
* Optionally set antialiasing, defaults to true. If set to true, images will be smoothed
*/
antialiasing?: boolean;
+
+ /**
+ * Optionally set the image rendering CSS hint on the canvas element, default is auto
+ */
+ canvasImageRendering?: 'auto' | 'pixelated';
/**
* Optionally override the pixel ratio to use for the screen, otherwise calculated automatically from the browser
*/
@@ -253,6 +258,7 @@ export class Screen {
public events = new EventEmitter();
private _canvas: HTMLCanvasElement;
private _antialiasing: boolean = true;
+ private _canvasImageRendering: 'auto' | 'pixelated' = 'auto';
private _contentResolution: ScreenDimension;
private _browser: BrowserEvents;
private _camera: Camera;
@@ -276,6 +282,7 @@ export class Screen {
this._canvas = options.canvas;
this.graphicsContext = options.context;
this._antialiasing = options.antialiasing ?? this._antialiasing;
+ this._canvasImageRendering = options.canvasImageRendering ?? this._canvasImageRendering;
this._browser = options.browser;
this._pixelRatioOverride = options.pixelRatio;
@@ -472,7 +479,7 @@ export class Screen {
}
}
- if (this._antialiasing) {
+ if (this._canvasImageRendering === 'auto') {
this._canvas.style.imageRendering = 'auto';
} else {
this._canvas.style.imageRendering = 'pixelated';
diff --git a/src/spec/EngineSpec.ts b/src/spec/EngineSpec.ts
index cc715aa7..07555c73 100644
--- a/src/spec/EngineSpec.ts
+++ b/src/spec/EngineSpec.ts
@@ -759,6 +759,131 @@ describe('The engine', () => { // TODO timeout
await expectAsync(image).toEqualImage('src/spec/images/EngineSpec/screenshot.png', 1.0);
});
+ it('can snap to pixel', async () => {
+ const engine = TestUtils.engine({
+ snapToPixel: true,
+ antialiasing: false,
+ width: 256,
+ height: 256
+ });
+ const clock = engine.clock as ex.TestClock;
+ await TestUtils.runToReady(engine);
+ const playerImage = new ex.ImageSource('src/spec/images/EngineSpec/hero.png');
+ const playerSpriteSheet = ex.SpriteSheet.fromImageSource({
+ image: playerImage,
+ grid: {
+ spriteWidth: 16,
+ spriteHeight: 16,
+ rows: 8,
+ columns: 8
+ }
+ });
+ const backgroundImage = new ex.ImageSource('src/spec/images/EngineSpec/tileset.png');
+ const backgroundSpriteSheet = ex.SpriteSheet.fromImageSource({
+ image: backgroundImage,
+ grid: {
+ spriteHeight: 16,
+ spriteWidth: 16,
+ columns: 27,
+ rows: 15
+ }
+ });
+ await playerImage.load();
+ await backgroundImage.load();
+
+
+ const tilemap = new ex.TileMap({
+ tileWidth: 16,
+ tileHeight: 16,
+ rows: 20,
+ columns: 20,
+ pos: ex.vec(0, 0)
+ });
+ tilemap.tiles.forEach(t => {
+ t.addGraphic(backgroundSpriteSheet.getSprite(6, 0));
+ });
+
+ const player = new ex.Actor({
+ anchor: ex.vec(0, 0),
+ pos: ex.vec(16 * 8 + .9, 16 * 8 + .9)
+ });
+ player.graphics.use(playerSpriteSheet.getSprite(0, 0));
+
+ engine.add(tilemap);
+ engine.add(player);
+ engine.currentScene.camera.pos = player.pos.add(ex.vec(.05, .05));
+
+ clock.step();
+
+ await expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('src/spec/images/EngineSpec/snaptopixel.png');
+ });
+
+ it('can do subpixel AA on pixel art', async () => {
+ const engine = TestUtils.engine({
+ pixelArt: true,
+ antialiasing: {
+ nativeContextAntialiasing: false,
+ multiSampleAntialiasing: true,
+ pixelArtSampler: true,
+ canvasImageRendering: 'auto',
+ filtering: ex.ImageFiltering.Blended
+ },
+ width: 256,
+ height: 256,
+ suppressHiDPIScaling: false,
+ pixelRatio: 2
+ });
+ const clock = engine.clock as ex.TestClock;
+ await TestUtils.runToReady(engine);
+ const playerImage = new ex.ImageSource('src/spec/images/EngineSpec/hero.png');
+ const playerSpriteSheet = ex.SpriteSheet.fromImageSource({
+ image: playerImage,
+ grid: {
+ spriteWidth: 16,
+ spriteHeight: 16,
+ rows: 8,
+ columns: 8
+ }
+ });
+ const backgroundImage = new ex.ImageSource('src/spec/images/EngineSpec/tileset.png');
+ const backgroundSpriteSheet = ex.SpriteSheet.fromImageSource({
+ image: backgroundImage,
+ grid: {
+ spriteHeight: 16,
+ spriteWidth: 16,
+ columns: 27,
+ rows: 15
+ }
+ });
+ await playerImage.load();
+ await backgroundImage.load();
+
+
+ const tilemap = new ex.TileMap({
+ tileWidth: 16,
+ tileHeight: 16,
+ rows: 20,
+ columns: 20,
+ pos: ex.vec(0, 0)
+ });
+ tilemap.tiles.forEach(t => {
+ t.addGraphic(backgroundSpriteSheet.getSprite(6, 0));
+ });
+
+ const player = new ex.Actor({
+ anchor: ex.vec(0, 0),
+ pos: ex.vec(127.45599999999973, 117.3119999999999)
+ });
+ player.graphics.use(playerSpriteSheet.getSprite(0, 0));
+
+ engine.add(tilemap);
+ engine.add(player);
+ engine.currentScene.camera.pos = player.pos;
+
+ clock.step();
+ await expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('src/spec/images/EngineSpec/pixelart.png');
+ });
+
describe('lifecycle overrides', () => {
let engine: ex.Engine;
beforeEach(() => {
diff --git a/src/spec/ExcaliburGraphicsContextSpec.ts b/src/spec/ExcaliburGraphicsContextSpec.ts
index eff5a83a..6ece4d24 100644
--- a/src/spec/ExcaliburGraphicsContextSpec.ts
+++ b/src/spec/ExcaliburGraphicsContextSpec.ts
@@ -288,7 +288,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
const rect = new ex.Rectangle({
@@ -311,7 +312,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
const rect = new ex.Rectangle({
@@ -336,7 +338,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
@@ -356,7 +359,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
@@ -376,7 +380,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
@@ -396,7 +401,8 @@ describe('The ExcaliburGraphicsContext', () => {
const context = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas,
backgroundColor: ex.Color.Black,
- smoothing: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
snapToPixel: true
});
@@ -430,11 +436,6 @@ describe('The ExcaliburGraphicsContext', () => {
sut.clear();
sut.drawImage(rect._bitmap, 20, 20);
- // sut.opacity = .5;
- // sut.drawCircle(ex.vec(50, 50), 50, ex.Color.Green);
- // sut.drawLine(ex.vec(10, 10), ex.vec(90, 90), ex.Color.Red, 5);
- // sut.drawLine(ex.vec(90, 10), ex.vec(10, 90), ex.Color.Red, 5);
- // sut.drawRectangle(ex.vec(10, 10), 80, 80, ex.Color.Blue);
sut.flush();
await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage(
@@ -510,6 +511,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White,
snapToPixel: false
});
@@ -524,6 +527,8 @@ describe('The ExcaliburGraphicsContext', () => {
const rectangleRenderer = sut.get('ex.rectangle');
spyOn(rectangleRenderer, 'flush').and.callThrough();
+ sut.clear();
+
sut.useDrawSorting = false;
sut.drawLine(ex.vec(0, 0), ex.vec(100, 100), ex.Color.Red, 2);
@@ -560,6 +565,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White,
snapToPixel: false
});
@@ -574,6 +581,7 @@ describe('The ExcaliburGraphicsContext', () => {
const rectangleRenderer = sut.get('ex.rectangle');
spyOn(rectangleRenderer, 'flush').and.callThrough();
+ sut.clear();
sut.useDrawSorting = true;
sut.drawLine(ex.vec(0, 0), ex.vec(100, 100), ex.Color.Red, 2);
@@ -608,6 +616,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White
});
@@ -629,6 +639,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White
});
@@ -881,6 +893,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White,
snapToPixel: false
});
@@ -899,6 +913,8 @@ describe('The ExcaliburGraphicsContext', () => {
const sut = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvasElement,
enableTransparency: false,
+ antialiasing: false,
+ multiSampleAntialiasing: false,
backgroundColor: ex.Color.White
});
diff --git a/src/spec/ImageSourceSpec.ts b/src/spec/ImageSourceSpec.ts
index a453fd9b..c0caea5d 100644
--- a/src/spec/ImageSourceSpec.ts
+++ b/src/spec/ImageSourceSpec.ts
@@ -86,7 +86,7 @@ describe('A ImageSource', () => {
const webgl = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas
});
- const imageRenderer = new ImageRenderer();
+ const imageRenderer = new ImageRenderer({pixelArtSampler: false, uvPadding: 0});
imageRenderer.initialize(webgl.__gl, webgl);
spyOn(webgl.textureLoader, 'load').and.callThrough();
@@ -107,7 +107,7 @@ describe('A ImageSource', () => {
const webgl = new ex.ExcaliburGraphicsContextWebGL({
canvasElement: canvas
});
- const imageRenderer = new ImageRenderer();
+ const imageRenderer = new ImageRenderer({pixelArtSampler: false, uvPadding: 0});
imageRenderer.initialize(webgl.__gl, webgl);
spyOn(webgl.textureLoader, 'load').and.callThrough();
@@ -145,7 +145,7 @@ describe('A ImageSource', () => {
expect(spriteFontImage.image.src).toBe('');
});
- it('will resolve the image if alreadly loaded', async () => {
+ it('will resolve the image if already loaded', async () => {
const spriteFontImage = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png');
const image = await spriteFontImage.load();
diff --git a/src/spec/ScreenSpec.ts b/src/spec/ScreenSpec.ts
index 6dfe8bd8..bebacad1 100644
--- a/src/spec/ScreenSpec.ts
+++ b/src/spec/ScreenSpec.ts
@@ -558,6 +558,7 @@ describe('A Screen', () => {
const sut = new ex.Screen({
canvas,
context,
+ canvasImageRendering: 'pixelated',
browser,
viewport: { width: 800, height: 600 },
pixelRatio: 2,
@@ -596,6 +597,7 @@ describe('A Screen', () => {
canvas: canvasStub,
context,
browser,
+ canvasImageRendering: 'pixelated',
viewport: { width: 800, height: 600 },
pixelRatio: 2,
antialiasing: false
diff --git a/src/spec/SpriteSpec.ts b/src/spec/SpriteSpec.ts
index 64757f95..b92099df 100644
--- a/src/spec/SpriteSpec.ts
+++ b/src/spec/SpriteSpec.ts
@@ -16,7 +16,13 @@ describe('A Sprite Graphic', () => {
canvasElement = document.createElement('canvas');
canvasElement.width = 100;
canvasElement.height = 100;
- ctx = new ex.ExcaliburGraphicsContextWebGL({ canvasElement, smoothing: false, snapToPixel: false });
+ ctx = new ex.ExcaliburGraphicsContextWebGL({
+ canvasElement,
+ uvPadding: 0.01,
+ antialiasing: false,
+ snapToPixel: false,
+ pixelArtSampler: false
+ });
});
it('exists', () => {
diff --git a/src/spec/images/EngineSpec/hero.png b/src/spec/images/EngineSpec/hero.png
new file mode 100644
index 0000000000000000000000000000000000000000..55b0fdd4298136b3b8e74716d5b56093babda684
GIT binary patch
literal 7921
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
IIVCg!0Gh>22><{9
literal 0
HcmV?d00001
diff --git a/src/spec/images/EngineSpec/pixelart.png b/src/spec/images/EngineSpec/pixelart.png
new file mode 100644
index 0000000000000000000000000000000000000000..646d8a50ae3afdd48da77e8f51f5d4bcacea05f4
GIT binary patch
literal 27229
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
Jmvv4FO#rKkN}vD$
literal 0
HcmV?d00001
diff --git a/src/spec/images/EngineSpec/tileset.png b/src/spec/images/EngineSpec/tileset.png
new file mode 100644
index 0000000000000000000000000000000000000000..d671166a0975f6913f4d0634894764802a34c37f
GIT binary patch
literal 57545
zcmeAS@N?(olHy`uVBq!ia0y~yU_8LU!0>^Cje&t-y2h3)1_lPk;vjb?hIQv;UNSH!
zFnGE+hE&XXv$wh;`1(w}e|e`Tt?5oZS^mM5P5L;Cdc#5%CdJEF3{+;8J=)eNnUT?M
zJm+QW9X{R3R+lH2SSj)v&$*Lv>!)h_Hj@&zOV?UtI8^mAc~}ZM4UVv}=X{WjpRj&X
z&dEvN-}kTGy7k+&d%JEMP555hpv>+YiL-sIq%>wA1j;i6x1vZ#K?dd#fV<@iZ1i
zn8r(>l~oPY%#T%TUuN3)qgqf&xy8M2
zcW%~_sig~6UEpM}c=!$Ej}Vj9iCdp=y$j29>RFoF5!L-BAm3lOsOa6~xyCmiPgIZz
z;x3(c$1*>hNi;h1=g+KXM?Zbtk#y-}aoU+#ov*L&`lGWha4A!`kCjdRjc2w=3>JO~
z&XUU$Pk0DVcr=G`_mh1YLOm@9jLxb}NmN(_ac5rLzsDRO+`pzW6>)xH{aqKRJZ)|@
z%dsbJJbhChTsg!XQT9&opSfkfNaDnw{AN=*=3RdMV&|8Q=TDwz|9D?J=0({ao~aiZ
zuP>OU+Ty<9>YD>yF02fFf6BIHF>YoKZS|XD5HI#8Ucf0tQkJJ|kBlW}m99R+pZo@8
z<)$-hl>Qmc6kcCgwLi&2P~dOPuZUInv^(_VM790A(wi6<
zs=t5RkTb1q=ETR!dtYDQwc+Q{_Y42UNblcPl4!^9;rNny-e;#r7iayiw$YR_x-`Ru
z`NFj<@i*NQ9;{7py!*z3<UF$_zO=F_5ajfta4|O<&{kpJ&(ovxxZ<<88l@|
zZb*FG6|`r=YmIq#H%aL-n{ByXx-d1avEhu_It^BagP+CAWJS;1-mz#({Qo!8uGSs7
zWt1TN=6RTkJ;Mt*IksCod%A@fuB|b6@Rg09TmL1ajHB$$(vS_?9;YOSoIiT}MfTiV
z|K+TC9#m$VOJ{NH;mi5DLAFivM?-V_;|NNqz*3nySB;UEPZ)B6)
z-`&bR@%6;*ds1R1eU#&8)qXCz;J}jO+`Ic0u`xUoWoT$vkR=uM=wi||d>+KenWlGu{RmAwC!^pgT!pjfm9-ljX_T*Xl
zl(KDEk%k+ibaw`v5!~g>?(VhY@13R<8DYii+`1-|ZOi)kTw6#qaN_*D5ZDaIyHyiIgP*z1QSw^EKDx9(rOeL3e$hUqhm&K`4SRa~3Cea$n^
z4Nu<1EL*m)HFy2%COzNNTdtRGG;L_OaqIQ2g6oHBmZcszcFkmq!tFVy&MQC9UH{r?
zo8{D%nHh;kkKBs8-nxg?Soz-fvrX?N3QCwtEnByF@=?wQUd`?5`}-#MGF>v3@;UQh
z!-cAo!Kw>G7N?*7WbTmIpx=te
z^U7FM93H*eqBMQ`pMOcPc#8_rKUmY98__0)@mIp$viBxQT9PCEVUyiA(b>aL$_
z4$fp#=LlTY!&fJ#=sWe|!7qHPA7njVXSjdE{CNLw4$-r=7wHrfEMd{U|BxfG`e^F@
zk5k`^FfoV}e-YfzpEo-y;cuu(wyLhZA?M`#)(?xDC+$BOwpm$8J7De-_bx-}$D!+)
z9<0$V%;}9tC^Fo>Qttd2tw-Obd5(nMjJtj`x{h1ynQ{1f{sR-IaAcomFgd1jEU(ld
z*(d4N>s>eA$S7|ve}7zuA*09h%!LaY{%KOlLiYU=)Xa}JUc6R!g+W8-+@tHyb!TY4
z6EIVS-W4k)*=Btl2Az`BKUzh5)w@qtjJ^x%(rt9U;!>xup1)Gb#9_&nV
zP7O8O79R6#O~&WckT6l^jyZphT$5L{aY{4!8EUfnZ&JO(gNg(d53fti4F7&Doqy__
zW!Wz)h6jyHjsNs5yw=1%uVRM1+j-6{^X%nPY-@Y$_$%i`e$`@F{pMb0bP>PB>Ad_{
z@prql`Q`uA|1LNmF2}@>R#L!|&L{V5et9_`)4lWOUp$atn>4WGoHOq&-(NDt3vSQDl(Svd(ei?ul~Cg$w81I(D>sKI?}3|J4lp
z&YYJvmN4K@eAr=ZXna=fLCJP=Pc`;~0&7jhGHgAsOuYU0ET1QnjLgG*E%hx%3q*dr
zuAF;DUV4Z9k@-@W8GbC^dwSR2BQBck7x(<;v+Yy;efn^XZQ^nEN>#`>JO?sh8*0o+!ry;_9=F6OOO^`~Kre
z!#MFwkN2VW0tUe*Q$AkNI46I~(#dt&Es1M?=4lGu@D_TovX#A|^}(aslTkXSOAh`k
zNYQ+^u#U6q`QDob(($L>CK~bT*~I>mgAaU%7D2oAdc|$2vjpnjH^p{H7F@cU?Ro$02#~2|I^yT*Xdi1qIhaAEAaP
zHWjf898xZEJcsWiPJVdZ|w_<^*_0d{B6|ArgwFwJnjlQFndA}^MN^uN9KS3
zqj=!iQ^AfeOlPg*ZujhTjr6IS#;7fRE{U;4XZi#u-(2G`y@y;%HPc)KOPs{UNW~|_2OAQb;qR#j8ZuQ
zumAix&RFm|l3~M2A;U|V>~mYax{sDFOKO~=6!XR1U>aASOVHJ09oHroA7P5jO8)UY
zNP2k-%U=#qS;}u#7+WbiX42oM4y{}a>4{g@txa2f{PV}<-kF6vCyS=n3SC;cM$0WKvg8T-d-khY
zCy##XIvv;DzxmgZbpeJ;k{TJ@TU4y4PEb&o;x^@3kHJ^P2@5to^4urHqj2dCYh%nC
zJrR$iGR%!Hm^@V_c5bcqyOO-r{N>XJE8UiD&3|>;b*^QF#Qo}W_8uuw>D3YsxOev@
z9d4^Gba!O)mSa9}Z?F90o4TuSPGgkTi^-V(>HPU+?&gOlOl*5-x6pdlRkgxSLlI89
z`WtLBN|<&sDO~yFnfdpHWzg$;e;%F(wI<}|FBh0FUr~GR#}1ayZq1z9`!`5!uoUEJ
z}xQVmh*|A~n$~9VP+6Q-v)Q6nkxw&KB#!agv;^*J){61^v
zg6|=b&5zDp)4z7M{W(|p@|80d_rI94<@RKm&lBhKZz*+mShIiaBA&nil^s{#9N^sE
z?~qxf;u0MhDl^IML*r880-n?pDyb(ldc0-2uUGh;tCe_fbMC!(=sy+@75-|!D~s6<
z{Q2Xv=WGA%)bsW;Gj?6;{vvs7MrzBw^7k^1O}vVsO1p3D{+?Wun8^5T&u(UhWmlwB
z-kk@vrw&iPY9Qs)^VUuNlSi3G(FT7vDMpU1H9=AKJ0Cxhm}v83ldW~)l?(~%(@M6=
zy_Q@5+nNcPXDm@Ta`CYY&+We+^OY}cP4YM@aN0n@+AQ>=D?*D(J|;{@@yLQ8dRXK@^zret}&%Sl~p*4C!iOQu#DJ6HccR4+e$
zN}>mH{aqZ8|Mt{yZ?hc#0#p~XyIa%2g%If*}_!m6;W4LB^wLOoTa`5$=Cxyb7L`Vs0
zgIY-P^VYbE3F1Ab!1s5?s!OpeIBIY~}Yi(fgtTc9N%KXN1
zWc6jH1FCnXr1BW-|M5sYM+thgL{*=tjVar%^
z{C~t#^RxUdETVq=kFI7iGT8myBKy-)?b*gIeV30SMwZ`T~3dtQr|3WoJg@|=?}@lnZU?xGsW>7{e$N(8AJ@hxS#RoMUJ
z#}2d3pG}&*x~D%U#6K5hSpD(n)2BZ^q?k^+lP9zHOW>Kf_8Aj0jJT(+c~KcD^v<>D
z-0jvs44mRCo_Fzv{ZQfm*iw7@a>SFfY+s{<1I}>%x7o~NX1Ca6hV<8i%Y-6ycg~pl
zF2Up2>k`K!PoDQ)4?Qsd4+F=RdTk-mx_5p??GqGcZWk8V;j%Z%fuSVrg=<2E#go5f
zQFWbX)+otTpB29^oSrT8;c@8tM-gkcOsF+F{?x%+?h&Vzv`PNKGaYvqNUq$~Ai30}
z&1IwdwwkcbDfV$o}24<%qtr(b1R3ChoHTSY`XMg6;Hy6^WAG
zyPH})EB+aFCJ9|sIn*Jj>*=|6e%7PgZ%d2qgcBX#M;z#9*d89^wsRBj{=8X7cg!g~
zpTovrv-6SA=EOAhXjw*o4wk5oOxI8?1?A7
z96r13(-ZgU28$ibYdP4g%qkAOeRz7)#pgcSuLV-tqFy&1(P1r-Hb1=Yx$n=()yDcS
zcg^)@SwMZN1XoSJ`T40ae%t@Np6g`uQra7N`ES^~bxPzD_LY`@KGRrDM#j-+HwwzlBfj5LvR$
zKYijdttl5)hXn5ZHd~gvsK{aW)W?l19~oDxid#o-jo7qbCcM2QD0`Vmczeklxs9PS
z5(N3Cz0r414K2Lz^$A{5HCmtbNI{T2(wVRhB7yS;TdAhiQL~sGjGD?7H5u
z)+=a#$?BVem+t#${ZngCFtt>#p3~0bx$S!C!et9t_1{=^N&o#+>bIQB{8#QAxr?(o
zr8whml!{b;uamhH^Xb;*VCBz{HwdX49tHb=VPKf4Yv~1E}X}RPVm#@PUi=T{3CI_&&E9bv;zUH-+{Z+24
zz!sfdcNOz?y4drZnBDxK>@bUG8MjjJ|C4i%XPta{%Pi2AyYsdArR%a?sZuXYLqx4U
zx&>Pcy}RAe^4sizllSz75GTb5xx
zwqJU#x-Q-*eO18qo1Mz)e>V5%zU2b-e*QB!HK|OETBLd);rjjE+uP-rbIyp7@6cy#
z>B{)-!PPlgz9={$GyFuKFRMy+f&d+w4z)5dTna{el*U&mK$xB6uV-kz9ntfHwDv?<+>Kuh@CQS2g*u#M$++jDza+wp~ujz3=d|94=mq)m9vlAak3n>}I{wbpm_
z%sDzWw&Iiil>Vz8Q!N&AX9)|f`Et)->D1B>E*fh011@I%R5h~hcVB*+dzo}kP`Ct#
z3){y->lVMM_2zoKPT43c#3JtY8MoW2_g245W>~PhctZaECD&F9=x6YuR6ZZ9JwX
zT{ksh$L>l!v2VApG0Du?wASPCx;cV-D&8;e*ru>8-sk-7x|6xV?XvG@>rBWpv0tU%
zoW#fS#JsR({r8V;6FeuBr3)Rp9alKBeoojm#ezbvHD(;bsTo2ltn0VWlC6_{b+qtS
zfpK}J?1PdEe>;yin;u@ed;k2s5^_KN*|t=bhNNELIAUmfsqSQEv8%{?ZnjJ7G;hqi
zc~di>>g%a3txp5A3YNU{XK3(UI_LG=Z5LjA^l(fOc=G3A+tT
zWb5Kp0T116Cau`dG3DOL$pRByIywti-&8ztV+m*fdzSmFp02g}_*(h+%hwk?4OHYL
z&8Agw`>|hodbBu(sYL$3OD3nfjT#-k29itMl|wc?D{0_yo65y_P(W7hz}Mf>VcgcY
zmS|0>Fg$W>N8^X2pH(G2)k?ee#T`BLtmv-D*)-+5$^FFw0n)0!`6lHF7bOP=&OGMN
z5x;wz)N(Po8DW}lriLB`mB?Aw!`$a`7>9CbXR0!EEL-=%KWxv%BRi)xO`7j_dcnU%
z_Y)00FXjdQI@YAZ(kYyFWI~tbq_0d@;-7pIVSKdqH+Q7?i=&&P!;&9&U7Ao@9Nf6K
z+Wy!v(WSv!0rRykSUE^7eDPsf6uZVX#aCDBEIYRA{8FuRlAN9M<0Zq(ERFePYW@$y
zmVC@i-{i1QAlLuEN~Z<2ZHpyU=05D4D%KP6w)!5+n|q(Db=SWYoz8l5!4W56@jYyR
z9@jiFWU_ocEv8%K$g{QfyOj$xx6GE~73|_G
zILoz8!rIs2;|0b`Vo8zn*zYR;JMzPx3(dN(bMC~M^K@l((6$$L`ow3n~e&%q<8!NAY`ME>fV55>9YsH&O8vBjUC!Axb{r}~ur@sg%YdW8t+s*xT=4vw+Oz!pg?p9LqDB0vmN!7*;
z4CV9RwXM4%5WwEy$B_N>d%3kk-;GB%>o54TM?|I`K4+w>_1Wsv2sA=L3}ScT)^JHujyc*_%1fL3q{Es=52rT)6TqP
zn{heVz{;X)U!8T|bu;$8j3-J|7lfM%eyhuP@hsi&ajg!+1`|fs%MW|AC(q!S_R!Nu
zOS0{pz*MRAuivx;E>5&jV^(8&!659b8Sp&i|u%MwGpqxZ(PDpA5rj!y|E5mpmw1U;j~hY7tBQp1`K(pZOTn
z5>Ecxy|MCM=(DL8^d3&X8*#y`Wy`-nmD@GB5pG|+B7#jbPWp3Z|2J%${-=Y7Ng%3x
z%M!;sj!Db6cjhfvbkjvz!?S5&RDFrPL&B{W|CW4{SRt=)a!bxm!=7gM_=DkL_M(5b
z`YZ9t)w1fEx9wbXRojcT(_rDc?Yr+7WQDwWFe7TxABF{Aj~TqvKgoV6_(OZFd*S^_
zPS1P<1PhmWHwHzQ=r&fRYh8TyQGAQ{%MTef2d5m9GSxA*nAOE<8Do|nJ$tgc^5VAC
z4xgr!>aOGBPo5lDk!WafYD2hy1N)B^Tl@^%oc`=kvJO04vOE8t#Paz9{|+t_0(DT=
zWhbw%3VIZG^3f`F*6**`Zg||CpKo^N*t5!T*1rr(&pGb@c5Ca^9}kb0v)YzN|Mc6R
zygOXi`5TYvuJzV`-@OSc;W180-}tgQc+%@56K08vbx2Ps^9)+B*>UMH#{U({iXNKZ
z)df#{eiB}#ZeYUm`#f{^@t<`Kq4&JBFNc1AE?b}QlIP~8jB39t#a9z9@xFPmLi^qP
z`3D2Vxi0Yrer1l?xy0Z}U4shWuXg3<&GW1;tM)XfPARxKtG4%W)AKKeD~q=@Dhusw
z+fg-L+AMTY!XBOT%-*}3zM5@jy|p7JDEqZWRW$pLjlTVPZpU}H7wvIho%En!V``;b
z%tp@5Hk+qzcVBNm*{OH&bpPYk(t?N2%RJg7BF$KASGD`m`e4huiHEMv)C@d&ecBuS
z=kn~U)V<=WmoAl={XCZUx0T7ywUrjm24#B-LyHm;VQT>xp3lBJ>8nBc`Y5*IjcjYD
zyH{TFF;+L8${m(ieV0A)Ju`#5-QL;-YxNBaPd55DEL>ynIdS)kJ%0*sG5HueY=8W;
z;oTGVY3DdHLNCrR+hsp%gR8>E1Jc(Yi{Dm&jti|ws@H#G#l@hw(?44(YQy$*+~0LI
zd0tCNs^!K#571~jgLK3;P92%<#9u!
z?bZ(r3?J5A3FhYiu_o>*qt4BExk9#_{jsbb6XY4rXc#`MDVBGO<+^YrY-fV#mz6qE
z`nPW$-#5Q`pSNDhnb3zj>*bQZW|;@?Oc3od`lJ1ZeG^}4%uI6y<7U@1wt#xUOljUj
z6$vvPH@K}U>JCv;pR4j%eg1;A_Ix@FY7(6HvOvRTf6mU@{qfem`oqGn|EcO#SLEK`
zpLinc@#9Go8(y(@Y}=(bZAV3?cIo2lYj+jJF>?9Ld7?Cl=f-#YSC8c{A5kxT^D$p<
znu_DBgdR3wxmo`iCY3yv*xVGm`B1}m{<{YR^=Ilw|4yyTdwHnl%KYrllLIGwp7{8S
zjN#0S)6~;rSKUy#Yw@}F_x>))&1sK*ZG9R3;8*d9h=%22atvZjS0Dd;Ghc7%(X+ju
zmwRXaxD=!9{bQUg>}S)zv&Tnb#B4
z?X7miRjIG3)?*S-Ti&r$FsbA2+!gydwBLQ%EcY|V|5cSzi)+)vh?a>7o6h?`%GG5y
z`*x`H+m6~gy_k&ScgvsuoET=I(5X}Z_tf`~H>Qho-jeP7&3MT)p#Jmw-Ol@t-IP;5
zR~IDx>Cg50S}|AFG@WywLS2kfmKZMow*Pou-N$N%27dpr&E*o4XI!3MeKY;qS=U*S
z&D-}|^WU#%{;l^@snpIXF6dqG$HJYK^7EdY`<=6rvpD>mOWJFVx?S&uW=GFB8nge}
zQIJU%n_g?!*+lX#K6r$O@A&fH2cHWZ>Sw4A$`br?_V>Y4YBidCVG8Mgc^7P2C1HJf
zM@!HADN&0qO}n_Bd&9zAQ(m!i_*_OKfN
z{msY}c-=vE%eMnFrm6mp;o5z_av?-FG=(ec
zR_HR`tZNW_@lEBp`bK#LUyiu9vbS$pTRfdV|Jpsr8OFVDHb;jQPfb{4b3f%9bIhEj
zY+|~cr@5vu`Ga*=`BLb7_seNEyZy@#FTHLM=EEZE
zUsS!|ipoOp9C-ro0rT?Ow7W5T?kVJG{k2@ipOW0LHJkr~Sn$UgQs?Ww*y@~>J&<$s
z!V*vAj=1+F?VRaVUv5}Fd2)Bs#2xmTK56z2@j~5m?|xd#!yt8Uk+Q%s)}1Xg>UMWA
zG8}L6NqhR{=$xx=t#a~~rPB=KU#Fhjxz3!6^Q%{#S%CPpFxC|pi{7i(p8MI_IU_L1
z$v0W~ocV6U{RR`~Z+`ZWN5=9>ySo0C4}~Xxwssx}QYe_0{-VRv{y@fWR<|4c6Si&0
zcv$(#h3VgS?fdQ**Z4@7uJWAhCa^c|^47R5-+2~q&7V;2?e^&362bnuhURN>|I9Zs
zv9Gm_*ptzD;IHR{Fs&K)-`qHAw=I`@AIF2QZQ2$86!$j#>5<|!Hn3>t6URiCyge%+t%x8+^JJ)%)F@
zuXvO1pGqdd&mrHKrxaHUIZZho;Gu
zy?JK!@&AlhZ8Ddke-+$y?iP
zbv0WhPMu9iGrc`?YHR%0&3xkDwiu_sn{>6rfFmgJ(Z|s2pEKk)zUY|xYHH4hCFYBd
zv?#a#V&F+|>*S2yxAfq;c*gbjVh_yKG5MbUBkw@wqTk%h*kivIWYU}$yY`}g{(2l
zG!^&r`-eTg86;db|6Q8*r%5wQKOFskGQEgxhMK*|{dvd8ymEZnLVkuBkC)k)EKU1Vd_MK5=cFCAw=b&*
zn`m%es@nUrwR6wyusz>j_cBk}>VC)Eb6+tFyYn|OJxcKHP&9&IVU&fK5G&Ky~+
z?R8VUO!jCS?{_AKdzIgX%b>HkzYncjyyy3q^8fE;ymtrPe!c6)l@ewypQ>pS)NJ$n
z)f@|SPu6xg2t~)3se`5|o0HdhdfrfMs$zK1SzF0>yKsZ%lCO;%f(*Ozcic49=J7Dj
z{-ASUvI0X+-N)*hoDWz2eg9)8d^k(B?W$Ky<_88|vyRe+e~%e_tm@`G__bB<;8u@g
z8jAaUHglez;rrv|KJTUDy%Q%L^ItbCc=3I0&>3q%j+p34
zJrnIpT&x&=m?U*sR?XYN5Wj6{QeWJZN>0!MrdcQMeV=CaJJ6QpF8>h$@#b}FwA|j_
z%Gy?CBR^U5HfU7wwzl49mwqnMUv42Ami0XT@XKfq*MeiO*QqSf)s#vSUv_pT$KG3K
z)ps)-kV+|7a^=n$u^YE%zOVl?@qXnJ0Xd;!tCEX!&Og3hSlcTZ!Q6a5hAH1v>g3ji3bVcj-PO+vYx(ovouggN{L-%D
z>6&JrQaoa&w^&?0wZkvq*8x5ectF+_ul%zoyn4@=Fah+-J94_`PYdHwx)6p0v7T>)EoQgArsUrJVi2&{$$^O{geB4^q=n^V
z;m7=)$G;d__R7Bu)iCwTswhgyY4+kh6UKA&iqw-wQ}3SeD%haPx|oUki*dElf7bZt
z$})2M_a-a;J9X{gnmK<%r%iZtXkMAyn?pIiMv4D_sT%rL2u@TG=-}{v|7Eg~ho`jD
z6zdP~9W|bmzxh(S?}CZLR_9NX&Mi5ubSS4??RtV
zJ)tlB+4)lw?ku0dBCa2GsQ=;-4c9vRHUHKca0@^1IQHy-f=$Es{}(e1T3y;MHk|t;
zBO}*v?Ae$5*I#ISi~aSYSoVR5g98)K%J(v@C$m_OeAnVJN-yx^oTs(#h)}kHknMbt
zBdl99S4>g@cg~0zXAohuVkHc%{<2ue1E3Q>vZTh`v>8Tlv44>u*pD5nwA)Q`$T%uck|0dlh#a-uFe@s-YKl9t>
z&VS~%C~b@S6%n5=*f*b?$-M0|SN+?2^VCw(L_hX(o-ma9C};ju#o)2=ue_P(`5mic
z#Xo(%JY$<#wvP(m{r}sK7w*|Mv%L0F#R7ZHTbGUR-`%RcO1Og4>(%=c0Y_fVw|QJ>
zI5&@dik7L*vXCXSf*2Y8GXIs3cyWA3_hf60(5xlPXH1xOXs7gAXXYJ-CJkFUS!Rf2
zIc}X5;J7PD;BC^5jx^iI-M>rB-<`N(a5}yH{-fLj2NQB+cG%5P(2$VhTT*4x)_r8v
zeb@L}#d$v}BN>ZJ%G6>WpS++}dg$78&a*vnX>z
z*f&S+$G++L#vO1e_Q#!I>w@QhtP4K=N!MYxWBH%&+0Nx9c(fU2Xa