Skip to content

Commit 0b3b906

Browse files
committed
fix jaggies on horizontal/vertical linear gradient (#17)
1 parent 8465d85 commit 0b3b906

File tree

3 files changed

+126
-69
lines changed

3 files changed

+126
-69
lines changed

src/main/micycle/peasygradients/PeasyGradients.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ public void linearGradient(Gradient gradient, PVector centerPoint, double angle)
343343

344344
double modAngle = angle % TWO_PI;
345345

346-
if (modAngle > PConstants.HALF_PI && modAngle <= PConstants.HALF_PI * 3) {
346+
if (modAngle > HALF_PI && modAngle <= HALF_PI * 3) {
347347
linearGradient(gradient, o[0], o[1]);
348348
} else {
349349
linearGradient(gradient, o[1], o[0]);
@@ -543,11 +543,11 @@ public void polygonGradient(Gradient gradient, PVector centerPoint, double angle
543543
* of the polygon to the midpoint of each side (which are closer than vertices)
544544
*/
545545
final double MIN_LENGTH_RATIO = FastMath.tan(HALF_PI - (Math.PI / sides)); // used for hexagon gradient (== tan(60)) tan(SIDES)
546-
final double SEGMENT_ANGLE = (2 * Math.PI) / sides; // max angle of polygon segment in radians
546+
final double SEGMENT_ANGLE = TWO_PI / sides; // max angle of polygon segment in radians
547547

548548
angle %= SEGMENT_ANGLE; // mod angle to minimise difference between theta and SEGMENT_ANGLE in loop
549549

550-
final double denominator = MIN_LENGTH_RATIO / ((Math.max(renderHeight, renderWidth)) * (0.01 * zoom * FastMath.pow(sides, 2.4)));
550+
final double denominator = MIN_LENGTH_RATIO / ((Math.max(renderHeight, renderWidth)) * (0.0125 * zoom * FastMath.pow(sides, 2.4)));
551551

552552
int LUT_SIZE = (int) Functions.max(2000, renderWidth * 20f, renderHeight * 20f); // suitable value?
553553
final int HALF_LUT_SIZE = (int) (LUT_SIZE / TWO_PI);
@@ -1061,7 +1061,7 @@ public Boolean call() {
10611061
rise = renderMidpointY - y;
10621062
for (int x = 0; x < gradientPG.width; x++) { // FULL WIDTH
10631063
run = renderMidpointX - x;
1064-
t = Functions.fastAtan2b(rise, run) + PConstants.PI - angle; // + PI to align bump with angle
1064+
t = Functions.fastAtan2b(rise, run) + Math.PI - angle; // + PI to align bump with angle
10651065
t *= INV_TWO_PI; // normalise
10661066
t -= Math.floor(t); // modulo
10671067

src/main/micycle/peasygradients/utilities/Functions.java

Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ public final class Functions {
2424
private static final FastLog fastLog = new DFastLog(10); // Used in veryFastPow(), 10 => 2KB table
2525

2626
private static final float PI = (float) Math.PI;
27-
private static final float TWO_PI = (float) (2 * Math.PI);
28-
private static final float HALF_PI = (float) (0.5f * Math.PI);
27+
private static final float TWO_PI_F = (float) (2 * Math.PI);
28+
private static final double TWO_PI = 2 * Math.PI;
29+
private static final float HALF_PI_F = (float) (0.5f * Math.PI);
2930
private static final float QRTR_PI_F = (float) (0.25f * Math.PI);
3031
private static final float THREE_QRTR_PI_F = (float) (0.75f * Math.PI);
3132
private static final double QRTR_PI = (0.25 * Math.PI);
@@ -73,17 +74,17 @@ public static float linearProject(float originX, float originY, float destX, flo
7374
public static float angleBetween(PVector tail, PVector head) {
7475
float a = fastAtan2b(tail.y - head.y, tail.x - head.x);
7576
if (a < 0) {
76-
a += TWO_PI;
77+
a += TWO_PI_F;
7778
}
7879
return a;
7980
}
8081

81-
public static double angleBetween(PVector head, float tailX, float tailY) {
82+
public static double angleBetween(PVector head, double tailX, double tailY) {
8283
double a = fastAtan2b(tailY - head.y, tailX - head.x);
8384
if (a < 0) {
8485
a += TWO_PI;
8586
}
86-
return a;
87+
return snapAngleToQuarterPi(a);
8788
}
8889

8990
/**
@@ -146,7 +147,7 @@ public static float random(float min, float max) {
146147
public static float randomFloat() {
147148
return random.nextFloat();
148149
}
149-
150+
150151
/**
151152
* Returns a pseudorandom, uniformly distributed double value between 0.0
152153
* (inclusive) and 1.0 (exclusive).
@@ -349,16 +350,16 @@ public static double fastAtan2a(double y, double x) {
349350
final double z = x / y;
350351
if (y > 0.0) {
351352
// atan2(y,x) = PI/2 - atan(x/y) if |y/x| > 1, y > 0
352-
return -fastAtan(z) + HALF_PI;
353+
return -fastAtan(z) + HALF_PI_F;
353354
} else {
354355
// atan2(y,x) = -PI/2 - atan(x/y) if |y/x| > 1, y < 0
355-
return -fastAtan(z) - HALF_PI;
356+
return -fastAtan(z) - HALF_PI_F;
356357
}
357358
}
358359
} else if (y > 0.0f) { // x = 0, y > 0
359-
return HALF_PI;
360+
return HALF_PI_F;
360361
} else if (y < 0.0f) { // x = 0, y < 0
361-
return -HALF_PI;
362+
return -HALF_PI_F;
362363
}
363364
return 0.0f; // x,y = 0. Could return NaN instead.
364365
}
@@ -390,24 +391,24 @@ public static float fastAtan2b(final float y, final float x) {
390391
return (angle);
391392
}
392393
}
393-
394+
394395
public static double fastAtan2b(final double y, final double x) {
395-
double r, angle;
396-
final double abs_y = Math.abs(y) + 1e-10; // kludge to prevent 0/0 condition
397-
398-
if (x < 0.0) {
399-
r = (x + abs_y) / (abs_y - x);
400-
angle = THREE_QRTR_PI;
401-
} else {
402-
r = (x - abs_y) / (x + abs_y);
403-
angle = QRTR_PI;
404-
}
405-
angle += (0.1963 * r * r - 0.9817) * r;
406-
if (y < 0.0) {
407-
return (-angle);
408-
} else {
409-
return (angle);
410-
}
396+
double r, angle;
397+
final double abs_y = Math.abs(y) + 1e-10; // kludge to prevent 0/0 condition
398+
399+
if (x < 0.0) {
400+
r = (x + abs_y) / (abs_y - x);
401+
angle = THREE_QRTR_PI;
402+
} else {
403+
r = (x - abs_y) / (x + abs_y);
404+
angle = QRTR_PI;
405+
}
406+
angle += (0.1963 * r * r - 0.9817) * r;
407+
if (y < 0.0) {
408+
return (-angle);
409+
} else {
410+
return (angle);
411+
}
411412
}
412413

413414
/**
@@ -451,8 +452,8 @@ public static float fastAtan2c(float y, float x) {
451452
* @return the interpolated angle in the range [0, 2PI]
452453
*/
453454
public static float lerpAngle(float fromRadians, float toRadians, float progress) {
454-
float delta = ((toRadians - fromRadians + TWO_PI + PI) % TWO_PI) - PI;
455-
return (fromRadians + delta * progress + TWO_PI) % TWO_PI;
455+
float delta = ((toRadians - fromRadians + TWO_PI_F + PI) % TWO_PI_F) - PI;
456+
return (fromRadians + delta * progress + TWO_PI_F) % TWO_PI_F;
456457
}
457458

458459
/**
@@ -472,14 +473,15 @@ public static float lerpAngle(float fromRadians, float toRadians, float progress
472473
* @return PVector[2] containing the two points of intersection
473474
* @see #lineRectIntersection(float, float, PVector, float)
474475
*/
475-
public static PVector[] lineRectIntersection(PVector[] rect, PVector point, float angle) {
476+
public static PVector[] lineRectIntersection(PVector[] rect, PVector point, double angle) {
476477

477478
PVector[] output = new PVector[2];
478479
output[0] = new PVector();
479480
output[1] = new PVector();
480481

481-
final float tanA = (float) Math.tan(TWO_PI - angle); // 'TWO_PI - ___' for clockwise orientation
482-
482+
angle = snapAngleToQuarterPi(angle); // handle float versions of PI properly
483+
final double tanA = Math.tan(TWO_PI - angle); // 'TWO_PI - ___' for clockwise orientation
484+
483485
// Avoid division by zero
484486
if (tanA == 0) {
485487
output[0].x = rect[3].x;
@@ -529,17 +531,40 @@ public static PVector[] lineRectIntersection(float rectWidth, float rectHeight,
529531
rect[3] = new PVector(rectWidth, 0);
530532
return lineRectIntersection(rect, point, angle);
531533
}
532-
534+
533535
/**
534-
* Double-argument version of {@link #lineRectIntersection(float, float, PVector, float)}.
536+
* Double-argument version of
537+
* {@link #lineRectIntersection(float, float, PVector, float)}.
535538
*/
536539
public static PVector[] lineRectIntersection(double rectWidth, double rectHeight, PVector point, double angle) {
537-
final PVector[] rect = new PVector[4];
538-
rect[0] = new PVector(0, 0);
539-
rect[1] = new PVector(0, (float) rectHeight);
540-
rect[2] = new PVector((float) rectWidth, (float) rectHeight);
541-
rect[3] = new PVector((float) rectWidth, 0);
542-
return lineRectIntersection(rect, point, (float) angle);
540+
final PVector[] rect = new PVector[4];
541+
rect[0] = new PVector(0, 0);
542+
rect[1] = new PVector(0, (float) rectHeight);
543+
rect[2] = new PVector((float) rectWidth, (float) rectHeight);
544+
rect[3] = new PVector((float) rectWidth, 0);
545+
return lineRectIntersection(rect, point, angle);
546+
}
547+
548+
/**
549+
* Corrects the angle to be a multiple of π/4 if it is very close to that
550+
* multiple, addressing floating-point precision issues with common angles like
551+
* π and π/2.
552+
* <p>
553+
* This method is especially useful when exact values are crucial for functions
554+
* like sin, cos, or tan, which may not behave as expected due to floating-point
555+
* inaccuracies (such as <code>float</code> version of π).
556+
*
557+
* @param angle the original angle in radians.
558+
* @return the adjusted angle, snapped to the nearest multiple of π/4 if within
559+
* a small threshold.
560+
*/
561+
public static double snapAngleToQuarterPi(double angle) {
562+
angle = angle % TWO_PI;
563+
double halfPiMultiple = Math.round(angle / QRTR_PI) * QRTR_PI;
564+
if (Math.abs(angle - halfPiMultiple) < 0.00314) {
565+
angle = halfPiMultiple;
566+
}
567+
return angle;
543568
}
544569

545570
/**
@@ -552,36 +577,36 @@ public static PVector[] lineRectIntersection(double rectWidth, double rectHeight
552577
* @param tanA tangent of the angle of the line within rectangle
553578
* @return points of intersection
554579
*/
555-
private static void calcProjection(float w, float h, PVector point, float tanA, PVector[] output) {
580+
private static void calcProjection(double w, double h, PVector point, double tanA, PVector[] output) {
556581

557-
float yCD = point.y - (tanA * (w - point.x));
558-
float yAB = point.y + (tanA * point.x);
582+
double yCD = point.y - (tanA * (w - point.x));
583+
double yAB = point.y + (tanA * point.x);
559584

560585
if (tanA < 0) {
561586
tanA = -tanA;
562587
}
563588

564589
// First projection onto CD
565590
if (yCD < 0) {
566-
output[0].x = w + (yCD / tanA);
591+
output[0].x = (float) (w + (yCD / tanA));
567592
} else if (yCD > h) {
568-
float opposite = yCD - h;
569-
output[0].x = w - (opposite / tanA);
570-
output[0].y = h;
593+
double opposite = yCD - h;
594+
output[0].x = (float) (w - (opposite / tanA));
595+
output[0].y = (float) h;
571596
} else {
572-
output[0].x = w;
573-
output[0].y = yCD;
597+
output[0].x = (float) w;
598+
output[0].y = (float) yCD;
574599
}
575600

576601
// Second projection onto AB
577602
if (yAB < 0) {
578-
output[1].x = -yAB / tanA;
603+
output[1].x = (float) (-yAB / tanA);
579604
} else if (yAB > h) {
580-
float opposite = yAB - h;
581-
output[1].x = opposite / tanA;
582-
output[1].y = h;
605+
double opposite = yAB - h;
606+
output[1].x = (float) (opposite / tanA);
607+
output[1].y = (float) h;
583608
} else {
584-
output[1].y = yAB;
609+
output[1].y = (float) yAB;
585610
}
586611
}
587612

src/test/micycle/peasygradients/colorspace/PeasyGradientsTests.java

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package micycle.peasygradients.colorspace;
22

3-
import static org.junit.jupiter.api.Assertions.*;
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
45

56
import java.util.Arrays;
67

@@ -32,13 +33,13 @@ void testLinearGradient() {
3233
// test all values same
3334
Gradient gradient = new Gradient(GREY, GREY);
3435
pg.linearGradient(gradient, 0);
35-
for (int i = 0; i < g.pixels.length; i++) {
36-
assertEquals(GREY, g.pixels[i]);
36+
for (int pixel : g.pixels) {
37+
assertEquals(GREY, pixel);
3738
}
3839

3940
// test gradient rendered monotonically
4041
gradient = new Gradient(WHITE, BLACK);
41-
pg.linearGradient(gradient, PConstants.TWO_PI); // also tests ange=two_pi == angle=0
42+
pg.linearGradient(gradient, PConstants.TWO_PI); // also tests that angle=two_pi == angle=0
4243
float[] lastCol = new float[] { 256, 256, 256 };
4344
for (int i = 0; i < g.pixels.length; i++) {
4445
float[] col = ColorUtils.decomposeclrRGB(g.pixels[i]);
@@ -60,16 +61,47 @@ void testLinearGradient() {
6061
}
6162

6263
@ParameterizedTest
63-
@ValueSource(ints = {1, 2, 3, 5, 50})
64+
@ValueSource(doubles = { 0, 2 * Math.PI, PConstants.TWO_PI })
65+
void testLinearGradientVerticalDivision(double angle) {
66+
/*
67+
* Test the linear gradient division. Each half (with posterisation=2) should be
68+
* each respective color.
69+
*/
70+
int w = 100;
71+
int h = 100;
72+
PImage g = new PImage(w, h);
73+
PeasyGradients pg = new PeasyGradients(g);
74+
pg.posterise(2);
75+
76+
int colA = WHITE;
77+
int colB = BLACK;
78+
Gradient gradient = new Gradient(colA, colB);
79+
pg.linearGradient(gradient, angle);
80+
81+
for (int x = 0; x < w / 2; x++) {
82+
for (int y = 0; y < h; y++) {
83+
assertEquals(colA, g.pixels[y * w + x], "Failed at x=" + x + ", y=" + y);
84+
}
85+
}
86+
for (int x = w / 2; x < w; x++) {
87+
for (int y = 0; y < h; y++) {
88+
assertEquals(colB, g.pixels[y * w + x], "Failed at x=" + x + ", y=" + y);
89+
}
90+
}
91+
92+
}
93+
94+
@ParameterizedTest
95+
@ValueSource(ints = { 1, 2, 3, 5, 50 })
6496
void testPosterise(int n) {
65-
PImage g = new PImage(1000, 1000);
66-
PeasyGradients pg = new PeasyGradients(g);
67-
pg.posterise(n);
97+
PImage g = new PImage(1000, 1000);
98+
PeasyGradients pg = new PeasyGradients(g);
99+
pg.posterise(n);
68100

69-
Gradient gradient = new Gradient(Palette.tetradic());
70-
pg.linearGradient(gradient, 0);
101+
Gradient gradient = new Gradient(Palette.tetradic());
102+
pg.linearGradient(gradient, 0);
71103

72-
assertEquals(n, makeUnique(g.pixels).length);
104+
assertEquals(n, makeUnique(g.pixels).length);
73105
}
74106

75107
private static int[] makeUnique(int... values) {

0 commit comments

Comments
 (0)