diff --git a/.gitignore b/.gitignore index 26b5b9756..59058d93e 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,4 @@ java/build/ /app/windows/obj /java/gradle/build /java/gradle/example/.processing +.kotlin/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c40365758..1cd69c7dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -353,7 +353,14 @@ tasks.register("includeJavaMode") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } tasks.register("includeJdk") { - from(Jvm.current().javaHome.absolutePath) + from(Jvm.current().javaHome.absolutePath) { + // TODO: Check if this is still needed when upgrading from JDK 17 + // https://github.com/adoptium/adoptium-support/issues/937 + if (OperatingSystem.current().isMacOsX) { + exclude("**/*.jsa") + exclude("**/legal/**") + } + } destinationDir = composeResources("jdk").get().asFile } tasks.register("includeSharedAssets"){ diff --git a/core/src/processing/awt/PGraphicsJava2D.java b/core/src/processing/awt/PGraphicsJava2D.java index 7d784ee60..015245a9a 100644 --- a/core/src/processing/awt/PGraphicsJava2D.java +++ b/core/src/processing/awt/PGraphicsJava2D.java @@ -2777,17 +2777,52 @@ protected WritableRaster getRaster() { @Override public void loadPixels() { - if (pixels == null || (pixels.length != pixelWidth*pixelHeight)) { - pixels = new int[pixelWidth * pixelHeight]; - } + if (pixelDensity > 1) { + loadLogicalPixels(); + } else { + if (pixels == null || (pixels.length != pixelWidth*pixelHeight)) { + pixels = new int[pixelWidth * pixelHeight]; + } + WritableRaster raster = getRaster(); + raster.getDataElements(0, 0, pixelWidth, pixelHeight, pixels); + if (raster.getNumBands() == 3) { + // Java won't set the high bits when RGB, returns 0 for alpha + // https://github.com/processing/processing/issues/2030 + for (int i = 0; i < pixels.length; i++) { + pixels[i] = 0xff000000 | pixels[i]; + } + } + } + } + + protected void loadLogicalPixels() { + int logicalPixelCount = width * height; + int physicalPixelCount = pixelWidth * pixelHeight; + + if (pixels == null || (pixels.length != logicalPixelCount)) { + pixels = new int[logicalPixelCount]; + } + + // Create temporary physical pixel buffe + int[] physicalPixels = new int[physicalPixelCount]; + WritableRaster raster = getRaster(); - raster.getDataElements(0, 0, pixelWidth, pixelHeight, pixels); + raster.getDataElements(0, 0, pixelWidth, pixelHeight, physicalPixels); if (raster.getNumBands() == 3) { - // Java won't set the high bits when RGB, returns 0 for alpha - // https://github.com/processing/processing/issues/2030 - for (int i = 0; i < pixels.length; i++) { - pixels[i] = 0xff000000 | pixels[i]; + for (int i = 0; i < physicalPixels.length; i++) { + physicalPixels[i] = 0xff000000 | physicalPixels[i]; + } + } + + for (int logicalY = 0; logicalY < height; logicalY++) { + for (int logicalX = 0; logicalX < width; logicalX++) { + int logicalIndex = logicalY * width + logicalX; + + float physicalX = (logicalX + 0.5f) * pixelDensity - 0.5f; + float physicalY = (logicalY + 0.5f) * pixelDensity - 0.5f; + + pixels[logicalIndex] = bilinearSample(physicalPixels, physicalX, physicalY, pixelWidth, pixelHeight); } } } @@ -2816,19 +2851,223 @@ public void loadPixels() { */ @Override public void updatePixels(int x, int y, int c, int d) { - //if ((x == 0) && (y == 0) && (c == width) && (d == height)) { -// System.err.format("%d %d %d %d .. w/h = %d %d .. pw/ph = %d %d %n", x, y, c, d, width, height, pixelWidth, pixelHeight); - if ((x != 0) || (y != 0) || (c != pixelWidth) || (d != pixelHeight)) { + if (pixelDensity > 1) { + updateLogicalPixels(x, y, c, d); + } else { + if ((x != 0) || (y != 0) || (c != pixelWidth) || (d != pixelHeight)) { + // Show a warning message, but continue anyway. + showVariationWarning("updatePixels(x, y, w, h)"); + } + if (pixels != null) { + getRaster().setDataElements(0, 0, pixelWidth, pixelHeight, pixels); + } + modified = true; + } + } + + protected void updateLogicalPixels(int x, int y, int c, int d) { + // For pixel density > 1, upsample logical pixels to physical pixels + if ((x != 0) || (y != 0) || (c != width) || (d != height)) { // Show a warning message, but continue anyway. showVariationWarning("updatePixels(x, y, w, h)"); -// new Exception().printStackTrace(System.out); } -// updatePixels(); + if (pixels != null) { - getRaster().setDataElements(0, 0, pixelWidth, pixelHeight, pixels); + int physicalPixelCount = pixelWidth * pixelHeight; + int[] physicalPixels = new int[physicalPixelCount]; + + if (parent.pixelAccessMode == PIXEL_SMOOTH) { + for (int physicalY = 0; physicalY < pixelHeight; physicalY++) { + for (int physicalX = 0; physicalX < pixelWidth; physicalX++) { + int physicalIndex = physicalY * pixelWidth + physicalX; + + float logicalX = (physicalX + 0.5f) / pixelDensity - 0.5f; + float logicalY = (physicalY + 0.5f) / pixelDensity - 0.5f; + + physicalPixels[physicalIndex] = bilinearSample(pixels, logicalX, logicalY, width, height); + } + } + } else { + for (int physicalY = 0; physicalY < pixelHeight; physicalY++) { + for (int physicalX = 0; physicalX < pixelWidth; physicalX++) { + int physicalIndex = physicalY * pixelWidth + physicalX; + + int logicalX = physicalX / pixelDensity; + int logicalY = physicalY / pixelDensity; + + logicalX = Math.max(0, Math.min(width - 1, logicalX)); + logicalY = Math.max(0, Math.min(height - 1, logicalY)); + + int logicalIndex = logicalY * width + logicalX; + physicalPixels[physicalIndex] = pixels[logicalIndex]; + } + } + } + + getRaster().setDataElements(0, 0, pixelWidth, pixelHeight, physicalPixels); } modified = true; } + + + /** + * Perform bilinear interpolation on pixel data. + * @param pixels the source pixel array + * @param x floating point x coordinate + * @param y floating point y coordinate + * @param w width of the source image + * @param h height of the source image + * @return interpolated color value + */ + private int bilinearSample(int[] pixels, float x, float y, int w, int h) { + // Clamp coordinates + x = Math.max(0, Math.min(w - 1, x)); + y = Math.max(0, Math.min(h - 1, y)); + + // Get integer coordinates and fractional parts + int x1 = (int) Math.floor(x); + int y1 = (int) Math.floor(y); + int x2 = Math.min(x1 + 1, w - 1); + int y2 = Math.min(y1 + 1, h - 1); + + float fx = x - x1; + float fy = y - y1; + + // Sample four corner pixels + int c00 = pixels[y1 * w + x1]; // top-left + int c10 = pixels[y1 * w + x2]; // top-right + int c01 = pixels[y2 * w + x1]; // bottom-left + int c11 = pixels[y2 * w + x2]; // bottom-right + + // Interpolate each color channel separately + int a = bilinearChannel((c00 >> 24) & 0xff, (c10 >> 24) & 0xff, (c01 >> 24) & 0xff, (c11 >> 24) & 0xff, fx, fy); + int r = bilinearChannel((c00 >> 16) & 0xff, (c10 >> 16) & 0xff, (c01 >> 16) & 0xff, (c11 >> 16) & 0xff, fx, fy); + int g = bilinearChannel((c00 >> 8) & 0xff, (c10 >> 8) & 0xff, (c01 >> 8) & 0xff, (c11 >> 8) & 0xff, fx, fy); + int b = bilinearChannel(c00 & 0xff, c10 & 0xff, c01 & 0xff, c11 & 0xff, fx, fy); + + return (a << 24) | (r << 16) | (g << 8) | b; + } + + + /** + * Bilinear interpolation for a single color channel. + * @param c00 top-left value + * @param c10 top-right value + * @param c01 bottom-left value + * @param c11 bottom-right value + * @param fx horizontal interpolation factor (0-1) + * @param fy vertical interpolation factor (0-1) + * @return interpolated channel value + */ + private int bilinearChannel(int c00, int c10, int c01, int c11, float fx, float fy) { + float top = c00 * (1 - fx) + c10 * fx; + float bottom = c01 * (1 - fx) + c11 * fx; + return Math.round(top * (1 - fy) + bottom * fy); + } + + + /** + * Distribute a color to physical pixels using bilinear weights. + * This is the inverse operation of bilinear sampling, ensuring + * that set(get()) operations are symmetric in PIXEL_SMOOTH mode. + * @param color the color to distribute + * @param physicalX floating point physical x coordinate + * @param physicalY floating point physical y coordinate + */ + private void distributeBilinear(int color, float physicalX, float physicalY) { + // Clamp coordinates + physicalX = Math.max(0, Math.min(pixelWidth - 1, physicalX)); + physicalY = Math.max(0, Math.min(pixelHeight - 1, physicalY)); + + // Get integer coordinates and fractional parts + int x1 = (int) Math.floor(physicalX); + int y1 = (int) Math.floor(physicalY); + int x2 = Math.min(x1 + 1, pixelWidth - 1); + int y2 = Math.min(y1 + 1, pixelHeight - 1); + + float fx = physicalX - x1; + float fy = physicalY - y1; + + // Calculate bilinear weights + float w00 = (1 - fx) * (1 - fy); // top-left + float w10 = fx * (1 - fy); // top-right + float w01 = (1 - fx) * fy; // bottom-left + float w11 = fx * fy; // bottom-right + + // Read current physical pixels + WritableRaster raster = getRaster(); + int[] corners = new int[4]; + + raster.getDataElements(x1, y1, getset); + corners[0] = getset[0]; // top-left + + raster.getDataElements(x2, y1, getset); + corners[1] = getset[0]; // top-right + + raster.getDataElements(x1, y2, getset); + corners[2] = getset[0]; // bottom-left + + raster.getDataElements(x2, y2, getset); + corners[3] = getset[0]; // bottom-right + + // Handle RGB format (add alpha channel if missing) + if (raster.getNumBands() == 3) { + for (int i = 0; i < corners.length; i++) { + corners[i] = 0xff000000 | corners[i]; + } + } + + // Blend new color + int[] newColors = new int[4]; + newColors[0] = blendColors(corners[0], color, w00); // top-left + newColors[1] = blendColors(corners[1], color, w10); // top-right + newColors[2] = blendColors(corners[2], color, w01); // bottom-left + newColors[3] = blendColors(corners[3], color, w11); // bottom-right + + getset[0] = newColors[0]; + raster.setDataElements(x1, y1, getset); + + getset[0] = newColors[1]; + raster.setDataElements(x2, y1, getset); + + getset[0] = newColors[2]; + raster.setDataElements(x1, y2, getset); + + getset[0] = newColors[3]; + raster.setDataElements(x2, y2, getset); + } + + + /** + * Blend two colors using a weight factor. + * @param existing the existing color + * @param newColor the new color to blend in + * @param weight the weight of the new color (0.0 to 1.0) + * @return the blended color + */ + private int blendColors(int existing, int newColor, float weight) { + if (weight <= 0) return existing; + if (weight >= 1) return newColor; + + // Extract channels + int aExist = (existing >> 24) & 0xff; + int rExist = (existing >> 16) & 0xff; + int gExist = (existing >> 8) & 0xff; + int bExist = existing & 0xff; + + int aNew = (newColor >> 24) & 0xff; + int rNew = (newColor >> 16) & 0xff; + int gNew = (newColor >> 8) & 0xff; + int bNew = newColor & 0xff; + + // Blend channels + int aBlend = Math.round(aExist * (1 - weight) + aNew * weight); + int rBlend = Math.round(rExist * (1 - weight) + rNew * weight); + int gBlend = Math.round(gExist * (1 - weight) + gNew * weight); + int bBlend = Math.round(bExist * (1 - weight) + bNew * weight); + + return (aBlend << 24) | (rBlend << 16) | (gBlend << 8) | bBlend; + } // @Override @@ -2855,15 +3094,54 @@ public void updatePixels(int x, int y, int c, int d) { @Override public int get(int x, int y) { if ((x < 0) || (y < 0) || (x >= width) || (y >= height)) return 0; - //return ((BufferedImage) image).getRGB(x, y); -// WritableRaster raster = ((BufferedImage) (useOffscreen && primarySurface ? offscreen : image)).getRaster(); - WritableRaster raster = getRaster(); - raster.getDataElements(x, y, getset); - if (raster.getNumBands() == 3) { - // https://github.com/processing/processing/issues/2030 - return getset[0] | 0xff000000; + + if (pixelDensity > 1) { + if (parent.pixelAccessMode == PIXEL_SMOOTH) { + float physicalX = (x + 0.5f) * pixelDensity - 0.5f; + float physicalY = (y + 0.5f) * pixelDensity - 0.5f; + + int minX = Math.max(0, (int) Math.floor(physicalX)); + int minY = Math.max(0, (int) Math.floor(physicalY)); + int maxX = Math.min(pixelWidth - 1, (int) Math.ceil(physicalX)); + int maxY = Math.min(pixelHeight - 1, (int) Math.ceil(physicalY)); + + int regionWidth = maxX - minX + 1; + int regionHeight = maxY - minY + 1; + int[] regionPixels = new int[regionWidth * regionHeight]; + + WritableRaster raster = getRaster(); + raster.getDataElements(minX, minY, regionWidth, regionHeight, regionPixels); + + if (raster.getNumBands() == 3) { + for (int i = 0; i < regionPixels.length; i++) { + regionPixels[i] = 0xff000000 | regionPixels[i]; + } + } + + float adjustedX = physicalX - minX; + float adjustedY = physicalY - minY; + + return bilinearSample(regionPixels, adjustedX, adjustedY, regionWidth, regionHeight); + } else { + int physicalX = x * pixelDensity; + int physicalY = y * pixelDensity; + + WritableRaster raster = getRaster(); + raster.getDataElements(physicalX, physicalY, getset); + if (raster.getNumBands() == 3) { + return getset[0] | 0xff000000; + } + return getset[0]; + } + } else { + WritableRaster raster = getRaster(); + raster.getDataElements(x, y, getset); + if (raster.getNumBands() == 3) { + // https://github.com/processing/processing/issues/2030 + return getset[0] | 0xff000000; + } + return getset[0]; } - return getset[0]; } @@ -2914,12 +3192,33 @@ protected void getImpl(int sourceX, int sourceY, @Override public void set(int x, int y, int argb) { - if ((x < 0) || (y < 0) || (x >= pixelWidth) || (y >= pixelHeight)) return; -// ((BufferedImage) image).setRGB(x, y, argb); - getset[0] = argb; -// WritableRaster raster = ((BufferedImage) (useOffscreen && primarySurface ? offscreen : image)).getRaster(); -// WritableRaster raster = image.getRaster(); - getRaster().setDataElements(x, y, getset); + if ((x < 0) || (y < 0) || (x >= width) || (y >= height)) return; + + if (pixelDensity > 1) { + if (parent.pixelAccessMode == PIXEL_SMOOTH) { + float physicalX = (x + 0.5f) * pixelDensity - 0.5f; + float physicalY = (y + 0.5f) * pixelDensity - 0.5f; + + distributeBilinear(argb, physicalX, physicalY); + } else { + int physicalStartX = x * pixelDensity; + int physicalStartY = y * pixelDensity; + + getset[0] = argb; + WritableRaster raster = getRaster(); + + for (int py = physicalStartY; py < physicalStartY + pixelDensity; py++) { + for (int px = physicalStartX; px < physicalStartX + pixelDensity; px++) { + if (px < pixelWidth && py < pixelHeight) { + raster.setDataElements(px, py, getset); + } + } + } + } + } else { + getset[0] = argb; + getRaster().setDataElements(x, y, getset); + } } diff --git a/core/src/processing/core/PApplet.java b/core/src/processing/core/PApplet.java index d1297ec6f..3351ba2b6 100644 --- a/core/src/processing/core/PApplet.java +++ b/core/src/processing/core/PApplet.java @@ -803,6 +803,9 @@ public PSurface getSurface() { // the pixelWidth and pixelHeight fields. public int pixelDensity = 1; + // Pixel access mode for high DPI scaling behavior + public int pixelAccessMode = PIXEL_EXACT; + boolean present; String outputPath; @@ -1105,6 +1108,36 @@ public void pixelDensity(int density) { } + /** + * Set the pixel access mode for high DPI displays. This controls how + * get() and set() operations behave when pixelDensity + * is greater than 1. + *

+ * PIXEL_EXACT (default): Uses nearest-neighbor sampling for pixel-perfect + * operations. Guarantees that set(x, y, get(x, y)) will not change + * the image. + *

+ * PIXEL_SMOOTH: Uses bilinear interpolation for smoother scaling. + * May return interpolated color values that don't exist in the original + * image. + *

+ * This function can be called at any time during the program execution. + * + * @webref environment + * @webBrief Controls pixel access behavior for high DPI displays + * @param mode either PIXEL_EXACT or PIXEL_SMOOTH + * @see PApplet#pixelDensity(int) + * @see PApplet#get(int, int) + * @see PApplet#set(int, int, int) + */ + public void pixelAccessMode(int mode) { + if (mode != PIXEL_EXACT && mode != PIXEL_SMOOTH) { + throw new RuntimeException("pixelAccessMode() must be PIXEL_EXACT or PIXEL_SMOOTH"); + } + this.pixelAccessMode = mode; + } + + /** * Called by PSurface objects to set the width and height variables, * and update the pixelWidth and pixelHeight variables. diff --git a/core/src/processing/core/PConstants.java b/core/src/processing/core/PConstants.java index af17c1fbf..e530997c9 100644 --- a/core/src/processing/core/PConstants.java +++ b/core/src/processing/core/PConstants.java @@ -232,6 +232,12 @@ public interface PConstants { // static final int CMYK = 5; // image & color (someday) + // pixel access modes (for high DPI / pixel density scaling) + + int PIXEL_EXACT = 0; // nearest-neighbor, pixel-perfect operations + int PIXEL_SMOOTH = 1; // bilinear interpolation, smooth scaling + + // image file types int TIFF = 0; diff --git a/core/src/processing/core/PImage.java b/core/src/processing/core/PImage.java index 666061ee9..5773ff7ff 100644 --- a/core/src/processing/core/PImage.java +++ b/core/src/processing/core/PImage.java @@ -434,27 +434,23 @@ public void updatePixels() { // ignore * @param w width * @param h height */ - public void updatePixels(int x, int y, int w, int h) { // ignore + public void updatePixels(int x, int y, int w, int h) { int x2 = x + w; int y2 = y + h; + int boundsWidth = (pixelDensity > 1) ? width : pixelWidth; + int boundsHeight = (pixelDensity > 1) ? height : pixelHeight; if (!modified) { mx1 = PApplet.max(0, x); - mx2 = PApplet.min(pixelWidth, x2); + mx2 = PApplet.min(boundsWidth, x2); my1 = PApplet.max(0, y); - my2 = PApplet.min(pixelHeight, y2); + my2 = PApplet.min(boundsHeight, y2); modified = true; - } else { if (x < mx1) mx1 = PApplet.max(0, x); - if (x > mx2) mx2 = PApplet.min(pixelWidth, x); + if (x2 > mx2) mx2 = PApplet.min(boundsWidth, x2); if (y < my1) my1 = PApplet.max(0, y); - if (y > my2) my2 = PApplet.min(pixelHeight, y); - - if (x2 < mx1) mx1 = PApplet.max(0, x2); - if (x2 > mx2) mx2 = PApplet.min(pixelWidth, x2); - if (y2 < my1) my1 = PApplet.max(0, y2); - if (y2 > my2) my2 = PApplet.min(pixelHeight, y2); + if (y2 > my2) my2 = PApplet.min(boundsHeight, y2); } } @@ -579,12 +575,15 @@ public void setLoaded(boolean l) { // ignore * @see PApplet#copy(PImage, int, int, int, int, int, int, int, int) */ public int get(int x, int y) { - if ((x < 0) || (y < 0) || (x >= pixelWidth) || (y >= pixelHeight)) return 0; + int boundsWidth = (pixelDensity > 1) ? width : pixelWidth; + int boundsHeight = (pixelDensity > 1) ? height : pixelHeight; + + if ((x < 0) || (y < 0) || (x >= boundsWidth) || (y >= boundsHeight)) return 0; return switch (format) { - case RGB -> pixels[y * pixelWidth + x] | 0xff000000; - case ARGB -> pixels[y * pixelWidth + x]; - case ALPHA -> (pixels[y * pixelWidth + x] << 24) | 0xffffff; + case RGB -> pixels[y * boundsWidth + x] | 0xff000000; + case ARGB -> pixels[y * boundsWidth + x]; + case ALPHA -> (pixels[y * boundsWidth + x] << 24) | 0xffffff; default -> 0; }; } @@ -704,18 +703,21 @@ protected void getImpl(int sourceX, int sourceY, * @usage web_application * @param x x-coordinate of the pixel * @param y y-coordinate of the pixel - * @param c any value of the color datatype + * @param argb any value of the color datatype * @see PImage#get(int, int, int, int) * @see PImage#pixels * @see PImage#copy(PImage, int, int, int, int, int, int, int, int) */ - public void set(int x, int y, int c) { - if ((x < 0) || (y < 0) || (x >= pixelWidth) || (y >= pixelHeight)) return; + public void set(int x, int y, int argb) { + loadPixels(); + int boundsWidth = (pixelDensity > 1) ? width : pixelWidth; + int boundsHeight = (pixelDensity > 1) ? height : pixelHeight; + if ((x < 0) || (y < 0) || (x >= boundsWidth) || (y >= boundsHeight)) return; switch (format) { - case RGB -> pixels[y * pixelWidth + x] = 0xff000000 | c; - case ARGB -> pixels[y * pixelWidth + x] = c; - case ALPHA -> pixels[y * pixelWidth + x] = ((c & 0xff) << 24) | 0xffffff; + case RGB -> pixels[y * boundsWidth + x] = 0xff000000 | argb; + case ARGB -> pixels[y * boundsWidth + x] = argb; + case ALPHA -> pixels[y * boundsWidth + x] = ((argb & 0xff) << 24) | 0xffffff; } updatePixels(x, y, 1, 1); // slow... diff --git a/core/src/processing/opengl/FrameBuffer.java b/core/src/processing/opengl/FrameBuffer.java index a5426c52d..da8ac74e3 100644 --- a/core/src/processing/opengl/FrameBuffer.java +++ b/core/src/processing/opengl/FrameBuffer.java @@ -176,10 +176,14 @@ public void copyStencil(FrameBuffer dest) { } public void copy(FrameBuffer dest, int mask) { + copy(dest, mask, PGL.NEAREST); + } + + public void copy(FrameBuffer dest, int mask, int filter) { pgl.bindFramebufferImpl(PGL.READ_FRAMEBUFFER, this.glFbo); pgl.bindFramebufferImpl(PGL.DRAW_FRAMEBUFFER, dest.glFbo); pgl.blitFramebuffer(0, 0, this.width, this.height, - 0, 0, dest.width, dest.height, mask, PGL.NEAREST); + 0, 0, dest.width, dest.height, mask, filter); pgl.bindFramebufferImpl(PGL.READ_FRAMEBUFFER, pg.getCurrentFB().glFbo); pgl.bindFramebufferImpl(PGL.DRAW_FRAMEBUFFER, pg.getCurrentFB().glFbo); } diff --git a/core/src/processing/opengl/PGraphicsOpenGL.java b/core/src/processing/opengl/PGraphicsOpenGL.java index 88164f43e..425c03958 100644 --- a/core/src/processing/opengl/PGraphicsOpenGL.java +++ b/core/src/processing/opengl/PGraphicsOpenGL.java @@ -401,6 +401,8 @@ public void dispose() { protected FrameBuffer drawFramebuffer; protected FrameBuffer readFramebuffer; protected FrameBuffer currentFramebuffer; + // Used for upscaling/downscaling the framebuffer when pixelDensity > 1 + protected FrameBuffer logicalFramebuffer; // ....................................................... @@ -5378,8 +5380,10 @@ public void loadPixels() { protected void allocatePixels() { updatePixelSize(); - if ((pixels == null) || (pixels.length != pixelWidth * pixelHeight)) { - pixels = new int[pixelWidth * pixelHeight]; + int pixelCount = (pixelDensity > 1) ? (width * height) : (pixelWidth * pixelHeight); + + if ((pixels == null) || (pixels.length != pixelCount)) { + pixels = new int[pixelCount]; pixelBuffer = PGL.allocateIntBuffer(pixels); loaded = false; } @@ -5388,6 +5392,11 @@ protected void allocatePixels() { protected void readPixels() { updatePixelSize(); + if (pixelDensity > 1) { + readLogicalPixels(); + return; + } + beginPixelsOp(OP_READ); try { // The readPixelsImpl() call in inside a try/catch block because it appears @@ -5396,7 +5405,7 @@ protected void readPixels() { // of this the width and height might have a different size than the // one of the pixels arrays. pgl.readPixelsImpl(0, 0, pixelWidth, pixelHeight, PGL.RGBA, PGL.UNSIGNED_BYTE, - pixelBuffer); + pixelBuffer); } catch (IndexOutOfBoundsException e) { // Silently catch the exception. } @@ -5408,10 +5417,49 @@ protected void readPixels() { } catch (ArrayIndexOutOfBoundsException e) { // ignored } + + } + + /** + * Downscales the current framebuffer to the logical framebuffer before + * readback into the pixels array. + */ + protected void readLogicalPixels() { + ensureLogicalFramebuffer(); + + beginPixelsOp(OP_READ); + + FrameBuffer readFB = getCurrentFB(); + int filter = parent.pixelAccessMode == PIXEL_EXACT ? PGL.NEAREST : PGL.LINEAR; + readFB.copy(logicalFramebuffer, PGL.COLOR_BUFFER_BIT, filter); + + pushFramebuffer(); + setFramebuffer(logicalFramebuffer); + + try { + pgl.readPixelsImpl(0, 0, width, height, PGL.RGBA, PGL.UNSIGNED_BYTE, pixelBuffer); + } catch (IndexOutOfBoundsException e) { + // Silently catch the exception. + } + + popFramebuffer(); + endPixelsOp(); + + try { + PGL.getIntArray(pixelBuffer, pixels); + PGL.nativeToJavaARGB(pixels, width, height); + } catch (ArrayIndexOutOfBoundsException e) { + // ignored + } } protected void drawPixels(int x, int y, int w, int h) { + if (pixelDensity > 1) { + drawLogicalPixels(x, y, w, h); + return; + } + int len = w * h; if (nativePixels == null || nativePixels.length < len) { nativePixels = new int[len]; @@ -5475,6 +5523,97 @@ protected void drawPixels(int x, int y, int w, int h) { } } + /** + * Draws the pixels to the logical framebuffer before upscaling to the + * output texture. + */ + protected void drawLogicalPixels(int x, int y, int w, int h) { + ensureLogicalFramebuffer(); + + int len = w * h; + if (nativePixels == null || nativePixels.length < len) { + nativePixels = new int[len]; + nativePixelBuffer = PGL.allocateIntBuffer(nativePixels); + } + + try { + if (0 < x || 0 < y || w < width || h < height) { + int offset0 = y * width + x; + int offset1 = 0; + for (int yc = y; yc < y + h; yc++) { + System.arraycopy(pixels, offset0, nativePixels, offset1, w); + offset0 += width; + offset1 += w; + } + } else { + PApplet.arrayCopy(pixels, 0, nativePixels, 0, len); + } + PGL.javaToNativeARGB(nativePixels, w, h); + } catch (ArrayIndexOutOfBoundsException e) { + // ignored + } + PGL.putIntArray(nativePixelBuffer, nativePixels); + + // TODO: Is there a better way to handle gpu->gpu writes in Processing's + // framebuffer management abstraction? + if (primaryGraphics && !pgl.isFBOBacked()) { + loadTextureImpl(POINT, false); + } + + Texture logicalTexture = logicalFramebuffer.colorBufferTex[0]; + pgl.copyToTexture(logicalTexture.glTarget, logicalTexture.glFormat, + logicalTexture.glName, x, height - (y + h), w, h, + nativePixelBuffer); + pgl.bindTexture(logicalTexture.glTarget, 0); + + IntBuffer fboId = IntBuffer.allocate(1); + pgl.genFramebuffers(1, fboId); + fboId.rewind(); + int framebufferId = fboId.get(0); + + pgl.bindFramebufferImpl(PGL.FRAMEBUFFER, framebufferId); + pgl.framebufferTexture2D(PGL.FRAMEBUFFER, PGL.COLOR_ATTACHMENT0, + texture.glTarget, texture.glName, 0); + + FrameBuffer textureFB = new FrameBuffer(this); + textureFB.glFbo = framebufferId; + textureFB.width = pixelWidth; + textureFB.height = pixelHeight; + + pgl.bindFramebufferImpl(PGL.READ_FRAMEBUFFER, logicalFramebuffer.glFbo); + pgl.bindFramebufferImpl(PGL.DRAW_FRAMEBUFFER, framebufferId); + + int physX = x * pixelDensity; + int physY = y * pixelDensity; + int physW = w * pixelDensity; + int physH = h * pixelDensity; + int srcY = height - (y + h); + + // Blit the framebuffer from logical to physical coordinates + // Note: the y-coord flipping can be confusing in this entire routine + int filter = parent.pixelAccessMode == PIXEL_EXACT ? PGL.NEAREST : PGL.LINEAR; + pgl.blitFramebuffer(x, srcY, x + w, srcY + h, + physX, physY, physX + physW, physY + physH, + PGL.COLOR_BUFFER_BIT, filter); + + + fboId.rewind(); + fboId.put(0, framebufferId); + pgl.bindFramebufferImpl(PGL.READ_FRAMEBUFFER, getCurrentFB().glFbo); + pgl.bindFramebufferImpl(PGL.DRAW_FRAMEBUFFER, getCurrentFB().glFbo); + pgl.deleteFramebuffers(1, fboId); + + boolean needToDrawTex = primaryGraphics && (!pgl.isFBOBacked() || + (pgl.isFBOBacked() && pgl.isMultisampled())) || + offscreenMultisample; + if (texture == null) return; + if (needToDrawTex) { + beginPixelsOp(OP_WRITE); + drawTexture(physX, physY, physW, physH); + endPixelsOp(); + } + } + ////////////////////////////////////////////////////////////// @@ -6556,6 +6695,41 @@ protected void initPrimary() { initialized = true; } + /** + * Ensures that the logical framebuffer is created and + * up-to-date with the current width and height of the + * image. + */ + protected void ensureLogicalFramebuffer() { + if (logicalFramebuffer == null || + logicalFramebuffer.width != width || + logicalFramebuffer.height != height || + logicalFramebuffer.contextIsOutdated()) { + + if (logicalFramebuffer != null) { + logicalFramebuffer.dispose(); + } + + // TODO: We really need to make sure that the logical framebuffer + // is always created with the same configuration as the current framebuffer. + // Double check msaa still works. + FrameBuffer currentFB = getCurrentFB(); + int depthBits = currentFB.depthBits; + int stencilBits = currentFB.stencilBits; + boolean packedDepthStencil = currentFB.packedDepthStencil; + logicalFramebuffer = new FrameBuffer(this, width, height, 1, 1, + depthBits, stencilBits, + packedDepthStencil, false); + Texture colorTex = new Texture(this, width, height); + colorTex.init(width, height); + logicalFramebuffer.setColorBuffer(colorTex); + + pushFramebuffer(); + setFramebuffer(logicalFramebuffer); + pgl.validateFramebuffer(); + popFramebuffer(); + } + } protected void beginOnscreenDraw() { updatePixelSize(); diff --git a/core/test/processing/core/PixelDensityTest.java b/core/test/processing/core/PixelDensityTest.java new file mode 100644 index 000000000..83db9552c --- /dev/null +++ b/core/test/processing/core/PixelDensityTest.java @@ -0,0 +1,218 @@ +package processing.core; + +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Tests for pixel density scaling functionality, including logical pixel arrays + * and pixel access modes (PIXEL_EXACT vs PIXEL_SMOOTH). + * + * TODO: + * - Fractional scaling. + * - Some kind of openGL test. + */ +public class PixelDensityTest { + + private TestPApplet sketch; + private PGraphics java2d; + private PGraphics opengl; + + static class TestPApplet extends PApplet { + public void settings() { + size(100, 100); + } + + public void setup() { + } + + public void draw() { + } + } + + @Before + public void setUp() { + sketch = new TestPApplet(); + sketch.settings(); + sketch.setup(); + sketch.initSurface(); + + // 2d graphics + java2d = sketch.createGraphics(100, 100, PConstants.JAVA2D); + + try { + opengl = sketch.createGraphics(100, 100, PConstants.P3D); + } catch (Exception e) { + // headless, ci, etc + opengl = null; + } + } + + @Test + public void testPixelDensityConstants() { + assertEquals("PIXEL_EXACT should be 0", 0, PConstants.PIXEL_EXACT); + assertEquals("PIXEL_SMOOTH should be 1", 1, PConstants.PIXEL_SMOOTH); + } + + @Test + public void testPixelAccessModeValidation() { + sketch.pixelAccessMode(PConstants.PIXEL_EXACT); + assertEquals(PConstants.PIXEL_EXACT, sketch.pixelAccessMode); + + sketch.pixelAccessMode(PConstants.PIXEL_SMOOTH); + assertEquals(PConstants.PIXEL_SMOOTH, sketch.pixelAccessMode); + + try { + sketch.pixelAccessMode(99); + fail("Should throw exception for invalid mode"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("PIXEL_EXACT or PIXEL_SMOOTH")); + } + } + + @Test + public void testLogicalPixelArraySize() { + java2d.pixelDensity = 2; + java2d.beginDraw(); + + assertEquals("Logical width should be unchanged", 100, java2d.width); + assertEquals("Logical height should be unchanged", 100, java2d.height); + + assertEquals("Physical width should be scaled", 200, java2d.pixelWidth); + assertEquals("Physical height should be scaled", 200, java2d.pixelHeight); + + java2d.loadPixels(); + assertEquals("Pixels array should be logical size", 100 * 100, java2d.pixels.length); + + java2d.endDraw(); + } + + @Test + public void testPixelExactModeSymmetry() { + sketch.pixelAccessMode(PConstants.PIXEL_EXACT); + + java2d.pixelDensity = 2; + java2d.beginDraw(); + java2d.background(0); + + java2d.set(10, 10, 0xFFFF0000); // Red + int redColor = java2d.get(10, 10); + java2d.set(10, 10, redColor); + int finalColor = java2d.get(10, 10); + + assertEquals("set(get()) should be a no-op", redColor, finalColor); + + java2d.endDraw(); + } + + @Test + public void testPixelSmoothModeSymmetry() { + sketch.pixelAccessMode(PConstants.PIXEL_SMOOTH); + + java2d.pixelDensity = 2; + java2d.beginDraw(); + java2d.background(0); + + java2d.set(10, 10, 0xFFFF0000); // Red + int redColor = java2d.get(10, 10); + java2d.set(10, 10, redColor); + int finalColor = java2d.get(10, 10); + + int redDiff = Math.abs(((redColor >> 16) & 0xFF) - ((finalColor >> 16) & 0xFF)); + int greenDiff = Math.abs(((redColor >> 8) & 0xFF) - ((finalColor >> 8) & 0xFF)); + int blueDiff = Math.abs((redColor & 0xFF) - (finalColor & 0xFF)); + + assertTrue("Red channel should be approximately equal", redDiff <= 2); + assertTrue("Green channel should be approximately equal", greenDiff <= 2); + assertTrue("Blue channel should be approximately equal", blueDiff <= 2); + + java2d.endDraw(); + } + + @Test + public void testPixelCoordinatesInBounds() { + java2d.pixelDensity = 2; + java2d.beginDraw(); + + for (int mode : new int[]{PConstants.PIXEL_EXACT, PConstants.PIXEL_SMOOTH}) { + sketch.pixelAccessMode(mode); + + java2d.set(0, 0, 0xFFFF0000); + java2d.set(99, 0, 0xFF00FF00); + java2d.set(0, 99, 0xFF0000FF); + java2d.set(99, 99, 0xFFFFFF00); + + assertEquals("Top-left should be red", 0xFFFF0000, java2d.get(0, 0)); + assertEquals("Top-right should be green", 0xFF00FF00, java2d.get(99, 0)); + assertEquals("Bottom-left should be blue", 0xFF0000FF, java2d.get(0, 99)); + assertEquals("Bottom-right should be yellow", 0xFFFFFF00, java2d.get(99, 99)); + + assertEquals("Out of bounds should return 0", 0, java2d.get(-1, 50)); + assertEquals("Out of bounds should return 0", 0, java2d.get(50, -1)); + assertEquals("Out of bounds should return 0", 0, java2d.get(100, 50)); + assertEquals("Out of bounds should return 0", 0, java2d.get(50, 100)); + } + + java2d.endDraw(); + } + + @Test + public void testPixelArrayOperations() { + java2d.pixelDensity = 2; + java2d.beginDraw(); + java2d.background(0); + + java2d.loadPixels(); + + java2d.pixels[0] = 0xFFFF0000; // Top-left red + java2d.pixels[99] = 0xFF00FF00; // Top-right green + java2d.pixels[99 * 100] = 0xFF0000FF; // Bottom-left blue + java2d.pixels[99 * 100 + 99] = 0xFFFFFF00; // Bottom-right yellow + + java2d.updatePixels(); + + assertEquals("Array set should match get()", 0xFFFF0000, java2d.get(0, 0)); + assertEquals("Array set should match get()", 0xFF00FF00, java2d.get(99, 0)); + assertEquals("Array set should match get()", 0xFF0000FF, java2d.get(0, 99)); + assertEquals("Array set should match get()", 0xFFFFFF00, java2d.get(99, 99)); + + java2d.endDraw(); + } + + @Test + public void testOpenGLPixelDensity() { + if (opengl == null) { + System.out.println("Skipping OpenGL test - not available"); + return; + } + + opengl.pixelDensity = 2; + opengl.beginDraw(); + opengl.background(0); + + opengl.set(10, 10, 0xFFFF0000); + int color = opengl.get(10, 10); + + int red = (color >> 16) & 0xFF; + assertTrue("Red channel should be preserved", red > 200); + + opengl.endDraw(); + } + + @Test + public void testBackwardCompatibility() { + java2d.pixelDensity = 1; + java2d.beginDraw(); + + assertEquals("Logical and physical width should be equal", java2d.width, java2d.pixelWidth); + assertEquals("Logical and physical height should be equal", java2d.height, java2d.pixelHeight); + + java2d.loadPixels(); + assertEquals("Pixels array should match physical size", java2d.pixelWidth * java2d.pixelHeight, java2d.pixels.length); + + java2d.set(50, 50, 0xFFFF0000); + assertEquals("Get should return set value", 0xFFFF0000, java2d.get(50, 50)); + + java2d.endDraw(); + } +} \ No newline at end of file