From 52b8d96b9611040c88b512f6a932e5f753a361db Mon Sep 17 00:00:00 2001 From: Michael Carleton Date: Fri, 3 Jan 2025 16:33:19 +0000 Subject: [PATCH] fix subregion rendering for all gradient types --- .../peasygradients/PeasyGradients.java | 99 ++++++++++++------- .../peasygradients/PeasyGradientsTests.java | 88 ++++++++++++++++- 2 files changed, 150 insertions(+), 37 deletions(-) diff --git a/src/main/micycle/peasygradients/PeasyGradients.java b/src/main/micycle/peasygradients/PeasyGradients.java index e3d58d2..2e4b2ce 100644 --- a/src/main/micycle/peasygradients/PeasyGradients.java +++ b/src/main/micycle/peasygradients/PeasyGradients.java @@ -47,13 +47,15 @@ * with multiple color stops and custom center offsets) * * - *

By default, renders directly to the Processing sketch. Use + *

+ * By default, renders directly to the Processing sketch. Use * {@code .setRenderTarget()} to specify a custom {@code PGraphics} output - * buffer.

+ * buffer. + *

* *

- * Linear, radial & conic sampling algorithms adapted from Jeremy - * Behreandt. Additional sampling patterns are original implementations. + * Linear, radial & conic sampling algorithms adapted from Jeremy Behreandt. + * Additional sampling patterns are original implementations. *

* * @author Michael Carleton @@ -107,9 +109,13 @@ public final class PeasyGradients { /** * Number of horizontal strips the plane is paritioned into for threaded - * rendering + * rendering. */ - private int renderStrips = (int) Math.max(cpuThreads * 0.75, 1); + private int renderStrips = (int) Math.min(Math.max(cpuThreads * 0.75, 1), 10); // 1..10 strips + + void setRenderStrips(int renderStrips) { + this.renderStrips = renderStrips; + } /** * Constructs a new PeasyGradients renderer from a running Processing sketch; @@ -194,22 +200,20 @@ public void setRenderTarget(PImage g) { * @param height height of region to render gradients into */ public void setRenderTarget(PImage g, int offSetX, int offSetY, int width, int height) { - if (offSetX < 0 || offSetY < 0 || (width + offSetX) > g.width || (offSetY + height) > g.height) { - System.err.println("Invalid parameters."); - return; - } + renderOffsetX = Math.max(0, offSetX); // Offset X cannot be less than 0 + renderOffsetX = Math.min(renderOffsetX, g.width - 1); // Offset X cannot be beyond (image width - 1) + renderOffsetY = Math.max(0, offSetY); // Offset Y cannot be less than 0 + renderOffsetY = Math.min(renderOffsetY, g.height - 1); // Offset Y cannot be beyond (image height - 1) - final int actualWidth = width - offSetX; - final int actualHeight = height - offSetY; + // 2. Constrain Width and Height based on Constrained Offsets: + renderWidth = Math.max(0, width); // Width cannot be negative + renderWidth = Math.min(renderWidth, g.width - renderOffsetX); // Width cannot extend beyond the image's right edge + renderHeight = Math.max(0, height); // Height cannot be negative + renderHeight = Math.min(renderHeight, g.height - renderOffsetY); // Height cannot extend beyond the image's bottom edge scaleX = g.width / (double) width; // used for correct rendering increment for some gradients scaleY = g.height / (double) height; // used for correct rendering increment for some gradients - renderWidth = width; - renderHeight = height; - renderOffsetX = offSetX; - renderOffsetY = offSetY; - if (!g.isLoaded()) { // load pixel array if not already done if (g instanceof PGraphics) { ((PGraphics) g).beginDraw(); @@ -220,7 +224,7 @@ public void setRenderTarget(PImage g, int offSetX, int offSetY, int width, int h gradientPG = g; - gradientCacheSize = (3 * Math.max(actualWidth, actualHeight)); + gradientCacheSize = (3 * Math.max(renderWidth, renderHeight)); gradientCache = new int[gradientCacheSize]; } @@ -403,7 +407,6 @@ public void linearGradient(Gradient gradient, PVector controlPoint1, PVector con double odY = controlPoint2.y - controlPoint1.y; // Rise and run of line. final double odSqInverse = 1 / (odX * odX + odY * odY); // Distance-squared of line. double opXod = -controlPoint1.x * odX + -controlPoint1.y * odY; - makeThreadPool(gradient, renderStrips, LinearThread.class, odX, odY, odSqInverse, opXod); gradientPG.updatePixels(); @@ -849,8 +852,8 @@ public void hourglassGradient(Gradient gradient, PVector centerPoint, double ang /** * Creates a pool of threads to split the rendering work for the given gradient - * type (each thread works on a portion of the pixels array). This method starts - * the threads and returns when all threads have completed. + * type (each thread works on a horizontal strip portion of the pixels array). + * This method starts the threads and returns when all threads have completed. * * @param gradient TODO * @param partitionsY @@ -879,14 +882,15 @@ private void makeThreadPool(Gradient gradient, final int partitionsY, final Clas // division) int rows = renderHeight / partitionsY; for (int strip = 0; strip < partitionsY - 1; strip++) { - fullArgs[1] = rows * strip; - fullArgs[2] = rows; + fullArgs[1] = rows * strip; // row vertical offset (y coord to start rendering at) + fullArgs[2] = rows; // row count (height of horizontal strip) RenderThread thread = constructor.newInstance(fullArgs); taskList.add(thread); } fullArgs[1] = rows * (partitionsY - 1); fullArgs[2] = renderHeight - rows * (partitionsY - 1); + RenderThread thread = constructor.newInstance(fullArgs); taskList.add(thread); @@ -941,7 +945,7 @@ private double interleavedGradientNoise(final int x, final int y) { } /** - * Threads operate on a portion of the pixels grid. + * Threads operate on a portion (horizontal strip) of the pixels grid. * * RenderThread child classes will implement call(); here the parallel gradient * rendering work is done. @@ -963,7 +967,7 @@ private abstract class RenderThread implements Callable { RenderThread(int rowOffset, int rows) { this.rowOffset = rowOffset; this.rows = rows; - pixel = rowOffset * renderWidth; + pixel = (rowOffset + renderOffsetY) * gradientPG.width; // Start at the correct global row, only considering renderOffsetY here. } } @@ -987,19 +991,20 @@ public Boolean call() { * Usually we'd call Functions.linearProject() to calculate step at each pixel, * but the function is inlined here to optimise speed. */ - opXod += rowOffset * odY * scaleY; // offset for thread + opXod += rowOffset * odY * scaleY; for (int y = rowOffset; y < rowOffset + rows; y++) { opXod += odY * scaleY; - double xOff = 0; // set partition x offset to correct amount + // Add renderOffsetX only at the start of each row. + pixel += renderOffsetX; for (int x = 0; x < renderWidth; x++) { - double step = (opXod + xOff) * odSqInverse; // get position of point on 1D gradient and normalise - xOff += odX * scaleX; + double step = (opXod + x * odX * scaleX) * odSqInverse; int stepInt = clampAndDither(step, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + // After rendering a row, jump to the beginning of the next row. + pixel += gradientPG.width - (renderWidth + renderOffsetX); } - return true; } @@ -1022,6 +1027,7 @@ public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { double rise = renderMidpointY - y; rise *= rise; + pixel += renderOffsetX; for (int x = 0; x < renderWidth; x++) { double run = renderMidpointX - x; @@ -1033,6 +1039,7 @@ public Boolean call() { int stepInt = clampAndDither(dist, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; } @@ -1059,7 +1066,8 @@ public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { rise = renderMidpointY - y; - for (int x = 0; x < gradientPG.width; x++) { // FULL WIDTH + pixel += renderOffsetX; + for (int x = 0; x < renderWidth; x++) { // FULL WIDTH run = renderMidpointX - x; t = Functions.fastAtan2b(rise, run) + Math.PI - angle; // + PI to align bump with angle t *= INV_TWO_PI; // normalise @@ -1069,6 +1077,7 @@ public Boolean call() { gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1107,15 +1116,16 @@ public Boolean call() throws Exception { double t; double spiralOffset = 0; for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; double rise = renderMidpointY - y; final double riseSquared = rise * rise; - for (int x = 0; x < gradientPG.width; x++) { // FULL WIDTH + for (int x = 0; x < renderWidth; x++) { // FULL WIDTH double run = renderMidpointX - x; t = Functions.fastAtan2b(rise, run) - angle; // -PI...PI spiralOffset = curviness == 0.5f ? Math.sqrt((riseSquared + run * run) * curveDenominator) : FastPow.fastPow((riseSquared + run * run) * curveDenominator, curviness); - spiralOffset*=curveCount; + spiralOffset *= curveCount; t += spiralOffset; t *= INV_TWO_PI; // normalise @@ -1125,6 +1135,7 @@ public Boolean call() throws Exception { gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1152,6 +1163,7 @@ public Boolean call() { double xDist; // x distance between midpoint and a given pixel for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; yDist = (renderMidpointY - y); xDist = renderMidpointX; for (int x = 0; x < renderWidth; x++) { @@ -1167,6 +1179,7 @@ public Boolean call() { final int stepInt = clampAndDither(dist, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1193,6 +1206,7 @@ private final class CrossThread extends RenderThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - renderMidpointY); for (int x = 0; x < renderWidth; x++) { final double newXpos = (x - renderMidpointX) * cos - yTranslate * sin + renderMidpointX; // rotate x about midpoint @@ -1203,6 +1217,7 @@ public Boolean call() { final int stepInt = clampAndDither(dist, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1229,6 +1244,7 @@ private final class DiamondThread extends RenderThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - renderMidpointY); for (int x = 0; x < renderWidth; x++) { final double newXpos = (x - renderMidpointX) * cos - yTranslate * sin + renderMidpointX; // rotate x about midpoint @@ -1239,6 +1255,7 @@ public Boolean call() { final int stepInt = clampAndDither(dist, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1262,8 +1279,9 @@ private class NoiseThread extends RenderThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - centerPoint.y); - for (int x = 0; x < gradientPG.width; x++) { + for (int x = 0; x < renderWidth; x++) { double newXpos = (x - centerPoint.x) * cos - yTranslate * sin + centerPoint.x; // rotate x about midpoint double newYpos = yTranslate * cos + (x - centerPoint.x) * sin + centerPoint.y; // rotate y about midpoint @@ -1272,6 +1290,7 @@ public Boolean call() { final int stepInt = clampAndDither(step, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1294,8 +1313,9 @@ private final class UniformNoiseThread extends NoiseThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - centerPoint.y); - for (int x = 0; x < gradientPG.width; x++) { + for (int x = 0; x < renderWidth; x++) { double newXpos = (x - centerPoint.x) * cos - yTranslate * sin + centerPoint.x; // rotate x about midpoint double newYpos = yTranslate * cos + (x - centerPoint.x) * sin + centerPoint.y; // rotate y about midpoint @@ -1304,6 +1324,7 @@ public Boolean call() { final int stepInt = clampAndDither(step, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1330,8 +1351,9 @@ private final class FractalNoiseThread extends RenderThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - centerPoint.y); - for (int x = 0; x < gradientPG.width; x++) { + for (int x = 0; x < renderWidth; x++) { double newXpos = (x - centerPoint.x) * cos - yTranslate * sin + centerPoint.x; // rotate x about midpoint double newYpos = yTranslate * cos + (x - centerPoint.x) * sin + centerPoint.y; // rotate y about midpoint @@ -1341,6 +1363,7 @@ public Boolean call() { final int stepInt = clampAndDither(step, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1368,6 +1391,7 @@ private final class SpotlightThread extends RenderThread { public Boolean call() { for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; final double yTranslate = (y - originPoint.y); for (int x = 0; x < renderWidth; x++) { double newXpos = (x - originPoint.x) * cos - yTranslate * sin + originPoint.x; // rotate x about midpoint @@ -1392,6 +1416,7 @@ public Boolean call() { int stepInt = clampAndDither(step, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; @@ -1428,6 +1453,7 @@ public Boolean call() { double xDist; for (int y = rowOffset; y < rowOffset + rows; y++) { + pixel += renderOffsetX; yDist = (renderMidpointY - y) * (renderMidpointY - y); final double yTranslate = (y - renderMidpointY); for (int x = 0; x < renderWidth; x++) { @@ -1452,6 +1478,7 @@ public Boolean call() { final int stepInt = clampAndDither(dist, x, y); gradientPG.pixels[pixel++] = gradientCache[stepInt]; } + pixel += gradientPG.width - (renderWidth + renderOffsetX); } return true; diff --git a/src/test/micycle/peasygradients/PeasyGradientsTests.java b/src/test/micycle/peasygradients/PeasyGradientsTests.java index 24b4245..c3365c2 100644 --- a/src/test/micycle/peasygradients/PeasyGradientsTests.java +++ b/src/test/micycle/peasygradients/PeasyGradientsTests.java @@ -9,12 +9,14 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import micycle.peasygradients.PeasyGradients; import micycle.peasygradients.gradient.Gradient; import micycle.peasygradients.gradient.Palette; import micycle.peasygradients.utilities.ColorUtils; +import micycle.peasygradients.utilities.FastNoiseLite.FractalType; +import micycle.peasygradients.utilities.FastNoiseLite.NoiseType; import processing.core.PConstants; import processing.core.PImage; +import processing.core.PVector; /** * Tests to ensure 2D gradients are rendered as expected. @@ -25,6 +27,90 @@ class PeasyGradientsTests { private static final int GREY = ColorUtils.RGB255ToRGB255(128, 128, 128); private static final int BLACK = ColorUtils.RGB255ToRGB255(0, 0, 0); + @Test + void testSubregionRendering() { + PeasyGradients pg = new PeasyGradients(); + + PImage i = new PImage(1000, 1000); + i.loadPixels(); + Arrays.fill(i.pixels, WHITE); + i.updatePixels(); + for (int value : i.pixels) { + assertEquals(WHITE, value); + } + + int offsetX = 250; + int offsetY = 250; + int regionWidth = 500; + int regionHeight = 500; + pg.setRenderTarget(i, offsetX, offsetY, regionWidth, regionHeight); // offSetX, offSetY, width, height + pg.setRenderStrips(4); + + PVector v = new PVector(500, 500); + Gradient g = new Gradient(BLACK, BLACK); + + pg.linearGradient(g, 0); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.radialGradient(g, v, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.conicGradient(g, new PVector(500, 500), 0); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.spiralGradient(g, new PVector(500, 500), 0, 0, 5); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.polygonGradient(g, v, 0, 0, 5); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.crossGradient(g, v, 0, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.diamondGradient(g, v, 0, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.noiseGradient(g, v, 0, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.uniformNoiseGradient(g, v, 0, 0, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.fractalNoiseGradient(g, v, 0, 1, NoiseType.Perlin, FractalType.None, 0, 0, 0); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.spotlightGradient(g, v, v.copy().add(100, 100)); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + + pg.hourglassGradient(g, v, 0, 1); + assertRegion(i, offsetX, offsetY, regionWidth, regionHeight); + Arrays.fill(i.pixels, WHITE); + } + + private void assertRegion(PImage image, int offsetX, int offsetY, int regionWidth, int regionHeight) { + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + int index = y * image.width + x; + if (x >= offsetX && x < offsetX + regionWidth && y >= offsetY && y < offsetY + regionHeight) { + assertEquals(BLACK, image.pixels[index], "Pixel at (" + x + ", " + y + ") should be black"); + } else { + assertEquals(WHITE, image.pixels[index], "Pixel at (" + x + ", " + y + ") should be white"); + } + } + } + } + @Test void testLinearGradient() { PImage g = new PImage(100, 1);