diff --git a/src/pixie.nim b/src/pixie.nim index aa4a712..c0ef31b 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -1,9 +1,9 @@ ## Public interface to you library. -import pixie/images, pixie/masks, pixie/paths, pixie/common, +import pixie/images, pixie/masks, pixie/paths, pixie/common, pixie/blends, pixie/fileformats/bmp, pixie/fileformats/png, flatty/binny -export images, masks, paths, PixieError +export images, masks, paths, PixieError, blends type FileFormat* = enum diff --git a/src/pixie/blends.nim b/src/pixie/blends.nim new file mode 100644 index 0000000..d5481e4 --- /dev/null +++ b/src/pixie/blends.nim @@ -0,0 +1,282 @@ +## Blending modes. +import chroma, math, algorithm + +type BlendMode* = enum + Normal + Darken + Multiply + LinearBurn + ColorBurn + Lighten + Screen + LinearDodge + ColorDodge + Overlay + SoftLight + HardLight + Difference + Exclusion + Hue + Saturation + Color + Luminosity + Mask ## Special blend mode that is used for masking + Copy ## Special that does not blend but copies the pixels from target. + + SubtractMask ## Inverse mask + +proc parseBlendMode*(s: string): BlendMode = + case s: + of "NORMAL": Normal + of "DARKEN": Darken + of "MULTIPLY": Multiply + of "LINEAR_BURN": LinearBurn + of "COLOR_BURN": ColorBurn + of "LIGHTEN": Lighten + of "SCREEN": Screen + of "LINEAR_DODGE": LinearDodge + of "COLOR_DODGE": ColorDodge + of "OVERLAY": Overlay + of "SOFT_LIGHT": SoftLight + of "HARD_LIGHT": HardLight + of "DIFFERENCE": Difference + of "EXCLUSION": Exclusion + of "HUE": Hue + of "SATURATION": Saturation + of "COLOR": Color + of "LUMINOSITY": Luminosity + of "MASK": Mask + of "COPY": Copy + else: Normal + +proc `+`*(a, b: Color): Color {.inline.} = + result.r = a.r + b.r + result.g = a.g + b.g + result.b = a.b + b.b + result.a = a.a + b.a + +proc `+`*(c: Color, v: float32): Color {.inline.} = + result.r = c.r + v + result.g = c.g + v + result.b = c.b + v + result.a = c.a + v + +proc `+`*(v: float32, c: Color): Color {.inline.} = + c + v + +proc `*`*(c: Color, v: float32): Color {.inline.} = + result.r = c.r * v + result.g = c.g * v + result.b = c.b * v + result.a = c.a * v + +proc `*`*(v: float32, target: Color): Color {.inline.} = + target * v + +proc `/`*(c: Color, v: float32): Color {.inline.} = + result.r = c.r / v + result.g = c.g / v + result.b = c.b / v + result.a = c.a / v + +proc `-`*(c: Color, v: float32): Color {.inline.} = + result.r = c.r - v + result.g = c.g - v + result.b = c.b - v + result.a = c.a - v + +proc mix*(blendMode: BlendMode, target, blend: Color): Color = + + if blendMode == Mask: + result.r = target.r + result.g = target.g + result.b = target.b + result.a = min(target.a, blend.a) + return + elif blendMode == SubtractMask: + result.r = target.r + result.g = target.g + result.b = target.b + result.a = target.a * (1 - blend.a) + return + elif blendMode == Copy: + result = target + return + + proc multiply(Cb, Cs: float32): float32 = + Cb * Cs + + proc screen(Cb, Cs: float32): float32 = + 1 - (1 - Cb) * (1 - Cs) + + proc hardLight(Cb, Cs: float32): float32 = + if Cs <= 0.5: multiply(Cb, 2 * Cs) + else: screen(Cb, 2 * Cs - 1) + + # Here are 4 implementations of soft light, none of them are quite right. + + # proc softLight(Cb, Cs: float32): float32 = + # ## W3C + # proc D(cb: float32): float32 = + # if Cb <= 0.25: + # ((16 * Cb - 12) * Cb + 4) * Cb + # else: + # sqrt(Cb) + # if Cs <= 0.5: + # return Cb - (1 - 2 * Cs) * Cb * (1 - Cb) + # else: + # return Cb + (2 * Cs - 1) * (D(Cb) - Cb) + + # proc softLight(a, b: float32): float32 = + # ## Photoshop + # if b < 0.5: + # 2 * a * b + a ^ 2 * (1 - 2 * b) + # else: + # 2 * a * (1 - b) + sqrt(a) * (2 * b - 1) + + proc softLight(a, b: float32): float32 = + ## Pegtop + (1 - 2 * b) * a ^ 2 + 2 * b * a + + # proc softLight(a, b: float32): float32 = + # ## Illusions.hu + # pow(a, pow(2, (2 * (0.5 - b)))) + + proc Lum(C: Color): float32 = + 0.3 * C.r + 0.59 * C.g + 0.11 * C.b + + proc ClipColor(C: Color): Color = + let + L = Lum(C) + n = min([C.r, C.g, C.b]) + x = max([C.r, C.g, C.b]) + var + C = C + if n < 0: + C = L + (((C - L) * L) / (L - n)) + if x > 1: + C = L + (((C - L) * (1 - L)) / (x - L)) + return C + + proc SetLum(C: Color, l: float32): Color = + let + d = l - Lum(C) + result.r = C.r + d + result.g = C.g + d + result.b = C.b + d + return ClipColor(result) + + proc Sat(C: Color): float32 = + max([C.r, C.g, C.b]) - min([C.r, C.g, C.b]) + + proc SetSat(C: Color, s: float32): Color = + var arr = [(C.r, 0), (C.g, 1), (C.b, 2)] + # TODO: Don't rely on sort. + arr.sort() + var + Cmin = arr[0][0] + Cmid = arr[1][0] + Cmax = arr[2][0] + if Cmax > Cmin: + Cmid = (((Cmid - Cmin) * s) / (Cmax - Cmin)) + Cmax = s + else: + Cmid = 0 + Cmax = 0 + Cmin = 0 + + if arr[0][1] == 0: + result.r = Cmin + if arr[1][1] == 0: + result.r = Cmid + if arr[2][1] == 0: + result.r = Cmax + + if arr[0][1] == 1: + result.g = Cmin + if arr[1][1] == 1: + result.g = Cmid + if arr[2][1] == 1: + result.g = Cmax + + if arr[0][1] == 2: + result.b = Cmin + if arr[1][1] == 2: + result.b = Cmid + if arr[2][1] == 2: + result.b = Cmax + + proc blendChannel(blendMode: BlendMode, Cb, Cs: float32): float32 = + result = case blendMode + of Normal: Cs + of Darken: min(Cb, Cs) + of Multiply: multiply(Cb, Cs) + of LinearBurn: Cb + Cs - 1 + of ColorBurn: + if Cb == 1: 1.0 + elif Cs == 0: 0.0 + else: 1.0 - min(1, (1 - Cb) / Cs) + of Lighten: max(Cb, Cs) + of Screen: screen(Cb, Cs) + of LinearDodge: Cb + Cs + of ColorDodge: + if Cb == 0: 0.0 + elif Cs == 1: 1.0 + else: min(1, Cb / (1 - Cs)) + of Overlay: hardLight(Cs, Cb) + of HardLight: hardLight(Cb, Cs) + of SoftLight: softLight(Cb, Cs) + of Difference: abs(Cb - Cs) + of Exclusion: Cb + Cs - 2 * Cb * Cs + else: 0.0 + let Cb = target + let Cs = blend + + var mixed: Color + if blendMode == Color: + mixed = SetLum(Cs, Lum(Cb)) + elif blendMode == Luminosity: + mixed = SetLum(Cb, Lum(Cs)) + elif blendMode == Hue: + mixed = SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb)) + elif blendMode == Saturation: + mixed = SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb)) + else: + mixed.r = blendMode.blendChannel(Cb.r, Cs.r) + mixed.g = blendMode.blendChannel(Cb.g, Cs.g) + mixed.b = blendMode.blendChannel(Cb.b, Cs.b) + + let ab = Cb.a + let As = Cs.a + result.r = As * (1 - ab) * Cs.r + As * ab * mixed.r + (1 - As) * ab * Cb.r + result.g = As * (1 - ab) * Cs.g + As * ab * mixed.g + (1 - As) * ab * Cb.g + result.b = As * (1 - ab) * Cs.b + As * ab * mixed.b + (1 - As) * ab * Cb.b + + result.a = (blend.a + target.a * (1.0 - blend.a)) + result.r /= result.a + result.g /= result.a + result.b /= result.a + +proc mix*(blendMode: BlendMode, dest, src: ColorRGBA): ColorRGBA {.inline.} = + return blendMode.mix(dest.color, src.color).rgba + + # TODO: Fix fast paths + # if blendMode == Normal: + # # Fast pass + # # target * (1 - blend.a) + blend * blend.a + # if target.a == 0: return blend + # let blendAComp = 255 - blend.a + # result.r = ((target.r.uint16 * blendAComp + blend.r.uint16 * blend.a) div 255).uint8 + # result.g = ((target.g.uint16 * blendAComp + blend.g.uint16 * blend.a) div 255).uint8 + # result.b = ((target.b.uint16 * blendAComp + blend.b.uint16 * blend.a) div 255).uint8 + # result.a = (blend.a.uint16 + (target.a.uint16 * blendAComp) div 255).uint8 + # inc blendCount + # elif blendMode == Mask: + # result.r = target.r + # result.g = target.g + # result.b = target.b + # result.a = min(target.a, blend.a) + # elif blendMode == COPY: + # result = target + # else: + # return blendMode.mix(target.color, blend.color).rgba diff --git a/src/pixie/images.nim b/src/pixie/images.nim index d791ab5..6efad1c 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -1,4 +1,4 @@ -import chroma, chroma/blends, vmath +import chroma, blends, vmath type Image* = ref object @@ -102,70 +102,6 @@ proc magnifyBy2*(image: Image, scale2x: int): Image = proc magnifyBy2*(image: Image): Image = image.magnifyBy2(2) -proc blitUnsafe*(destImage: Image, srcImage: Image, src, dest: Rect) = - ## Blits rectangle from one image to the other image. - ## * No bounds checking * - ## Make sure that src and dest rect are in bounds. - ## Make sure that channels for images are the same. - ## Failure in the assumptions will case unsafe memory writes. - ## Note: Does not do alpha or color mixing. - for y in 0 ..< int(dest.h): - let - srcIdx = int(src.x) + (int(src.y) + y) * srcImage.width - destIdx = int(dest.x) + (int(dest.y) + y) * destImage.width - copyMem( - destImage.data[destIdx].addr, - srcImage.data[srcIdx].addr, - int(dest.w) * 4 - ) - -proc blit*(destImage: Image, srcImage: Image, src, dest: Rect) = - ## Blits rectangle from one image to the other image. - ## Note: Does not do alpha or color mixing. - doAssert src.w == dest.w and src.h == dest.h - doAssert src.x >= 0 and src.x + src.w <= srcImage.width.float32 - doAssert src.y >= 0 and src.y + src.h <= srcImage.height.float32 - - # See if the image hits the bounds and needs to be adjusted. - var - src = src - dest = dest - if dest.x < 0: - dest.w += dest.x - src.x -= dest.x - src.w += dest.x - dest.x = 0 - if dest.x + dest.w > destImage.width.float32: - let diff = destImage.width.float32 - (dest.x + dest.w) - dest.w += diff - src.w += diff - if dest.y < 0: - dest.h += dest.y - src.y -= dest.y - src.h += dest.y - dest.y = 0 - if dest.y + dest.h > destImage.height.float32: - let diff = destImage.height.float32 - (dest.y + dest.h) - dest.h += diff - src.h += diff - - # See if image is entirely outside the bounds: - if dest.x + dest.w < 0 or dest.x > destImage.width.float32: - return - if dest.y + dest.h < 0 or dest.y > destImage.height.float32: - return - - blitUnsafe(destImage, srcImage, src, dest) - -proc blit*(destImage: Image, srcImage: Image, pos: Vec2) = - ## Blits rectangle from one image to the other image. - ## Note: Does not do alpha or color mixing. - destImage.blit( - srcImage, - rect(0.0, 0.0, srcImage.width.float32, srcImage.height.float32), - rect(pos.x, pos.y, srcImage.width.float32, srcImage.height.float32) - ) - func moduloMod(n, M: int): int {.inline.} = ## Computes "mathematical" modulo vs c modulo. ((n mod M) + M) mod M @@ -222,75 +158,6 @@ proc hasEffect*(blendMode: BlendMode, rgba: ColorRGBA): bool = else: rgba.a > 0 -proc drawBlendIntegerPos*( - destImage, srcImage: Image, pos = vec2(0, 0), blendMode = Normal, -) = - ## Fast draw of dest + fill using offset with color blending. - for y in 0 ..< srcImage.height: - for x in 0 ..< srcImage.width: - let - srcRgba = srcImage.getRgbaUnsafe(x, y) - if blendMode.hasEffect(srcRgba): - let - destRgba = destImage.getRgbaUnsafe(x + pos.x.int, y + pos.y.int) - rgba = blendMode.mix(destRgba, srcRgba) - # TODO: Make unsafe - destImage[x + pos.x.int, y + pos.y.int] = rgba - -proc draw*(destImage: Image, srcImage: Image, mat: Mat4, blendMode = Normal) = - ## Draws one image onto another using matrix with color blending. - var srcImage = srcImage - let - matInv = mat.inverse() - bounds = [ - mat * vec3(-1, -1, 0), - mat * vec3(-1, float32 srcImage.height + 1, 0), - mat * vec3(float32 srcImage.width + 1, -1, 0), - mat * vec3(float32 srcImage.width + 1, float32 srcImage.height + 1, 0) - ] - var - boundsX: array[4, float32] - boundsY: array[4, float32] - for i, v in bounds: - boundsX[i] = v.x - boundsY[i] = v.y - let - xStart = max(int min(boundsX), 0) - yStart = max(int min(boundsY), 0) - xEnd = min(int max(boundsX), destImage.width) - yEnd = min(int max(boundsY), destImage.height) - - var - # compute movement vectors - start = matInv * vec3(0.5, 0.5, 0) - stepX = matInv * vec3(1.5, 0.5, 0) - start - stepY = matInv * vec3(0.5, 1.5, 0) - start - - minFilterBy2 = max(stepX.length, stepY.length) - - while minFilterBy2 > 2.0: - srcImage = srcImage.minifyBy2() - start /= 2 - stepX /= 2 - stepY /= 2 - minFilterBy2 /= 2 - - # fill the bounding rectangle - for y in yStart ..< yEnd: - for x in xStart ..< xEnd: - let srcV = start + stepX * float32(x) + stepY * float32(y) - if srcImage.inside(int srcV.x.floor, int srcV.y.floor): - let - srcRgba = srcImage.getRgbaSmooth(srcV.x - 0.5, srcV.y - 0.5) - if blendMode.hasEffect(srcRgba): - let - destRgba = destImage.getRgbaUnsafe(x, y) - color = blendMode.mix(destRgba, srcRgba) - destImage.setRgbaUnsafe(x, y, color) - -proc draw*(destImage: Image, srcImage: Image, pos = vec2(0, 0), blendMode = Normal) = - destImage.draw(srcImage, translate(vec3(pos.x, pos.y, 0)), blendMode) - func translate*(v: Vec2): Mat3 = result[0, 0] = 1 result[1, 1] = 1 @@ -298,8 +165,13 @@ func translate*(v: Vec2): Mat3 = result[2, 1] = v.y result[2, 2] = 1 -proc copyDraw*(destImage: Image, srcImage: Image, mat: Mat3, blendMode = Normal): Image = +proc draw*(destImage: Image, srcImage: Image, mat: Mat3, blendMode = Normal): Image = ## Draws one image onto another using matrix with color blending. + + # Todo: if matrix is simple integer translation -> fast pass + # Todo: if matrix is a simple flip -> fast path + # Todo: if blend mode is copy -> fast path + result = newImage(destImage.width, destImage.height) for y in 0 ..< destImage.width: for x in 0 ..< destImage.height: @@ -313,31 +185,5 @@ proc copyDraw*(destImage: Image, srcImage: Image, mat: Mat3, blendMode = Normal) rgba = blendMode.mix(destRgba, srcRgba) result.setRgbaUnsafe(x, y, rgba) -proc copyDraw*(destImage: Image, srcImage: Image, pos = vec2(0, 0), blendMode = Normal): Image = - destImage.copyDraw(srcImage, translate(-pos), blendMode) - -proc inplaceDraw*(destImage: Image, srcImage: Image, mat: Mat3, blendMode = Normal) = - ## Draws one image onto another using matrix with color blending. - for y in 0 ..< destImage.width: - for x in 0 ..< destImage.height: - let srcPos = mat * vec2(x.float32, y.float32) - let destRgba = destImage.getRgbaUnsafe(x, y) - var rgba = destRgba - var srcRgba = rgba(0, 0, 0, 0) - if srcImage.inside(srcPos.x.floor.int, srcPos.y.floor.int): - srcRgba = srcImage.getRgbaSmooth(srcPos.x - 0.5, srcPos.y - 0.5) - if blendMode.hasEffect(srcRgba): - rgba = blendMode.mix(destRgba, srcRgba) - destImage.setRgbaUnsafe(x, y, rgba) - -proc inplaceDraw*(destImage: Image, srcImage: Image, pos = vec2(0, 0), blendMode = Normal) = - destImage.inplaceDraw(srcImage, translate(-pos), blendMode) - -## Thoughts -## single draw function that takes a matrix -## if matrix is simple integer translation -> fast pass -## if matrix is a simple flip -> fast path -## if blend mode is copy -> fast path -## -## Helper function that takes x,y -## Helper function that takes x,y and rotation. +proc draw*(destImage: Image, srcImage: Image, pos = vec2(0, 0), blendMode = Normal): Image = + destImage.draw(srcImage, translate(-pos), blendMode) diff --git a/tests/benchmark_images.nim b/tests/benchmark_images.nim index ac077d5..9912797 100644 --- a/tests/benchmark_images.nim +++ b/tests/benchmark_images.nim @@ -1,40 +1,56 @@ import pixie, chroma, vmath, fidget/opengl/perf, pixie/fileformats/bmp -block: - var a = newImage(100, 100) - a.fill(rgba(255, 0, 0, 255)) - var b = newImage(100, 100) - b.fill(rgba(0, 255, 0, 255)) - a.inplaceDraw(b, pos=vec2(25, 25)) - writeFile("tests/images/inplaceDraw.bmp", a.encodeBmp()) +proc inPlaceDraw*(destImage: Image, srcImage: Image, mat: Mat3, blendMode = Normal) = + ## Draws one image onto another using matrix with color blending. + for y in 0 ..< destImage.width: + for x in 0 ..< destImage.height: + let srcPos = mat * vec2(x.float32, y.float32) + let destRgba = destImage.getRgbaUnsafe(x, y) + var rgba = destRgba + var srcRgba = rgba(0, 0, 0, 0) + if srcImage.inside(srcPos.x.floor.int, srcPos.y.floor.int): + srcRgba = srcImage.getRgbaSmooth(srcPos.x - 0.5, srcPos.y - 0.5) + if blendMode.hasEffect(srcRgba): + rgba = blendMode.mix(destRgba, srcRgba) + destImage.setRgbaUnsafe(x, y, rgba) + +proc inPlaceDraw*(destImage: Image, srcImage: Image, pos = vec2(0, 0), blendMode = Normal) = + destImage.inPlaceDraw(srcImage, translate(-pos), blendMode) block: var a = newImage(100, 100) a.fill(rgba(255, 0, 0, 255)) var b = newImage(100, 100) b.fill(rgba(0, 255, 0, 255)) - var c = a.copyDraw(b, pos=vec2(25, 25)) + a.inPlaceDraw(b, pos=vec2(25, 25)) + writeFile("tests/images/inPlaceDraw.bmp", a.encodeBmp()) + +block: + var a = newImage(100, 100) + a.fill(rgba(255, 0, 0, 255)) + var b = newImage(100, 100) + b.fill(rgba(0, 255, 0, 255)) + var c = a.draw(b, pos=vec2(25, 25)) writeFile("tests/images/copyDraw.bmp", c.encodeBmp()) - -timeIt "inplaceDraw": +timeIt "inPlaceDraw": var tmp = 0 - for i in 0 ..< 100000: + for i in 0 ..< 1000: var a = newImage(100, 100) a.fill(rgba(255, 0, 0, 255)) var b = newImage(100, 100) b.fill(rgba(0, 255, 0, 255)) - a.inplaceDraw(b, pos=vec2(25, 25)) + a.inPlaceDraw(b, pos=vec2(25, 25)) tmp += a.width * a.height echo tmp timeIt "copyDraw": var tmp = 0 - for i in 0 ..< 100000: + for i in 0 ..< 1000: var a = newImage(100, 100) a.fill(rgba(255, 0, 0, 255)) var b = newImage(100, 100) b.fill(rgba(0, 255, 0, 255)) - var c = a.copyDraw(b, pos=vec2(25, 25)) + var c = a.draw(b, pos=vec2(25, 25)) tmp += c.width * c.height echo tmp