From 369066e04d3eba797930a885ffb4782378d3f1ad Mon Sep 17 00:00:00 2001 From: Ryan Oldenburg Date: Sat, 6 Feb 2021 21:12:34 -0600 Subject: [PATCH] masks + paths --- src/pixie.nim | 4 +- src/pixie/fileformats/png.nim | 14 +- src/pixie/masks.nim | 67 ++++ src/pixie/paths.nim | 399 +++++++++++++++++------ tests/images/paths/pathRectangleMask.png | Bin 0 -> 180 bytes tests/images/paths/pathRoundRectMask.png | Bin 0 -> 488 bytes tests/test_paths.nim | 26 +- 7 files changed, 408 insertions(+), 102 deletions(-) create mode 100644 src/pixie/masks.nim create mode 100644 tests/images/paths/pathRectangleMask.png create mode 100644 tests/images/paths/pathRoundRectMask.png diff --git a/src/pixie.nim b/src/pixie.nim index cbd19eb..88dcfdb 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -1,8 +1,8 @@ -import pixie/images, pixie/paths, pixie/common, pixie/blends, +import pixie/images, pixie/masks, pixie/paths, pixie/common, pixie/blends, pixie/fileformats/bmp, pixie/fileformats/png, pixie/fileformats/jpg, pixie/fileformats/svg, flatty/binny, os -export images, paths, common, blends +export images, masks, paths, common, blends type FileFormat* = enum diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 16793a3..c98ce41 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -1,4 +1,5 @@ -import chroma, pixie/common, math, zippy, zippy/crc, flatty/binny, pixie/images +import chroma, pixie/common, math, zippy, zippy/crc, flatty/binny, + pixie/images, pixie/masks # See http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html @@ -501,5 +502,16 @@ proc encodePng*(image: Image): string = image.width, image.height, 4, image.data[0].addr, image.data.len * 4 )) +proc encodePng*(mask: Mask): string = + ## Encodes the mask data into the PNG file format. + if mask.data.len == 0: + raise newException( + PixieError, + "Mask has no data (are height and width 0?)" + ) + cast[string](encodePng( + mask.width, mask.height, 1, mask.data[0].addr, mask.data.len + )) + when defined(release): {.pop.} diff --git a/src/pixie/masks.nim b/src/pixie/masks.nim new file mode 100644 index 0000000..ff6e704 --- /dev/null +++ b/src/pixie/masks.nim @@ -0,0 +1,67 @@ +import common, vmath + +type + Mask* = ref object + ## Mask object that holds mask opacity data. + width*, height*: int + data*: seq[uint8] + +when defined(release): + {.push checks: off.} + +proc newMask*(width, height: int): Mask = + ## Creates a new mask with the parameter dimensions. + if width <= 0 or height <= 0: + raise newException(PixieError, "Mask width and height must be > 0") + + result = Mask() + result.width = width + result.height = height + result.data = newSeq[uint8](width * height) + +proc wh*(mask: Mask): Vec2 {.inline.} = + ## Return with and height as a size vector. + vec2(mask.width.float32, mask.height.float32) + +proc copy*(mask: Mask): Mask = + ## Copies the image data into a new image. + result = newMask(mask.width, mask.height) + result.data = mask.data + +proc `$`*(mask: Mask): string = + ## Prints the mask size. + "" + +proc inside*(mask: Mask, x, y: int): bool {.inline.} = + ## Returns true if (x, y) is inside the mask. + x >= 0 and x < mask.width and y >= 0 and y < mask.height + +proc dataIndex*(mask: Mask, x, y: int): int {.inline.} = + mask.width * y + x + +proc getValueUnsafe*(mask: Mask, x, y: int): uint8 {.inline.} = + ## Gets a color from (x, y) coordinates. + ## * No bounds checking * + ## Make sure that x, y are in bounds. + ## Failure in the assumptions will case unsafe memory reads. + result = mask.data[mask.width * y + x] + +proc `[]`*(mask: Mask, x, y: int): uint8 {.inline.} = + ## Gets a pixel at (x, y) or returns transparent black if outside of bounds. + if mask.inside(x, y): + return mask.getValueUnsafe(x, y) + +proc setValueUnsafe*(mask: Mask, x, y: int, value: uint8) {.inline.} = + ## Sets a value from (x, y) coordinates. + ## * No bounds checking * + ## Make sure that x, y are in bounds. + ## Failure in the assumptions will case unsafe memory writes. + mask.data[mask.dataIndex(x, y)] = value + +proc `[]=`*(mask: Mask, x, y: int, value: uint8) {.inline.} = + ## Sets a pixel at (x, y) or does nothing if outside of bounds. + if mask.inside(x, y): + mask.setValueUnsafe(x, y, value) + +when defined(release): + {.pop.} diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index 475e96f..5e69d8b 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -1,4 +1,4 @@ -import common, strutils, vmath, images, chroma, bumpy, blends +import common, strutils, vmath, images, masks, chroma, bumpy, blends when defined(amd64) and not defined(pixieNoSimd): import nimsimd/sse2 @@ -767,13 +767,18 @@ proc computeBounds(seqs: varargs[seq[(Segment, int16)]]): Rect = result.w = xMax - xMin result.h = yMax - yMin -proc fillShapes( - image: Image, - shapes: seq[seq[Vec2]], - color: ColorRGBA, - windingRule: WindingRule -) = - var topHalf, bottomHalf, fullHeight: seq[(Segment, int16)] +proc shouldFill(windingRule: WindingRule, count: int): bool {.inline.} = + case windingRule: + of wrNonZero: + count != 0 + of wrEvenOdd: + count mod 2 != 0 + +proc partitionSegments(shapes: seq[seq[Vec2]], middle: int): tuple[ + topHalf: seq[(Segment, int16)], + bottomHalf: seq[(Segment, int16)], + fullHeight: seq[(Segment, int16)] +] = for shape in shapes: for segment in shape.segments: if segment.at.y == segment.to.y: # Skip horizontal @@ -784,12 +789,107 @@ proc fillShapes( if segment.at.y > segment.to.y: swap(segment.at, segment.to) winding = -1 - if ceil(segment.to.y).int < image.height div 2: - topHalf.add((segment, winding)) - elif segment.at.y.int >= image.height div 2: - bottomHalf.add((segment, winding)) + if ceil(segment.to.y).int < middle: + result.topHalf.add((segment, winding)) + elif segment.at.y.int >= middle: + result.bottomHalf.add((segment, winding)) else: - fullHeight.add((segment, winding)) + result.fullHeight.add((segment, winding)) + +proc computeCoverages( + coverages: var seq[uint8], + hits: var seq[(float32, int16)], + size: Vec2, + y: int, + topHalf, bottomHalf, fullHeight: seq[(Segment, int16)], + windingRule: WindingRule +) = + const + quality = 5 # Must divide 255 cleanly + sampleCoverage = 255.uint8 div quality + ep = 0.0001 * PI + offset = 1 / quality.float32 + initialOffset = offset / 2 + + proc intersects( + scanline: Line, + segment: Segment, + winding: int16, + hits: var seq[(float32, int16)], + numHits: var int + ) {.inline.} = + if segment.at.y <= scanline.a.y and segment.to.y >= scanline.a.y: + var at: Vec2 + if scanline.intersects(segment, at):# and segment.to != at: + if numHits == hits.len: + hits.setLen(hits.len * 2) + hits[numHits] = (at.x.clamp(0, scanline.b.x), winding) + inc numHits + + var numHits: int + + # Do scanlines for this row + for m in 0 ..< quality: + let + yLine = y.float32 + initialOffset + offset * m.float32 + ep + scanline = Line(a: vec2(0, yLine), b: vec2(size.x, yLine)) + numHits = 0 + if y < size.y.int div 2: + for (segment, winding) in topHalf: + scanline.intersects(segment, winding, hits, numHits) + else: + for (segment, winding) in bottomHalf: + scanline.intersects(segment, winding, hits, numHits) + for (segment, winding) in fullHeight: + scanline.intersects(segment, winding, hits, numHits) + + quickSort(hits, 0, numHits - 1) + + var + x: float32 + count: int + for i in 0 ..< numHits: + let (at, winding) = hits[i] + + var + fillStart = x.int + leftCover = if at.int - x.int > 0: trunc(x) + 1 - x else: at - x + if leftCover != 0: + inc fillStart + if shouldFill(windingRule, count): + coverages[x.int] += (leftCover * sampleCoverage.float32).uint8 + + if at.int - x.int > 0: + let rightCover = at - trunc(at) + if rightCover > 0 and shouldFill(windingRule, count): + coverages[at.int] += (rightCover * sampleCoverage.float32).uint8 + + let fillLen = at.int - fillStart + if fillLen > 0 and shouldFill(windingRule, count): + var i = fillStart + when defined(amd64) and not defined(pixieNoSimd): + let vSampleCoverage = mm_set1_epi8(cast[int8](sampleCoverage)) + for j in countup(i, fillStart + fillLen - 16, 16): + let current = mm_loadu_si128(coverages[j].addr) + mm_storeu_si128( + coverages[j].addr, + mm_add_epi8(current, vSampleCoverage) + ) + i += 16 + for j in i ..< fillStart + fillLen: + coverages[j] += sampleCoverage + + count += winding + x = at + +proc fillShapes( + image: Image, + shapes: seq[seq[Vec2]], + color: ColorRGBA, + windingRule: WindingRule +) = + let (topHalf, bottomHalf, fullHeight) = + partitionSegments(shapes, image.height div 2) # Figure out the total bounds of all the shapes, # rasterize only within the total bounds @@ -799,105 +899,29 @@ proc fillShapes( startY = max(0, bounds.y.int) stopY = min(image.height, (bounds.y + bounds.h).int) - const - quality = 5 # Must divide 255 cleanly - sampleCoverage = 255.uint8 div quality - ep = 0.0001 * PI - offset = 1 / quality.float32 - initialOffset = offset / 2 - var coverages = newSeq[uint8](image.width) hits = newSeq[(float32, int16)](4) - numHits: int for y in startY ..< stopY: # Reset buffer for this row zeroMem(coverages[0].addr, coverages.len) - proc intersects( - scanline: Line, - segment: Segment, - winding: int16, - hits: var seq[(float32, int16)], - numHits: var int - ) {.inline.} = - if segment.at.y <= scanline.a.y and segment.to.y >= scanline.a.y: - var at: Vec2 - if scanline.intersects(segment, at):# and segment.to != at: - if numHits == hits.len: - hits.setLen(hits.len * 2) - hits[numHits] = (at.x.clamp(0, scanline.b.x), winding) - inc numHits - - # Do scanlines for this row - for m in 0 ..< quality: - let - yLine = y.float32 + initialOffset + offset * m.float32 + ep - scanline = Line(a: vec2(0, yLine), b: vec2(image.width.float32, yLine)) - numHits = 0 - if y < image.height div 2: - for (segment, winding) in topHalf: - scanline.intersects(segment, winding, hits, numHits) - else: - for (segment, winding) in bottomHalf: - scanline.intersects(segment, winding, hits, numHits) - for (segment, winding) in fullHeight: - scanline.intersects(segment, winding, hits, numHits) - - quickSort(hits, 0, numHits - 1) - - proc shouldFill(windingRule: WindingRule, count: int): bool {.inline.} = - case windingRule: - of wrNonZero: - count != 0 - of wrEvenOdd: - count mod 2 != 0 - - var - x: float32 - count: int - for i in 0 ..< numHits: - let (at, winding) = hits[i] - - var - fillStart = x.int - leftCover = if at.int - x.int > 0: trunc(x) + 1 - x else: at - x - if leftCover != 0: - inc fillStart - if shouldFill(windingRule, count): - coverages[x.int] += (leftCover * sampleCoverage.float32).uint8 - - if at.int - x.int > 0: - let rightCover = at - trunc(at) - if rightCover > 0 and shouldFill(windingRule, count): - coverages[at.int] += (rightCover * sampleCoverage.float32).uint8 - - let fillLen = at.int - fillStart - if fillLen > 0 and shouldFill(windingRule, count): - var i = fillStart - when defined(amd64) and not defined(pixieNoSimd): - let vSampleCoverage = mm_set1_epi8(cast[int8](sampleCoverage)) - for j in countup(i, fillStart + fillLen - 16, 16): - let current = mm_loadu_si128(coverages[j].addr) - mm_storeu_si128( - coverages[j].addr, - mm_add_epi8(current, vSampleCoverage) - ) - i += 16 - for j in i ..< fillStart + fillLen: - coverages[j] += sampleCoverage - - count += winding - x = at + computeCoverages( + coverages, + hits, + image.wh, + y, + topHalf, bottomHalf, fullHeight, + windingRule + ) # Apply the coverage and blend var x = startX when defined(amd64) and not defined(pixieNoSimd): # When supported, SIMD blend as much as possible - let - coverageMask1 = cast[M128i]([0xffffffff, 0, 0, 0]) # First 32 bits + coverageMask1 = cast[M128i]([uint32.high, 0, 0, 0]) # First 32 bits coverageMask2 = mm_set1_epi32(cast[int32](0x000000ff)) # Only `r` oddMask = mm_set1_epi16(cast[int16](0xff00)) div255 = mm_set1_epi16(cast[int16](0x8081)) @@ -982,6 +1006,107 @@ proc fillShapes( image.setRgbaUnsafe(x, y, blendNormalPremultiplied(backdrop, source)) inc x +proc fillShapes( + mask: Mask, + shapes: seq[seq[Vec2]], + windingRule: WindingRule +) = + let (topHalf, bottomHalf, fullHeight) = + partitionSegments(shapes, mask.height div 2) + + # Figure out the total bounds of all the shapes, + # rasterize only within the total bounds + let + bounds = computeBounds(topHalf, bottomHalf, fullHeight) + startX = max(0, bounds.x.int) + startY = max(0, bounds.y.int) + stopY = min(mask.height, (bounds.y + bounds.h).int) + + var + coverages = newSeq[uint8](mask.width) + hits = newSeq[(float32, int16)](4) + + for y in startY ..< stopY: + # Reset buffer for this row + zeroMem(coverages[0].addr, coverages.len) + + computeCoverages( + coverages, + hits, + mask.wh, + y, + topHalf, bottomHalf, fullHeight, + windingRule + ) + + # Apply the coverage and blend + var x = startX + when defined(amd64) and not defined(pixieNoSimd): + # When supported, SIMD blend as much as possible + let + oddMask = mm_set1_epi16(cast[int16](0xff00)) + v255high = mm_set1_epi16(cast[int16](255.uint16 shl 8)) + div255 = mm_set1_epi16(cast[int16](0x8081)) + + for _ in countup(x, coverages.len - 16, 16): + var coverage = mm_loadu_si128(coverages[x].addr) + + let eqZero = mm_cmpeq_epi16(coverage, mm_setzero_si128()) + if mm_movemask_epi8(eqZero) != 0xffff: + # If the coverages are not all zero + var + coverageEven = mm_slli_epi16(mm_andnot_si128(oddMask, coverage), 8) + coverageOdd = mm_and_si128(coverage, oddMask) + + let + evenK = mm_sub_epi16(v255high, coverageEven) + oddK = mm_sub_epi16(v255high, coverageOdd) + + var + backdrop = mm_loadu_si128(mask.data[mask.dataIndex(x, y)].addr) + backdropEven = mm_slli_epi16(mm_andnot_si128(oddMask, backdrop), 8) + backdropOdd = mm_and_si128(backdrop, oddMask) + + # backdrop * k + backdropEven = mm_mulhi_epu16(backdropEven, evenK) + backdropOdd = mm_mulhi_epu16(backdropOdd, oddK) + + # div 255 + backdropEven = mm_srli_epi16(mm_mulhi_epu16(backdropEven, div255), 7) + backdropOdd = mm_srli_epi16(mm_mulhi_epu16(backdropOdd, div255), 7) + + # Shift from high to low bits + coverageEven = mm_srli_epi16(coverageEven, 8) + coverageOdd = mm_srli_epi16(coverageOdd, 8) + + var + blendedEven = mm_add_epi16(coverageEven, backdropEven) + blendedOdd = mm_add_epi16(coverageOdd, backdropOdd) + + blendedOdd = mm_slli_epi16(blendedOdd, 8) + + mm_storeu_si128( + mask.data[mask.dataIndex(x, y)].addr, + mm_or_si128(blendedEven, blendedOdd) + ) + + x += 16 + + while x < mask.width: + if x + 8 <= coverages.len: + let peeked = cast[ptr uint64](coverages[x].addr)[] + if peeked == 0: + x += 8 + continue + let coverage = coverages[x] + if coverage != 0: + let + backdrop = mask.getValueUnsafe(x, y) + blended = + coverage + ((backdrop.uint32 * (255 - coverage)) div 255).uint8 + mask.setValueUnsafe(x, y, blended) + inc x + proc strokeShapes( shapes: seq[seq[Vec2]], strokeWidth: float32, @@ -1065,6 +1190,37 @@ proc fillPath*( segment = mat * segment image.fillShapes(shapes, color, windingRule) +proc fillPath*( + mask: Mask, + path: SomePath, + windingRule = wrNonZero +) {.inline.} = + mask.fillShapes(parseSomePath(path), windingRule) + +proc fillPath*( + mask: Mask, + path: SomePath, + pos: Vec2, + windingRule = wrNonZero +) = + var shapes = parseSomePath(path) + for shape in shapes.mitems: + for segment in shape.mitems: + segment += pos + mask.fillShapes(shapes, color, windingRule) + +proc fillPath*( + mask: Mask, + path: SomePath, + mat: Mat3, + windingRule = wrNonZero +) = + var shapes = parseSomePath(path) + for shape in shapes.mitems: + for segment in shape.mitems: + segment = mat * segment + mask.fillShapes(shapes, windingRule) + proc strokePath*( image: Image, path: SomePath, @@ -1115,5 +1271,52 @@ proc strokePath*( segment = mat * segment image.fillShapes(strokeShapes, color, windingRule) +proc strokePath*( + mask: Mask, + path: SomePath, + strokeWidth = 1.0, + windingRule = wrNonZero +) = + let strokeShapes = strokeShapes( + parseSomePath(path), + strokeWidth, + windingRule + ) + mask.fillShapes(strokeShapes, windingRule) + +proc strokePath*( + mask: Mask, + path: SomePath, + strokeWidth = 1.0, + pos: Vec2, + windingRule = wrNonZero +) = + var strokeShapes = strokeShapes( + parseSomePath(path), + strokeWidth, + windingRule + ) + for shape in strokeShapes.mitems: + for segment in shape.mitems: + segment += pos + mask.fillShapes(strokeShapes, windingRule) + +proc strokePath*( + mask: Mask, + path: SomePath, + strokeWidth = 1.0, + mat: Mat3, + windingRule = wrNonZero +) = + var strokeShapes = strokeShapes( + parseSomePath(path), + strokeWidth, + windingRule + ) + for shape in strokeShapes.mitems: + for segment in shape.mitems: + segment = mat * segment + mask.fillShapes(strokeShapes, windingRule) + when defined(release): {.pop.} diff --git a/tests/images/paths/pathRectangleMask.png b/tests/images/paths/pathRectangleMask.png new file mode 100644 index 0000000000000000000000000000000000000000..0df19b100d565a365391a977ca3fd2c97f8d503c GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^DIm-NBp5CB3`AHRh4lE; z+1W*BcycwpIWg<|=54P(HM)81^w@b~!s{E)ZmoB2oA|bB&SJqJJyPMP8QJsS?92Gk hU+=HHB<6|zPQMKf3(pAeUb7VB98Xt2mvv4FO#s5vJ{aSW+od^^+D`*VUw+kCCq z8qUA0k|H^GORsL{yQcTA;g#Cay!fFq{c*dKesdez3%wt=QZE++}{?kGv<}obZ?F`JsZwgHhGDO zs%mf6UR|d3zf7aUpUi1nQI$UJdGxN^Zsn;hE22`{9`C<0Y5EiEDe5k!{qLk@vu8;C zzG|tPcJhCL;eHp*s|8+1{|Jb9`yIaP{8QwUEI25CSnsl9sM@#pRI`tM3n&UbUHx3v IIVCg!0J>Dw_y7O^ literal 0 HcmV?d00001 diff --git a/tests/test_paths.nim b/tests/test_paths.nim index e60138c..ef28738 100644 --- a/tests/test_paths.nim +++ b/tests/test_paths.nim @@ -1,4 +1,4 @@ -import pixie, chroma +import pixie, chroma, pixie/fileformats/png block: let pathStr = """ @@ -178,3 +178,27 @@ block: image.fillPath(path, rgba(255, 0, 0, 255)) image.toStraightAlpha() image.writeFile("tests/images/paths/pathRoundRect.png") + +block: + let + mask = newMask(100, 100) + pathStr = "M 10 10 H 90 V 90 H 10 L 10 10" + mask.fillPath(pathStr) + writeFile("tests/images/paths/pathRectangleMask.png", mask.encodePng()) + +block: + let + mask = newMask(100, 100) + r = 10.0 + x = 10.0 + y = 10.0 + h = 80.0 + w = 80.0 + var path: Path + path.moveTo(x + r, y) + path.arcTo(x + w, y, x + w, y + h, r) + path.arcTo(x + w, y + h, x, y + h, r) + path.arcTo(x, y + h, x, y, r) + path.arcTo(x, y, x + w, y, r) + mask.fillPath(path) + writeFile("tests/images/paths/pathRoundRectMask.png", mask.encodePng())