Skip to content

Commit

Permalink
Support color blending in the canvas
Browse files Browse the repository at this point in the history
  • Loading branch information
TwitchBronBron committed Aug 10, 2022
1 parent 1ca1489 commit 56d8097
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 101 deletions.
11 changes: 6 additions & 5 deletions src/Canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from 'chai';
import { Canvas } from './Canvas';
import { Color } from './Color';
import { expectColor } from './Color.spec';

describe('Canvas', () => {

Expand All @@ -17,20 +18,20 @@ describe('Canvas', () => {

it('supports simple pixels', () => {
canvas.setPixel(black, 0, 0);
expect(canvas.getPixel(0, 0)).to.equal(black);
expectColor(canvas.getPixel(0, 0), black);

canvas.setPixel(white, 10, 10);
expect(canvas.getPixel(10, 10)).to.equal(white);
expectColor(canvas.getPixel(10, 10), white);
});

it('supports negative pixels', () => {
canvas.setPixel(red, -5, -10);
expect(canvas.getPixel(-5, -10)).to.equal(red);
expectColor(canvas.getPixel(-5, -10), red);

canvas.setPixel(black, 0, 0);
expect(canvas.getPixel(0, 0)).to.equal(black);
expectColor(canvas.getPixel(0, 0), black);

canvas.setPixel(white, 10, 10);
expect(canvas.getPixel(10, 10)).to.equal(white);
expectColor(canvas.getPixel(10, 10), white);
});
});
122 changes: 33 additions & 89 deletions src/Canvas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Color } from './Color';
import { Color } from './Color';
import * as Jimp from 'jimp';
import { colorAverage } from './util';

Expand All @@ -14,7 +14,7 @@ export class Canvas {
* A map of rows, containing columns, all indexed by their x or y coordinate.
* Index by Y first, then X
*/
private grid = new Map<number, Map<number, Color>>();
private grid = new Map<number, Map<number, Color[]>>();

/**
* Get the lowest-defined x value
Expand Down Expand Up @@ -77,47 +77,59 @@ export class Canvas {
public getPixel(x: number, y: number) {
x = Math.round(x);
y = Math.round(y);
return this.grid.get(y)?.get(x);
const colorStack = this.grid.get(y)?.get(x);
if (colorStack) {
return Color.blend(this.backgroundColor, ...colorStack);
}
}

/**
* Get the row at the specified y coordinate. If it doesn't exist, create it
*/
private getRow(y: number) {
private getCell(x: number, y: number) {
y = Math.round(y);
let result = this.grid.get(y);
if (!result) {
result = new Map();
this.grid.set(y, result);
let row = this.grid.get(y);
if (!row) {
row = new Map();
this.grid.set(y, row);
}
let cell = row.get(x);
if (!cell) {
cell = [];
row.set(x, cell);
}
return result;

return cell;
}

/**
* Set the color for the given coordinate
* Set the color (or color stack) for the given coordinate.
*/
public setPixel(color: Color, x: number, y: number) {
public setPixel(color: Color | Color[], x: number, y: number) {
const colorStack = Array.isArray(color) ? color : [color];
x = Math.round(x);
this.getRow(y).set(x, color);
this.getCell(x, y).push(...colorStack);
}

/**
* Delete a pixel at the specified location
* Delete a pixel at the specified location. This deletes the whole stack of pixels at that position.
*/
public deletePixel(x: number, y: number) {
x = Math.round(x);
y = Math.round(y);
const row = this.getRow(y);
row.delete(x);
if (row.size === 0) {
this.grid.delete(y);
const row = this.grid.get(y);
if (row) {
row.delete(x);
if (row.size === 0) {
this.grid.delete(y);
}
}
}

/**
* Set the color for each of the given coordinates
*/
public setPixels(color: Color, ...points: Array<[x: number, y: number]>) {
public setPixels(color: Color | Color[], ...points: Array<[x: number, y: number]>) {
for (let [x, y] of points) {
this.setPixel(color, x, y);
}
Expand All @@ -141,75 +153,6 @@ export class Canvas {
}
}

/**
* Merge the incoming color with the color at the current coordinates.
*/
public mergePixels(color: Color, ...pixels: Array<[x: number, y: number]>) {
for (const [x, y] of pixels) {
const current = this.getPixel(x, y) ?? this.backgroundColor.clone();
const merged = current.merge(color);
this.setPixels(merged, [x, y]);
}
}

/**
* Set a pixel color, and anti-alias the pixels around it
*/
public setAntiAliased(color: Color, x: number, y: number) {
//get a new color with the alpha value totally blanked out
color = color.clone().setAlpha(0);

for (let roundedX = Math.floor(x); roundedX < Math.ceil(x); roundedX++) {
for (let roundedY = Math.floor(y); roundedY < Math.ceil(y); roundedY++) {
let percentX = 1 - Math.abs(x - roundedX);
let percentY = 1 - Math.abs(y - roundedY);
let percent = percentX * percentY;

const currentAlpha = (this.getPixel(x, y) ?? color.clone()).alpha;
const additionalAlpha = 255 * percent;
//make the pixel more solid by this percentage
const antiAliasedColor = color.clone().setAlpha(
currentAlpha + additionalAlpha
);

this.setPixels(
antiAliasedColor,
[roundedX, roundedY]
);
}
}
}

public boxBlur(defaultColor: Color, opacityPercent: number) {
const result = new Canvas(this.backgroundColor);
const { minX, minY, maxX, maxY, width, height } = this;
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
//skip these out of bounds pixels
if (x < 1 || y < 1 || x + 1 === width || y + 1 === height) {
continue;
}
// Set P to the average of 9 pixels:
const color = colorAverage(
defaultColor,
[
this.getPixel(x - 1, y + 1), // Top left
this.getPixel(x + 0, y + 1), // Top center
this.getPixel(x + 1, y + 1), // Top right
this.getPixel(x - 1, y + 0), // Mid left
this.getPixel(x + 0, y + 0), // Current pixel
this.getPixel(x + 1, y + 0), // Mid right
this.getPixel(x - 1, y - 1), // Low left
this.getPixel(x + 0, y - 1), // Low center
this.getPixel(x + 1, y - 1) // Low right
]
);
result.setPixels(color.setAlpha(color.alpha * opacityPercent), [x, y]);
}
}
this.grid = result.grid;
}

/**
* Translate all the pixels by this amount
*/
Expand All @@ -231,8 +174,9 @@ export class Canvas {
}
});
for (const [y, row] of this.grid) {
for (const [x, color] of row) {
image.setPixelColor(color?.toInteger(), x, y);
for (const [x, colorStack] of row) {
const finalColor = Color.blend(this.backgroundColor, ...colorStack);
image.setPixelColor(finalColor?.toInteger(), x, y);
}
}
return image.write(outPath);
Expand Down
10 changes: 5 additions & 5 deletions src/Color.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ import type { ColorLike, RgbaArray } from './Color';
import { Color } from './Color';

describe('Color', () => {
function expectColor(input: ColorLike, expected: RgbaArray) {
const color = new Color(input);
expect(color.toRgbaArray()).to.eql(expected);
}

it('parses hex', () => {
expectColor('#01020304', [1, 2, 3, 4]);
});
Expand Down Expand Up @@ -124,3 +119,8 @@ describe('Color', () => {
});
});
});

export function expectColor(input: ColorLike | undefined, expected: ColorLike) {
const color = new Color(input as any);
expect(color.toRgbaArray()).to.eql(new Color(expected).toRgbaArray());
}
53 changes: 51 additions & 2 deletions src/Color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export class Color {
this.set(value);
}

/**
* Stores the rgb and a values as integers within the range (0 - 255)
*/
private value!: RgbaArray;

/**
Expand Down Expand Up @@ -126,7 +129,53 @@ export class Color {
}

public toString() {
return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha}`;
return `rgba(${this.red}, ${this.green}, ${this.blue}, ${this.alpha / 255}`;
}

/**
* Blend one or more colors together into a single color.
* The first color is the bottom color
*/
public static blend(...colors: Color[]): Color {
const args = colors.map(x => {
return [
x.red,
x.green,
x.blue,
x.alpha / 255
];
});
let base = [0, 0, 0, 0];
let mix = [] as unknown as RgbaArray;
let added;
while ((added = args.shift())) {
if (typeof added[3] === 'undefined') {
added[3] = 1;
}
// check if both alpha channels exist.
if (base[3] && added[3]) {
mix = [0, 0, 0, 0];
// alpha
mix[3] = 1 - ((1 - added[3]) * (1 - base[3]));
// red
mix[0] = Math.round((added[0] * added[3] / mix[3]) + (base[0] * base[3] * (1 - added[3]) / mix[3]));
// green
mix[1] = Math.round((added[1] * added[3] / mix[3]) + (base[1] * base[3] * (1 - added[3]) / mix[3]));
// blue
mix[2] = Math.round((added[2] * added[3] / mix[3]) + (base[2] * base[3] * (1 - added[3]) / mix[3]));

} else if (added) {
mix = added as any;
} else {
mix = base as any;
}
base = mix;
}
if (mix?.length === 4) {
mix[3] = snap(mix[3] * 255);
return new Color(mix as any);
}
return undefined as unknown as Color;
}
}

Expand Down Expand Up @@ -161,7 +210,7 @@ function isColor(value: any): value is Color {
}

/**
* Take any color value and turn it into an rgba array
* Take any color value and turn it into an rgba array (each representing 0-255, even alpha)
*/
function toRgbaArray(value: ColorLike) {
let result: number[] | undefined;
Expand Down

0 comments on commit 56d8097

Please sign in to comment.