From 48ccd63737d742e75d2197975046a55730f58a71 Mon Sep 17 00:00:00 2001 From: Mr Robot Date: Fri, 2 Oct 2026 12:36:46 +0200 Subject: [PATCH 1/3] Fix GLES3 packed sRGB texture transfers --- .../jme3/renderer/opengl/GLImageFormats.java | 9 +- .../com/jme3/renderer/opengl/GLRenderer.java | 4 +- .../com/jme3/renderer/opengl/TextureUtil.java | 94 ++- .../opengl/TextureUtilPackedSrgbTest.java | 650 ++++++++++++++++++ 4 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLImageFormats.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLImageFormats.java index c16c9e4e2a..55ca055bd7 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLImageFormats.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLImageFormats.java @@ -185,8 +185,13 @@ public static GLImageFormat[][] getFormatsForCaps(EnumSet caps) { // sRGB formats if (caps.contains(Caps.Srgb)) { formatSrgb(formatToGL, Format.RGB8, GLExt.GL_SRGB8_EXT, GL.GL_RGB, GL.GL_UNSIGNED_BYTE, opengl, false, true); - formatSrgb(formatToGL, Format.RGB565, GLExt.GL_SRGB8_EXT, GL.GL_RGB, GL.GL_UNSIGNED_SHORT_5_6_5, opengl, false, true); - formatSrgb(formatToGL, Format.RGB5A1, GLExt.GL_SRGB8_ALPHA8_EXT, GL.GL_RGBA, GL.GL_UNSIGNED_SHORT_5_5_5_1, opengl, false, true); + // GLES3 sRGB formats require unsigned bytes. TextureUtil expands packed source pixels. + formatSrgb(formatToGL, Format.RGB565, GLExt.GL_SRGB8_EXT, GL.GL_RGB, + opengles3 ? GL.GL_UNSIGNED_BYTE : GL.GL_UNSIGNED_SHORT_5_6_5, + opengl, false, true); + formatSrgb(formatToGL, Format.RGB5A1, GLExt.GL_SRGB8_ALPHA8_EXT, GL.GL_RGBA, + opengles3 ? GL.GL_UNSIGNED_BYTE : GL.GL_UNSIGNED_SHORT_5_5_5_1, + opengl || opengles3, false, true); formatSrgb(formatToGL, Format.RGBA8, GLExt.GL_SRGB8_ALPHA8_EXT, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, true, false, true); if (!coreProfile) { diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java index be54cb3092..fbd1f12b5d 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java @@ -3171,7 +3171,7 @@ public void modifyTexture(Texture tex, Image pixels, int x, int y) { } int target = convertTextureType(tex.getType(), pixels.getMultiSamples(), -1); texUtil.uploadSubTexture(target, pixels, 0, x, y, - 0, 0, pixels.getWidth(), pixels.getHeight(), linearizeSrgbImages); + 0, 0, pixels.getWidth(), pixels.getHeight(), linearizeSrgbImages, tex.getImage().getColorSpace()); } /** @@ -3199,7 +3199,7 @@ public void modifyTexture(Texture2D dest, Image src, int destX, int destY, } int target = convertTextureType(dest.getType(), src.getMultiSamples(), -1); texUtil.uploadSubTexture(target, src, 0, destX, destY, - srcX, srcY, areaW, areaH, linearizeSrgbImages); + srcX, srcY, areaW, areaH, linearizeSrgbImages, dest.getImage().getColorSpace()); } @Override diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java index 7e74ceb3b3..7205877ca1 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java @@ -36,7 +36,9 @@ import com.jme3.texture.Image; import com.jme3.texture.Image.Format; import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.EnumSet; import java.util.logging.Level; import java.util.logging.Logger; @@ -157,6 +159,37 @@ private void setupTextureSwizzle(int target, Format format) { } } + private static boolean requiresPackedExpansion(Format format, GLImageFormat glFormat) { + return glFormat.dataType == GL.GL_UNSIGNED_BYTE + && (format == Format.RGB565 || format == Format.RGB5A1); + } + + private static ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int pixelCount) { + // GL reads packed shorts in native byte order, regardless of the ByteBuffer's byte-order metadata. + ByteBuffer source = data.duplicate().order(ByteOrder.nativeOrder()); + boolean hasAlpha = format == Format.RGB5A1; + int components = hasAlpha ? 4 : 3; + if (pixelCount > source.remaining() / 2) { + throw new IllegalArgumentException("Packed image data is too small for the requested dimensions"); + } + ByteBuffer expanded = BufferUtils.createByteBuffer(Math.multiplyExact(pixelCount, components)); + for (int i = 0; i < pixelCount; i++) { + int pixel = source.getShort() & 0xffff; + int red = pixel >> 11; + int green = hasAlpha ? (pixel >> 6) & 0x1f : (pixel >> 5) & 0x3f; + int blue = hasAlpha ? (pixel >> 1) & 0x1f : pixel & 0x1f; + // Preserve the encoded sRGB values; the sRGB texture performs linearization when sampled. + expanded.put((byte) ((red * 255 + 15) / 31)); + expanded.put((byte) ((green * 255 + (hasAlpha ? 15 : 31)) / (hasAlpha ? 31 : 63))); + expanded.put((byte) ((blue * 255 + 15) / 31)); + if (hasAlpha) { + expanded.put((byte) ((pixel & 1) * 255)); + } + } + expanded.flip(); + return expanded; + } + private void uploadTextureLevel(GLImageFormat format, int target, int level, int slice, int sliceCount, int width, int height, int depth, int samples, ByteBuffer data) { if (format.compressed && data != null) { if (target == GL2.GL_TEXTURE_3D) { @@ -267,13 +300,17 @@ public void uploadTexture(Image image, boolean getSrgbFormat = image.getColorSpace() == ColorSpace.sRGB && linearizeSrgb; Image.Format jmeFormat = image.getFormat(); - GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); + final GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); ByteBuffer data = null; int sliceCount = 1; if (index >= 0) { - data = image.getData(index); + ByteBuffer source = image.getData(index); + if (source != null) { + data = source.duplicate(); + data.clear(); + } } if (image.getData() != null && image.getData().size() > 0) { @@ -312,7 +349,16 @@ public void uploadTexture(Image image, data.limit(pos + mipSizes[i]); } - uploadTextureLevel(oglFormat, target, i, index, sliceCount, mipWidth, mipHeight, mipDepth, samples, data); + ByteBuffer uploadData = data; + if (data != null && requiresPackedExpansion(jmeFormat, oglFormat)) { + int pixelCount = Math.multiplyExact(mipWidth, mipHeight); + if (target == GL2.GL_TEXTURE_3D) { + pixelCount = Math.multiplyExact(pixelCount, mipDepth); + } + uploadData = expandPackedPixels(data, jmeFormat, pixelCount); + } + uploadTextureLevel(oglFormat, target, i, index, sliceCount, + mipWidth, mipHeight, mipDepth, samples, uploadData); pos += mipSizes[i]; } @@ -344,12 +390,16 @@ public void uploadSubTexture(Image image, int target, int index, int x, int y, b } boolean getSrgbFormat = image.getColorSpace() == ColorSpace.sRGB && linearizeSrgb; - GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); + final GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); ByteBuffer data = null; if (index >= 0) { - data = image.getData(index); + ByteBuffer source = image.getData(index); + if (source != null) { + data = source.duplicate(); + data.clear(); + } } if (data == null) { @@ -358,12 +408,23 @@ public void uploadSubTexture(Image image, int target, int index, int x, int y, b data.position(0); data.limit(data.capacity()); + if (requiresPackedExpansion(jmeFormat, oglFormat)) { + data = expandPackedPixels(data, jmeFormat, + Math.multiplyExact(image.getWidth(), image.getHeight())); + } gl.glTexSubImage2D(target, 0, x, y, image.getWidth(), image.getHeight(), oglFormat.format, oglFormat.dataType, data); } public void uploadSubTexture(int target, Image src, int index, int targetX, int targetY, int areaX, int areaY, int areaWidth, int areaHeight, boolean linearizeSrgb) { + uploadSubTexture(target, src, index, targetX, targetY, areaX, areaY, areaWidth, areaHeight, + linearizeSrgb, src.getColorSpace()); + } + + void uploadSubTexture(int target, Image src, int index, int targetX, int targetY, + int areaX, int areaY, int areaWidth, int areaHeight, + boolean linearizeSrgb, ColorSpace destinationColorSpace) { if (target != GL.GL_TEXTURE_2D || src.getDepth() > 1) { throw new UnsupportedOperationException("Updating non-2D texture is not supported"); } @@ -384,8 +445,10 @@ public void uploadSubTexture(int target, Image src, int index, int targetX, int throw new UnsupportedOperationException("Updating depth images is not supported"); } - boolean getSrgbFormat = src.getColorSpace() == ColorSpace.sRGB && linearizeSrgb; - GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); + // The destination storage determines which pixel types GLES3 accepts, even when the + // source has a different color space. This is a raw copy, without CPU gamma conversion. + boolean getSrgbFormat = destinationColorSpace == ColorSpace.sRGB && linearizeSrgb; + final GLImageFormat oglFormat = getImageFormatWithError(jmeFormat, getSrgbFormat); ByteBuffer data = src.getData(index); @@ -393,13 +456,18 @@ public void uploadSubTexture(int target, Image src, int index, int targetX, int throw new IndexOutOfBoundsException("The image index " + index + " is not valid for the given image"); } - int Bpp = src.getFormat().getBitsPerPixel() / 8; + data = data.duplicate(); + int bytesPerPixel = src.getFormat().getBitsPerPixel() / 8; + if (requiresPackedExpansion(jmeFormat, oglFormat)) { + data.clear(); + data = expandPackedPixels(data, jmeFormat, Math.multiplyExact(src.getWidth(), src.getHeight())); + bytesPerPixel = jmeFormat == Format.RGB565 ? 3 : 4; + } int srcWidth = src.getWidth(); - int cpos = data.position(); int skip = areaX; skip += areaY * srcWidth; - skip *= Bpp; + skip *= bytesPerPixel; data.position(skip); @@ -407,8 +475,9 @@ public void uploadSubTexture(int target, Image src, int index, int targetX, int if (needsStride && (!supportUnpackRowLength)) { // doesn't support stride, copy row by row (slower). for (int i = 0; i < areaHeight; i++) { - data.position(skip + (srcWidth * Bpp * i)); - gl.glTexSubImage2D(target, 0, targetX, targetY + i, areaWidth, 1, oglFormat.format, oglFormat.dataType, data); + data.position(skip + (srcWidth * bytesPerPixel * i)); + gl.glTexSubImage2D(target, 0, targetX, targetY + i, areaWidth, 1, + oglFormat.format, oglFormat.dataType, data); } } else { if (needsStride) @@ -417,7 +486,6 @@ public void uploadSubTexture(int target, Image src, int index, int targetX, int if (needsStride) gl.glPixelStorei(GL.GL_UNPACK_ROW_LENGTH, 0); } - data.position(cpos); } diff --git a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java new file mode 100644 index 0000000000..05b06fdeaf --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java @@ -0,0 +1,650 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.renderer.opengl; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.renderer.Caps; +import com.jme3.texture.Image; +import com.jme3.texture.Image.Format; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.image.ColorSpace; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Verifies packed sRGB transfers without creating a native GL context. */ +public class TextureUtilPackedSrgbTest { + + private static final Format[] PACKED_FORMATS = {Format.RGB565, Format.RGB5A1}; + + @Test + public void testGles3SrgbFormatCapabilities() { + TextureUtil util = new RecordingGl().textureUtil(true, true); + GLImageFormat rgb = util.getImageFormat(Format.RGB565, true); + GLImageFormat rgba = util.getImageFormat(Format.RGB5A1, true); + assertEquals(GLExt.GL_SRGB8_EXT, rgb.internalFormat); + assertEquals(GLExt.GL_SRGB8_ALPHA8_EXT, rgba.internalFormat); + assertEquals(GL.GL_RGB, rgb.format); + assertEquals(GL.GL_RGBA, rgba.format); + assertEquals(GL.GL_UNSIGNED_BYTE, rgb.dataType); + assertEquals(GL.GL_UNSIGNED_BYTE, rgba.dataType); + assertFalse(rgb.colorRenderable); + assertTrue(rgba.colorRenderable); + assertTrue(rgb.filterable); + assertTrue(rgba.filterable); + } + + @Test + public void testRendererUploadsEncodedRgb565Channels() { + RecordingGl gl = new RecordingGl(); + final GLRenderer renderer = gl.renderer(); + ByteBuffer data = packed(0x0000, 0xffff, 0xf800, 0x07e0, 0x001f, 0x1863, 0x8410, 0x39e7); + Image image = image(Format.RGB565, 4, 2, data); + renderer.updateTexImageData(image, Texture.Type.TwoDimensional, 0, false); + Upload upload = gl.uploads.get(0); + assertSrgb(upload, Format.RGB565); + assertArrayEquals(bytes(0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 0, + 0, 0, 255, 25, 12, 25, 132, 130, 132, 58, 61, 58), upload.data); + assertTrue(upload.direct); + } + + @Test + public void testRendererUploadsEncodedRgb5a1ChannelsAndAlpha() { + RecordingGl gl = new RecordingGl(); + final GLRenderer renderer = gl.renderer(); + ByteBuffer data = packed(0x0000, 0xffff, 0xf801, 0x07c0, 0x003f, 0x18c6, 0x8421, 0x39ce); + renderer.updateTexImageData(image(Format.RGB5A1, 4, 2, data), + Texture.Type.TwoDimensional, 0, false); + Upload upload = gl.uploads.get(0); + assertSrgb(upload, Format.RGB5A1); + assertArrayEquals(bytes(0, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, + 0, 255, 0, 0, 0, 0, 255, 255, 25, 25, 25, 0, + 132, 132, 132, 255, 58, 58, 58, 0), upload.data); + } + + @Test + public void testAllPackedValuesUseNormalizedEncodedChannels() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = ByteBuffer.allocateDirect(65536 * 2).order(ByteOrder.nativeOrder()); + byte[] expected = new byte[65536 * components(format)]; + int offset = 0; + for (int value = 0; value <= 0xffff; value++) { + source.putShort((short) value); + expected[offset++] = (byte) Math.round((value >>> 11) * 255.0 / 31); + int green = format == Format.RGB565 ? (value >>> 5) & 63 : (value >>> 6) & 31; + expected[offset++] = (byte) Math.round(green * 255.0 / (format == Format.RGB565 ? 63 : 31)); + int blue = format == Format.RGB565 ? value & 31 : (value >>> 1) & 31; + expected[offset++] = (byte) Math.round(blue * 255.0 / 31); + if (format == Format.RGB5A1) { + expected[offset++] = (byte) ((value & 1) == 0 ? 0 : 255); + } + } + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 256, 256, source), + GL.GL_TEXTURE_2D, 0, true); + assertArrayEquals(expected, gl.uploads.get(0).data); + } + } + + @Test + public void testNativePackedBytesIgnoreBufferOrderMetadata() { + for (Format format : PACKED_FORMATS) { + for (ByteOrder order : new ByteOrder[]{ByteOrder.BIG_ENDIAN, ByteOrder.LITTLE_ENDIAN}) { + ByteBuffer source = packed(0xf800, 0x001f).order(order); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 2, 1, source), + GL.GL_TEXTURE_2D, 0, true); + assertArrayEquals(format == Format.RGB565 ? bytes(255, 0, 0, 0, 0, 255) + : bytes(255, 0, 0, 0, 0, 0, 123, 255), gl.uploads.get(0).data); + state.assertUnchanged(source); + } + } + } + + @Test + public void testMipLevelsPreserveImageAndSourceState() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = packed(0, 0, 0, 0, 0xffff, 0xffff, 0xffff, 0xffff, 0xf800, 0xf800, 0); + source.position(3).mark().limit(7); + final SourceState state = new SourceState(source); + Image image = image(format, 4, 2, source); + int[] mipSizes = {16, 4, 2}; + image.setMipMapSizes(mipSizes); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image, GL.GL_TEXTURE_2D, 0, true); + assertEquals(3, gl.uploads.size()); + for (int level = 0; level < 3; level++) { + Upload upload = gl.uploads.get(level); + assertSrgb(upload, format); + assertEquals(level, upload.level); + assertEquals(Math.max(1, 4 >> level), upload.width); + assertEquals(Math.max(1, 2 >> level), upload.height); + assertEquals(mipSizes[level] / 2 * components(format), upload.data.length); + } + byte[] red = format == Format.RGB565 ? bytes(255, 0, 0, 255, 0, 0) + : bytes(255, 0, 0, 0, 255, 0, 0, 0); + assertArrayEquals(red, gl.uploads.get(1).data); + assertArrayEquals(new byte[components(format)], gl.uploads.get(2).data); + assertSame(source, image.getData(0)); + assertSame(mipSizes, image.getMipMapSizes()); + assertArrayEquals(new int[]{16, 4, 2}, mipSizes); + assertEquals(format, image.getFormat()); + assertEquals(ColorSpace.sRGB, image.getColorSpace()); + state.assertUnchanged(source); + source.reset(); + assertEquals(3, source.position()); + } + } + + @Test + public void testReadOnlySourceAndCubemapFace() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = packed(0xffff).asReadOnlyBuffer(); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 1, 1, source), + GL.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, true); + assertEquals(GL.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, gl.uploads.get(0).target); + assertArrayEquals(white(format, 1), gl.uploads.get(0).data); + } + } + + @Test + public void testVolumeMipLevels() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = packed(0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0); + Image image = new Image(format, 2, 2, 2, new ArrayList<>(Arrays.asList(source)), ColorSpace.sRGB); + image.setMipMapSizes(new int[]{16, 2}); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image, GL2.GL_TEXTURE_3D, 0, true); + assertEquals(2, gl.uploads.size()); + assertEquals(2, gl.uploads.get(0).depth); + assertArrayEquals(white(format, 8), gl.uploads.get(0).data); + assertEquals(1, gl.uploads.get(1).depth); + assertArrayEquals(new byte[components(format)], gl.uploads.get(1).data); + } + } + + @Test + public void testArrayAllocationAndSlices() { + for (Format format : PACKED_FORMATS) { + Image image = new Image(format, 2, 1, 0, + new ArrayList<>(Arrays.asList(packed(0, 0), packed(0xffff, 0xffff))), ColorSpace.sRGB); + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); + util.uploadTexture(image, GLExt.GL_TEXTURE_2D_ARRAY_EXT, -1, true); + util.uploadTexture(image, GLExt.GL_TEXTURE_2D_ARRAY_EXT, 0, true); + util.uploadTexture(image, GLExt.GL_TEXTURE_2D_ARRAY_EXT, 1, true); + Upload allocation = gl.uploads.get(0); + assertSrgb(allocation, format); + assertNull(allocation.data); + assertEquals(2, allocation.depth); + assertEquals(0, gl.uploads.get(1).offsetZ); + assertArrayEquals(new byte[2 * components(format)], gl.uploads.get(1).data); + assertEquals(1, gl.uploads.get(2).offsetZ); + assertArrayEquals(white(format, 2), gl.uploads.get(2).data); + } + } + + @Test + public void testNullTextureAllocation() { + for (Format format : PACKED_FORMATS) { + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 3, 2, null), GL.GL_TEXTURE_2D, 0, true); + assertSrgb(gl.uploads.get(0), format); + assertNull(gl.uploads.get(0).data); + } + } + + @Test + public void testDeprecatedWholeSubImage() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = packed(0xffff, 0xffff); + source.position(1).mark().limit(3); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadSubTexture(image(format, 2, 1, source), + GL.GL_TEXTURE_2D, 0, 7, 9, true); + Upload upload = gl.uploads.get(0); + assertEquals(GL.GL_UNSIGNED_BYTE, upload.type); + assertEquals(7, upload.offsetX); + assertEquals(9, upload.offsetY); + assertArrayEquals(white(format, 2), upload.data); + state.assertUnchanged(source); + source.reset(); + } + } + + @Test + public void testCroppedSubImageWithAndWithoutRowLength() { + for (Format format : PACKED_FORMATS) { + for (boolean rowLength : new boolean[]{false, true}) { + ByteBuffer source = packed(0, 0, 0, 0, 0xffff, 0xf800, 0, 0, 0xffff); + source.position(2).mark().limit(16); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, rowLength).uploadSubTexture(GL.GL_TEXTURE_2D, + image(format, 3, 3, source), 0, 7, 9, 1, 1, 2, 2, true); + byte[] expected = format == Format.RGB565 + ? bytes(255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 255, 255) + : bytes(255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255); + assertEquals(rowLength ? 1 : 2, gl.uploads.size()); + if (rowLength) { + assertArrayEquals(expected, gl.uploads.get(0).data); + } else { + assertArrayEquals(Arrays.copyOfRange(expected, 0, expected.length / 2), + gl.uploads.get(0).data); + assertArrayEquals(Arrays.copyOfRange(expected, expected.length / 2, expected.length), + gl.uploads.get(1).data); + assertEquals(10, gl.uploads.get(1).offsetY); + } + assertEquals(0, gl.rowLength); + assertEquals(7, gl.uploads.get(0).offsetX); + assertEquals(9, gl.uploads.get(0).offsetY); + state.assertUnchanged(source); + source.reset(); + } + } + } + + @Test + public void testRendererSubImageTransfersAcrossColorSpaces() { + for (Format format : PACKED_FORMATS) { + for (ColorSpace sourceSpace : ColorSpace.values()) { + for (ColorSpace destinationSpace : ColorSpace.values()) { + for (boolean deprecated : new boolean[]{false, true}) { + RecordingGl gl = new RecordingGl(); + final GLRenderer renderer = gl.renderer(); + Image destination = image(format, 2, 1, packed(0, 0)); + destination.setColorSpace(destinationSpace); + Texture2D texture = new Texture2D(destination); + ByteBuffer sourceData = packed(0x1863, 0xf801); + sourceData.position(1).mark(); + final SourceState state = new SourceState(sourceData); + Image source = image(format, 2, 1, sourceData); + source.setColorSpace(sourceSpace); + if (deprecated) { + renderer.modifyTexture(texture, source, 0, 0); + } else { + renderer.modifyTexture(texture, source, 0, 0, 0, 0, 2, 1); + } + assertEquals(2, gl.uploads.size()); + Upload definition = gl.uploads.get(0); + assertEquals(destinationSpace == ColorSpace.sRGB ? srgbInternal(format) + : format == Format.RGB565 ? GL.GL_RGB565 : GL.GL_RGB5_A1, + definition.internal); + Upload update = gl.uploads.get(1); + if (destinationSpace == ColorSpace.sRGB) { + assertEquals(GL.GL_UNSIGNED_BYTE, update.type); + assertArrayEquals(format == Format.RGB565 ? bytes(25, 12, 25, 255, 0, 8) + : bytes(25, 8, 140, 255, 255, 0, 0, 255), update.data); + } else { + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, update.type); + assertArrayEquals(rawBytes(sourceData), update.data); + } + assertEquals(format == Format.RGB565 ? GL.GL_RGB : GL.GL_RGBA, update.format); + state.assertUnchanged(sourceData); + sourceData.reset(); + } + } + } + } + } + + @Test + public void testDesktopSubImagesKeepPackedTransfers() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = packed(0x1863, 0xf801); + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(false, true); + Image image = image(format, 2, 1, source); + util.uploadSubTexture(image, GL.GL_TEXTURE_2D, 0, 0, 0, true); + util.uploadSubTexture(GL.GL_TEXTURE_2D, image, 0, 0, 0, 0, 0, 2, 1, true); + for (Upload upload : gl.uploads) { + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, upload.type); + assertArrayEquals(rawBytes(source), upload.data); + } + } + } + + @Test + public void testDesktopLinearAndDisabledLinearizationKeepPackedTransfers() { + for (Format format : PACKED_FORMATS) { + for (int control = 0; control < 3; control++) { + ByteBuffer source = packed(0x1863, 0xf801); + Image image = image(format, 2, 1, source); + if (control == 1) { + image.setColorSpace(ColorSpace.Linear); + } + RecordingGl gl = new RecordingGl(); + gl.textureUtil(control != 0, true).uploadTexture(image, GL.GL_TEXTURE_2D, 0, control != 2); + Upload upload = gl.uploads.get(0); + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, upload.type); + assertArrayEquals(rawBytes(source), upload.data); + assertEquals(control == 0 ? srgbInternal(format) + : format == Format.RGB565 ? GL.GL_RGB565 : GL.GL_RGB5_A1, upload.internal); + } + } + } + + @Test + public void testOrdinarySrgbBytesRemainUnchanged() { + for (Format format : new Format[]{Format.RGB8, Format.RGBA8}) { + byte[] expected = format == Format.RGB8 ? bytes(12, 34, 56) : bytes(12, 34, 56, 78); + ByteBuffer source = ByteBuffer.allocateDirect(expected.length); + source.put(expected); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 1, 1, source), GL.GL_TEXTURE_2D, 0, true); + assertEquals(GL.GL_UNSIGNED_BYTE, gl.uploads.get(0).type); + assertArrayEquals(expected, gl.uploads.get(0).data); + } + } + + @Test + public void testSurplusCapacityIsNotConverted() { + for (Format format : PACKED_FORMATS) { + ByteBuffer source = ByteBuffer.allocateDirect(3).order(ByteOrder.nativeOrder()); + source.putShort((short) 0xffff).put((byte) 123); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(true, true).uploadTexture(image(format, 1, 1, source), GL.GL_TEXTURE_2D, 0, true); + assertArrayEquals(white(format, 1), gl.uploads.get(0).data); + state.assertUnchanged(source); + } + } + + @Test + public void testGles3WithoutCoreProfileAndWithoutSrgb() { + for (Format format : PACKED_FORMATS) { + for (boolean srgb : new boolean[]{false, true}) { + EnumSet caps = EnumSet.of(Caps.OpenGLES20, Caps.OpenGLES30); + if (srgb) { + caps.add(Caps.Srgb); + } + ByteBuffer source = packed(0xffff); + RecordingGl gl = new RecordingGl(); + gl.textureUtil(caps).uploadTexture(image(format, 1, 1, source), GL.GL_TEXTURE_2D, 0, true); + Upload upload = gl.uploads.get(0); + if (srgb) { + assertSrgb(upload, format); + assertArrayEquals(white(format, 1), upload.data); + } else { + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, upload.type); + assertArrayEquals(rawBytes(source), upload.data); + } + } + } + } + + @Test + public void testIncompletePackedPixelFailsWithoutChangingSource() { + ByteBuffer source = ByteBuffer.allocateDirect(1); + source.position(1).mark(); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); + assertThrows(IllegalArgumentException.class, + () -> util.uploadTexture(image(Format.RGB565, 1, 1, source), GL.GL_TEXTURE_2D, 0, true)); + assertTrue(gl.uploads.isEmpty()); + state.assertUnchanged(source); + source.reset(); + } + + private static Image image(Format format, int width, int height, ByteBuffer source) { + return new Image(format, width, height, source, ColorSpace.sRGB); + } + + private static ByteBuffer packed(int... values) { + ByteBuffer result = ByteBuffer.allocateDirect(values.length * 2).order(ByteOrder.nativeOrder()); + for (int value : values) { + result.putShort((short) value); + } + result.flip(); + return result; + } + + private static int components(Format format) { + return format == Format.RGB565 ? 3 : 4; + } + + private static int srgbInternal(Format format) { + return format == Format.RGB565 ? GLExt.GL_SRGB8_EXT : GLExt.GL_SRGB8_ALPHA8_EXT; + } + + private static void assertSrgb(Upload upload, Format format) { + assertEquals(srgbInternal(format), upload.internal); + assertEquals(format == Format.RGB565 ? GL.GL_RGB : GL.GL_RGBA, upload.format); + assertEquals(GL.GL_UNSIGNED_BYTE, upload.type); + } + + private static byte[] bytes(int... values) { + byte[] result = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + result[i] = (byte) values[i]; + } + return result; + } + + private static byte[] rawBytes(ByteBuffer source) { + ByteBuffer copy = source.duplicate(); + copy.clear(); + byte[] result = new byte[copy.remaining()]; + copy.get(result); + return result; + } + + private static byte[] white(Format format, int count) { + byte[] result = new byte[components(format) * count]; + Arrays.fill(result, (byte) 255); + return result; + } + + private static final class SourceState { + private final int position; + private final int limit; + private final ByteOrder order; + private final byte[] data; + + private SourceState(ByteBuffer source) { + position = source.position(); + limit = source.limit(); + order = source.order(); + data = rawBytes(source); + } + + private void assertUnchanged(ByteBuffer source) { + assertEquals(position, source.position()); + assertEquals(limit, source.limit()); + assertEquals(order, source.order()); + assertArrayEquals(data, rawBytes(source)); + } + } + + private static final class Upload { + private int target; + private int level; + private int internal; + private int format; + private int type; + private int offsetX; + private int offsetY; + private int offsetZ; + private int width; + private int height; + private int depth = 1; + private boolean direct; + private byte[] data; + } + + private static final class RecordingGl implements InvocationHandler { + private final List uploads = new ArrayList<>(); + private int rowLength; + + private T proxy(Class type) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, this)); + } + + private TextureUtil textureUtil(boolean gles, boolean unpackRowLength) { + EnumSet caps = gles + ? EnumSet.of(Caps.OpenGLES20, Caps.OpenGLES30, Caps.CoreProfile, Caps.Srgb) + : EnumSet.of(Caps.OpenGL20, Caps.OpenGL30, Caps.CoreProfile, Caps.Srgb); + if (unpackRowLength) { + caps.add(Caps.UnpackRowLength); + } + return textureUtil(caps); + } + + private TextureUtil textureUtil(EnumSet caps) { + TextureUtil util = new TextureUtil(proxy(GL.class), proxy(GL2.class), proxy(GLExt.class)); + util.initialize(caps); + return util; + } + + private GLRenderer renderer() { + final GLRenderer renderer = new GLRenderer(proxy(GLES_30.class), + proxy(GLExt.class), proxy(GLFbo.class)); + renderer.initialize(); + assertTrue(renderer.getCaps().contains(Caps.OpenGLES30)); + assertTrue(renderer.getCaps().contains(Caps.Srgb)); + renderer.setLinearizeSrgbImages(true); + return renderer; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "glGetString": + int name = (Integer) args[0]; + if (name == GL.GL_VERSION) { + return "OpenGL ES 3.0"; + } + if (name == GL.GL_SHADING_LANGUAGE_VERSION) { + return "OpenGL ES GLSL ES 3.00"; + } + if (name == GL.GL_EXTENSIONS) { + return ""; + } + return "Packed-sRGB recording GL"; + case "glGetInteger": + int parameter = (Integer) args[0]; + int value = parameter == GL3.GL_NUM_EXTENSIONS || parameter == GL.GL_FRAMEBUFFER_BINDING + || parameter == GLExt.GL_SAMPLE_BUFFERS_ARB || parameter == GLExt.GL_SAMPLES_ARB + ? 0 : 16; + ((IntBuffer) args[1]).put(0, value); + return null; + case "glGenTextures": + case "glGenVertexArrays": + ((IntBuffer) args[0]).put(0, 1); + return null; + case "glPixelStorei": + if ((Integer) args[0] == GL.GL_UNPACK_ROW_LENGTH) { + rowLength = (Integer) args[1]; + } + return null; + case "glTexImage2D": + case "glTexImage3D": + case "glTexSubImage2D": + case "glTexSubImage3D": + record(method.getName(), args); + return null; + case "glIsEnabled": + case "supportsGpuTimerQuery": + return false; + default: + if (method.getReturnType() == void.class) { + return null; + } + throw new AssertionError("Unexpected GL call: " + method); + } + } + + private void record(String method, Object[] args) { + Upload upload = new Upload(); + upload.target = (Integer) args[0]; + upload.level = (Integer) args[1]; + boolean subImage = method.contains("Sub"); + boolean volume = method.endsWith("3D"); + int dimensionIndex; + if (subImage) { + upload.offsetX = (Integer) args[2]; + upload.offsetY = (Integer) args[3]; + if (volume) { + upload.offsetZ = (Integer) args[4]; + } + dimensionIndex = volume ? 5 : 4; + } else { + upload.internal = (Integer) args[2]; + dimensionIndex = 3; + } + upload.width = (Integer) args[dimensionIndex]; + upload.height = (Integer) args[dimensionIndex + 1]; + if (volume) { + upload.depth = (Integer) args[dimensionIndex + 2]; + } + upload.format = (Integer) args[args.length - 3]; + upload.type = (Integer) args[args.length - 2]; + ByteBuffer source = (ByteBuffer) args[args.length - 1]; + if (source != null) { + upload.direct = source.isDirect(); + int bpp = upload.type == GL.GL_UNSIGNED_BYTE ? (upload.format == GL.GL_RGB ? 3 : 4) : 2; + int stride = rowLength == 0 ? upload.width : rowLength; + upload.data = new byte[upload.width * upload.height * upload.depth * bpp]; + ByteBuffer copy = source.duplicate(); + for (int row = 0; row < upload.height * upload.depth; row++) { + copy.position(source.position() + row * stride * bpp); + copy.get(upload.data, row * upload.width * bpp, upload.width * bpp); + } + } + uploads.add(upload); + } + } +} From b2d7a5bc78d5d1388e3e8dc9444b3f2d848538cf Mon Sep 17 00:00:00 2001 From: Mr Robot Date: Fri, 2 Oct 2026 14:35:37 +0200 Subject: [PATCH 2/3] Reuse packed texture conversion scratch and verify buffer state --- .../com/jme3/renderer/opengl/GLRenderer.java | 1 + .../com/jme3/renderer/opengl/TextureUtil.java | 21 ++- .../opengl/TextureUtilPackedSrgbTest.java | 176 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java index fbd1f12b5d..b3338983b2 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java @@ -920,6 +920,7 @@ public void resetGLObjects() { @Override public void cleanup() { logger.log(Level.FINE, "Deleting objects and invalidating state"); + texUtil.cleanup(); objManager.deleteAllObjects(this); statistics.clearMemory(); invalidateState(); diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java index 7205877ca1..97d4038aa2 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java @@ -57,6 +57,7 @@ public final class TextureUtil { private final GLExt glext; private GLImageFormat[][] formats; private boolean supportUnpackRowLength; + private ByteBuffer packedPixelBuffer; public TextureUtil(GL gl, GL2 gl2, GLExt glext) { this.gl = gl; @@ -164,7 +165,7 @@ private static boolean requiresPackedExpansion(Format format, GLImageFormat glFo && (format == Format.RGB565 || format == Format.RGB5A1); } - private static ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int pixelCount) { + private ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int pixelCount) { // GL reads packed shorts in native byte order, regardless of the ByteBuffer's byte-order metadata. ByteBuffer source = data.duplicate().order(ByteOrder.nativeOrder()); boolean hasAlpha = format == Format.RGB5A1; @@ -172,7 +173,14 @@ private static ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int if (pixelCount > source.remaining() / 2) { throw new IllegalArgumentException("Packed image data is too small for the requested dimensions"); } - ByteBuffer expanded = BufferUtils.createByteBuffer(Math.multiplyExact(pixelCount, components)); + int requiredBytes = Math.multiplyExact(pixelCount, components); + if (packedPixelBuffer == null || packedPixelBuffer.capacity() < requiredBytes) { + cleanup(); + packedPixelBuffer = BufferUtils.createByteBuffer(requiredBytes); + } + // Client-memory pixels have been consumed when the preceding GL upload returns. + ByteBuffer expanded = packedPixelBuffer; + expanded.clear(); for (int i = 0; i < pixelCount; i++) { int pixel = source.getShort() & 0xffff; int red = pixel >> 11; @@ -190,6 +198,15 @@ private static ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int return expanded; } + /** Releases the owned CPU conversion scratch buffer; repeated calls are safe. */ + void cleanup() { + ByteBuffer buffer = packedPixelBuffer; + packedPixelBuffer = null; + if (buffer != null) { + BufferUtils.destroyDirectBuffer(buffer); + } + } + private void uploadTextureLevel(GLImageFormat format, int target, int level, int slice, int sliceCount, int width, int height, int depth, int samples, ByteBuffer data) { if (format.compressed && data != null) { if (target == GL2.GL_TEXTURE_3D) { diff --git a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java index 05b06fdeaf..cb7e535cca 100644 --- a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java +++ b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java @@ -34,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -354,6 +355,84 @@ public void testDesktopSubImagesKeepPackedTransfers() { } } + @Test + public void testUnconvertedSubImagesPreserveCallerStateWhenDriverConsumesData() { + for (Format format : PACKED_FORMATS) { + for (boolean gles : new boolean[]{false, true}) { + for (boolean deprecated : new boolean[]{false, true}) { + for (boolean fail : new boolean[]{false, true}) { + ByteBuffer source = packed(0x1863, 0xf801, 0x7bee, 0x7bef).asReadOnlyBuffer(); + source.position(1).mark().position(2); + final SourceState state = new SourceState(source); + RecordingGl gl = new RecordingGl(); + gl.consumeUploadBuffer = true; + gl.failUpload = fail; + TextureUtil util = gl.textureUtil(gles, true); + Image image = image(format, 2, 2, source); + image.setColorSpace(ColorSpace.Linear); + Runnable upload = () -> { + if (deprecated) { + util.uploadSubTexture(image, GL.GL_TEXTURE_2D, 0, 0, 0, true); + } else { + util.uploadSubTexture(GL.GL_TEXTURE_2D, image, 0, 0, 0, 0, 0, 2, 2, true); + } + }; + if (fail) { + assertThrows(IllegalStateException.class, upload::run); + } else { + upload.run(); + } + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, gl.uploads.get(0).type); + state.assertUnchanged(source); + source.reset(); + assertEquals(1, source.position()); + } + } + } + } + } + + @Test + public void testRendererSubImageTransfersWithUnknownColorSpaces() { + for (Format format : PACKED_FORMATS) { + for (ColorSpace sourceSpace : new ColorSpace[]{null, ColorSpace.Linear, ColorSpace.sRGB}) { + for (ColorSpace destinationSpace : new ColorSpace[]{null, ColorSpace.sRGB}) { + for (boolean deprecated : new boolean[]{false, true}) { + RecordingGl gl = new RecordingGl(); + final GLRenderer renderer = gl.renderer(); + Image destination = image(format, 2, 1, packed(0, 0)); + destination.setColorSpace(destinationSpace); + Texture2D texture = new Texture2D(destination); + ByteBuffer sourceData = packed(0x1863, 0xf801); + sourceData.position(1).mark(); + final SourceState state = new SourceState(sourceData); + Image source = image(format, 2, 1, sourceData); + source.setColorSpace(sourceSpace); + if (deprecated) { + renderer.modifyTexture(texture, source, 0, 0); + } else { + renderer.modifyTexture(texture, source, 0, 0, 0, 0, 2, 1); + } + Upload update = gl.uploads.get(1); + if (destinationSpace == ColorSpace.sRGB) { + assertEquals(GL.GL_UNSIGNED_BYTE, update.type); + assertArrayEquals(format == Format.RGB565 ? bytes(25, 12, 25, 255, 0, 8) + : bytes(25, 8, 140, 255, 255, 0, 0, 255), update.data); + } else { + assertEquals(format == Format.RGB565 ? GL.GL_UNSIGNED_SHORT_5_6_5 + : GL.GL_UNSIGNED_SHORT_5_5_5_1, update.type); + assertArrayEquals(rawBytes(sourceData), update.data); + } + state.assertUnchanged(sourceData); + sourceData.reset(); + assertEquals(1, sourceData.position()); + } + } + } + } + } + @Test public void testDesktopLinearAndDisabledLinearizationKeepPackedTransfers() { for (Format format : PACKED_FORMATS) { @@ -425,6 +504,86 @@ public void testGles3WithoutCoreProfileAndWithoutSrgb() { } } + @Test + public void testPackedScratchIsReusedAcrossLevelsSlicesAndSubImages() { + for (Format format : PACKED_FORMATS) { + RecordingGl gl = new RecordingGl(); + gl.consumeUploadBuffer = true; + TextureUtil util = gl.textureUtil(true, true); + ByteBuffer source = packed(0, 0, 0, 0, 0xffff); + Image mipImage = image(format, 2, 2, source); + mipImage.setMipMapSizes(new int[]{8, 2}); + util.uploadTexture(mipImage, GL.GL_TEXTURE_CUBE_MAP_POSITIVE_X, 0, true); + ByteBuffer scratch = gl.uploads.get(0).buffer; + assertSame(scratch, gl.uploads.get(1).buffer); + assertEquals(0, gl.uploads.get(1).bufferPosition); + assertEquals(components(format), gl.uploads.get(1).bufferLimit); + assertArrayEquals(white(format, 1), gl.uploads.get(1).data); + util.uploadTexture(mipImage, GL.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, true); + + Image layers = new Image(format, 1, 1, 0, + new ArrayList<>(Arrays.asList(packed(0), packed(0xffff))), ColorSpace.sRGB); + util.uploadTexture(layers, GLExt.GL_TEXTURE_2D_ARRAY_EXT, 0, true); + util.uploadTexture(layers, GLExt.GL_TEXTURE_2D_ARRAY_EXT, 1, true); + Image smaller = image(format, 2, 1, packed(0xffff, 0)); + util.uploadSubTexture(smaller, GL.GL_TEXTURE_2D, 0, 0, 0, true); + util.uploadSubTexture(GL.GL_TEXTURE_2D, smaller, 0, 0, 0, 1, 0, 1, 1, true); + for (Upload upload : gl.uploads) { + assertSame(scratch, upload.buffer); + assertEquals(4 * components(format), upload.bufferCapacity); + } + assertEquals(2 * components(format), gl.uploads.get(7).bufferLimit); + assertArrayEquals(new byte[components(format)], gl.uploads.get(7).data); + util.uploadTexture(image(format, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); + assertSame(scratch, gl.uploads.get(8).buffer); + assertEquals(0, gl.uploads.get(8).bufferPosition); + assertEquals(components(format), gl.uploads.get(8).bufferLimit); + assertArrayEquals(white(format, 1), gl.uploads.get(8).data); + util.cleanup(); + } + } + + @Test + public void testPackedScratchGrowsThenReusesCapacityUntilCleanup() { + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); + util.uploadTexture(image(Format.RGB565, 1, 1, packed(0)), GL.GL_TEXTURE_2D, 0, true); + final ByteBuffer first = gl.uploads.get(0).buffer; + util.uploadTexture(image(Format.RGB5A1, 2, 2, packed(0xffff, 0xffff, 0xffff, 0xffff)), + GL.GL_TEXTURE_2D, 0, true); + ByteBuffer larger = gl.uploads.get(1).buffer; + assertNotSame(first, larger); + assertEquals(16, gl.uploads.get(1).bufferCapacity); + util.uploadTexture(image(Format.RGB565, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); + assertSame(larger, gl.uploads.get(2).buffer); + assertEquals(0, gl.uploads.get(2).bufferPosition); + assertEquals(3, gl.uploads.get(2).bufferLimit); + assertArrayEquals(bytes(255, 255, 255), gl.uploads.get(2).data); + util.cleanup(); + util.cleanup(); + util.uploadTexture(image(Format.RGB5A1, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); + assertNotSame(larger, gl.uploads.get(3).buffer); + assertEquals(4, gl.uploads.get(3).bufferCapacity); + assertArrayEquals(white(Format.RGB5A1, 1), gl.uploads.get(3).data); + util.cleanup(); + } + + @Test + public void testRendererCleanupReleasesConversionScratch() { + RecordingGl gl = new RecordingGl(); + GLRenderer renderer = gl.renderer(); + renderer.updateTexImageData(image(Format.RGB565, 1, 1, packed(0xffff)), + Texture.Type.TwoDimensional, 0, false); + final ByteBuffer first = gl.uploads.get(0).buffer; + renderer.cleanup(); + renderer.cleanup(); + renderer.updateTexImageData(image(Format.RGB565, 1, 1, packed(0xffff)), + Texture.Type.TwoDimensional, 0, false); + assertNotSame(first, gl.uploads.get(1).buffer); + assertArrayEquals(bytes(255, 255, 255), gl.uploads.get(1).data); + renderer.cleanup(); + } + @Test public void testIncompletePackedPixelFailsWithoutChangingSource() { ByteBuffer source = ByteBuffer.allocateDirect(1); @@ -523,11 +682,17 @@ private static final class Upload { private int depth = 1; private boolean direct; private byte[] data; + private ByteBuffer buffer; + private int bufferPosition; + private int bufferLimit; + private int bufferCapacity; } private static final class RecordingGl implements InvocationHandler { private final List uploads = new ArrayList<>(); private int rowLength; + private boolean consumeUploadBuffer; + private boolean failUpload; private T proxy(Class type) { return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, this)); @@ -595,6 +760,13 @@ public Object invoke(Object proxy, Method method, Object[] args) { case "glTexSubImage2D": case "glTexSubImage3D": record(method.getName(), args); + ByteBuffer source = (ByteBuffer) args[args.length - 1]; + if (consumeUploadBuffer && source != null) { + source.position(source.limit()); + } + if (failUpload) { + throw new IllegalStateException("Simulated upload failure"); + } return null; case "glIsEnabled": case "supportsGpuTimerQuery": @@ -635,6 +807,10 @@ private void record(String method, Object[] args) { ByteBuffer source = (ByteBuffer) args[args.length - 1]; if (source != null) { upload.direct = source.isDirect(); + upload.buffer = source; + upload.bufferPosition = source.position(); + upload.bufferLimit = source.limit(); + upload.bufferCapacity = source.capacity(); int bpp = upload.type == GL.GL_UNSIGNED_BYTE ? (upload.format == GL.GL_RGB ? 3 : 4) : 2; int stride = rowLength == 0 ? upload.width : rowLength; upload.data = new byte[upload.width * upload.height * upload.depth * bpp]; From 9d541c8d8a7658f2f65ff6a0449dc085d79b227c Mon Sep 17 00:00:00 2001 From: Mr Robot Date: Sat, 3 Oct 2026 22:42:34 +0200 Subject: [PATCH 3/3] Bound packed texture scratch and convert only cropped pixels --- .../com/jme3/renderer/opengl/TextureUtil.java | 131 ++++++--- .../opengl/TextureUtilPackedSrgbTest.java | 273 ++++++++++++++++-- 2 files changed, 346 insertions(+), 58 deletions(-) diff --git a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java index 97d4038aa2..8ad7e6be80 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java +++ b/jme3-core/src/main/java/com/jme3/renderer/opengl/TextureUtil.java @@ -50,6 +50,8 @@ */ public final class TextureUtil { + private static final int PACKED_PIXEL_BUFFER_SIZE = 32 * 1024 * 1024; + private static final Logger logger = Logger.getLogger(TextureUtil.class.getName()); private final GL gl; @@ -166,36 +168,58 @@ private static boolean requiresPackedExpansion(Format format, GLImageFormat glFo } private ByteBuffer expandPackedPixels(ByteBuffer data, Format format, int pixelCount) { + return expandPackedPixels(data, format, pixelCount, 1, pixelCount); + } + + private ByteBuffer expandPackedPixels(ByteBuffer data, Format format, + int width, int height, int rowLength) { // GL reads packed shorts in native byte order, regardless of the ByteBuffer's byte-order metadata. ByteBuffer source = data.duplicate().order(ByteOrder.nativeOrder()); boolean hasAlpha = format == Format.RGB5A1; int components = hasAlpha ? 4 : 3; - if (pixelCount > source.remaining() / 2) { + long sourcePixels = width == 0 || height == 0 ? 0 : (long) (height - 1) * rowLength + width; + if (width < 0 || height < 0 || rowLength < width || sourcePixels > source.remaining() / 2) { throw new IllegalArgumentException("Packed image data is too small for the requested dimensions"); } - int requiredBytes = Math.multiplyExact(pixelCount, components); - if (packedPixelBuffer == null || packedPixelBuffer.capacity() < requiredBytes) { - cleanup(); - packedPixelBuffer = BufferUtils.createByteBuffer(requiredBytes); + int requiredBytes = Math.multiplyExact(Math.multiplyExact(width, height), components); + if (packedPixelBuffer == null) { + packedPixelBuffer = BufferUtils.createByteBuffer(PACKED_PIXEL_BUFFER_SIZE); } - // Client-memory pixels have been consumed when the preceding GL upload returns. - ByteBuffer expanded = packedPixelBuffer; - expanded.clear(); - for (int i = 0; i < pixelCount; i++) { - int pixel = source.getShort() & 0xffff; - int red = pixel >> 11; - int green = hasAlpha ? (pixel >> 6) & 0x1f : (pixel >> 5) & 0x3f; - int blue = hasAlpha ? (pixel >> 1) & 0x1f : pixel & 0x1f; - // Preserve the encoded sRGB values; the sRGB texture performs linearization when sampled. - expanded.put((byte) ((red * 255 + 15) / 31)); - expanded.put((byte) ((green * 255 + (hasAlpha ? 15 : 31)) / (hasAlpha ? 31 : 63))); - expanded.put((byte) ((blue * 255 + 15) / 31)); - if (hasAlpha) { - expanded.put((byte) ((pixel & 1) * 255)); + // Keep a fixed scratch allocation; unusually large uploads must not enlarge it permanently. + ByteBuffer expanded = requiredBytes <= PACKED_PIXEL_BUFFER_SIZE + ? packedPixelBuffer : BufferUtils.createByteBuffer(requiredBytes); + try { + // Client-memory pixels have been consumed when the preceding GL upload returns. + expanded.clear(); + int start = source.position(); + for (int row = 0; row < height && width != 0; row++) { + source.position(start + Math.multiplyExact(Math.multiplyExact(row, rowLength), 2)); + for (int column = 0; column < width; column++) { + int pixel = source.getShort() & 0xffff; + int red = pixel >> 11; + int green = hasAlpha ? (pixel >> 6) & 0x1f : (pixel >> 5) & 0x3f; + int blue = hasAlpha ? (pixel >> 1) & 0x1f : pixel & 0x1f; + // Preserve encoded sRGB values; the texture performs linearization when sampled. + expanded.put((byte) ((red * 255 + 15) / 31)); + expanded.put((byte) ((green * 255 + (hasAlpha ? 15 : 31)) / (hasAlpha ? 31 : 63))); + expanded.put((byte) ((blue * 255 + 15) / 31)); + if (hasAlpha) { + expanded.put((byte) ((pixel & 1) * 255)); + } + } } + expanded.flip(); + return expanded; + } catch (RuntimeException | Error exception) { + releasePackedPixels(expanded); + throw exception; + } + } + + private void releasePackedPixels(ByteBuffer buffer) { + if (buffer != null && buffer != packedPixelBuffer) { + BufferUtils.destroyDirectBuffer(buffer); } - expanded.flip(); - return expanded; } /** Releases the owned CPU conversion scratch buffer; repeated calls are safe. */ @@ -366,16 +390,20 @@ public void uploadTexture(Image image, data.limit(pos + mipSizes[i]); } - ByteBuffer uploadData = data; - if (data != null && requiresPackedExpansion(jmeFormat, oglFormat)) { - int pixelCount = Math.multiplyExact(mipWidth, mipHeight); - if (target == GL2.GL_TEXTURE_3D) { - pixelCount = Math.multiplyExact(pixelCount, mipDepth); + ByteBuffer expanded = null; + try { + if (data != null && requiresPackedExpansion(jmeFormat, oglFormat)) { + int pixelCount = Math.multiplyExact(mipWidth, mipHeight); + if (target == GL2.GL_TEXTURE_3D) { + pixelCount = Math.multiplyExact(pixelCount, mipDepth); + } + expanded = expandPackedPixels(data, jmeFormat, pixelCount); } - uploadData = expandPackedPixels(data, jmeFormat, pixelCount); + uploadTextureLevel(oglFormat, target, i, index, sliceCount, + mipWidth, mipHeight, mipDepth, samples, expanded != null ? expanded : data); + } finally { + releasePackedPixels(expanded); } - uploadTextureLevel(oglFormat, target, i, index, sliceCount, - mipWidth, mipHeight, mipDepth, samples, uploadData); pos += mipSizes[i]; } @@ -425,13 +453,17 @@ public void uploadSubTexture(Image image, int target, int index, int x, int y, b data.position(0); data.limit(data.capacity()); - if (requiresPackedExpansion(jmeFormat, oglFormat)) { - data = expandPackedPixels(data, jmeFormat, - Math.multiplyExact(image.getWidth(), image.getHeight())); + ByteBuffer expanded = null; + try { + if (requiresPackedExpansion(jmeFormat, oglFormat)) { + expanded = expandPackedPixels(data, jmeFormat, + Math.multiplyExact(image.getWidth(), image.getHeight())); + } + gl.glTexSubImage2D(target, 0, x, y, image.getWidth(), image.getHeight(), + oglFormat.format, oglFormat.dataType, expanded != null ? expanded : data); + } finally { + releasePackedPixels(expanded); } - - gl.glTexSubImage2D(target, 0, x, y, image.getWidth(), image.getHeight(), - oglFormat.format, oglFormat.dataType, data); } public void uploadSubTexture(int target, Image src, int index, int targetX, int targetY, int areaX, int areaY, int areaWidth, int areaHeight, boolean linearizeSrgb) { @@ -476,9 +508,24 @@ void uploadSubTexture(int target, Image src, int index, int targetX, int targetY data = data.duplicate(); int bytesPerPixel = src.getFormat().getBitsPerPixel() / 8; if (requiresPackedExpansion(jmeFormat, oglFormat)) { + if (areaX < 0 || areaY < 0 || areaWidth < 0 || areaHeight < 0 + || areaX > src.getWidth() - areaWidth || areaY > src.getHeight() - areaHeight) { + throw new IllegalArgumentException("The requested region is outside the source image"); + } data.clear(); - data = expandPackedPixels(data, jmeFormat, Math.multiplyExact(src.getWidth(), src.getHeight())); - bytesPerPixel = jmeFormat == Format.RGB565 ? 3 : 4; + if (areaWidth != 0 && areaHeight != 0) { + int firstPixel = Math.addExact(Math.multiplyExact(areaY, src.getWidth()), areaX); + data.position(Math.multiplyExact(firstPixel, bytesPerPixel)); + } + ByteBuffer expanded = null; + try { + expanded = expandPackedPixels(data, jmeFormat, areaWidth, areaHeight, src.getWidth()); + gl.glTexSubImage2D(target, 0, targetX, targetY, areaWidth, areaHeight, + oglFormat.format, oglFormat.dataType, expanded); + } finally { + releasePackedPixels(expanded); + } + return; } int srcWidth = src.getWidth(); @@ -499,9 +546,13 @@ void uploadSubTexture(int target, Image src, int index, int targetX, int targetY } else { if (needsStride) gl.glPixelStorei(GL.GL_UNPACK_ROW_LENGTH, srcWidth); - gl.glTexSubImage2D(target, 0, targetX, targetY, areaWidth, areaHeight, oglFormat.format, oglFormat.dataType, data); - if (needsStride) - gl.glPixelStorei(GL.GL_UNPACK_ROW_LENGTH, 0); + try { + gl.glTexSubImage2D(target, 0, targetX, targetY, areaWidth, areaHeight, + oglFormat.format, oglFormat.dataType, data); + } finally { + if (needsStride) + gl.glPixelStorei(GL.GL_UNPACK_ROW_LENGTH, 0); + } } } diff --git a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java index cb7e535cca..1201502510 100644 --- a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java +++ b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureUtilPackedSrgbTest.java @@ -56,16 +56,27 @@ import java.util.Arrays; import java.util.EnumSet; import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; /** Verifies packed sRGB transfers without creating a native GL context. */ public class TextureUtilPackedSrgbTest { + private static final int SCRATCH_CAPACITY = 32 * 1024 * 1024; private static final Format[] PACKED_FORMATS = {Format.RGB565, Format.RGB5A1}; + private final List contexts = new ArrayList<>(); + + @AfterEach + public void cleanupScratchBuffers() { + for (RecordingGl gl : new ArrayList<>(contexts)) { + gl.close(); + } + } @Test public void testGles3SrgbFormatCapabilities() { - TextureUtil util = new RecordingGl().textureUtil(true, true); + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); GLImageFormat rgb = util.getImageFormat(Format.RGB565, true); GLImageFormat rgba = util.getImageFormat(Format.RGB5A1, true); assertEquals(GLExt.GL_SRGB8_EXT, rgb.internalFormat); @@ -78,6 +89,7 @@ public void testGles3SrgbFormatCapabilities() { assertTrue(rgba.colorRenderable); assertTrue(rgb.filterable); assertTrue(rgba.filterable); + gl.close(); } @Test @@ -92,6 +104,7 @@ public void testRendererUploadsEncodedRgb565Channels() { assertArrayEquals(bytes(0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 12, 25, 132, 130, 132, 58, 61, 58), upload.data); assertTrue(upload.direct); + gl.close(); } @Test @@ -106,6 +119,7 @@ public void testRendererUploadsEncodedRgb5a1ChannelsAndAlpha() { assertArrayEquals(bytes(0, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 0, 0, 0, 255, 255, 25, 25, 25, 0, 132, 132, 132, 255, 58, 58, 58, 0), upload.data); + gl.close(); } @Test @@ -129,6 +143,7 @@ public void testAllPackedValuesUseNormalizedEncodedChannels() { gl.textureUtil(true, true).uploadTexture(image(format, 256, 256, source), GL.GL_TEXTURE_2D, 0, true); assertArrayEquals(expected, gl.uploads.get(0).data); + gl.close(); } } @@ -144,6 +159,7 @@ public void testNativePackedBytesIgnoreBufferOrderMetadata() { assertArrayEquals(format == Format.RGB565 ? bytes(255, 0, 0, 0, 0, 255) : bytes(255, 0, 0, 0, 0, 0, 123, 255), gl.uploads.get(0).data); state.assertUnchanged(source); + gl.close(); } } } @@ -180,6 +196,7 @@ public void testMipLevelsPreserveImageAndSourceState() { state.assertUnchanged(source); source.reset(); assertEquals(3, source.position()); + gl.close(); } } @@ -192,6 +209,7 @@ public void testReadOnlySourceAndCubemapFace() { GL.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, true); assertEquals(GL.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, gl.uploads.get(0).target); assertArrayEquals(white(format, 1), gl.uploads.get(0).data); + gl.close(); } } @@ -208,6 +226,7 @@ public void testVolumeMipLevels() { assertArrayEquals(white(format, 8), gl.uploads.get(0).data); assertEquals(1, gl.uploads.get(1).depth); assertArrayEquals(new byte[components(format)], gl.uploads.get(1).data); + gl.close(); } } @@ -229,6 +248,7 @@ public void testArrayAllocationAndSlices() { assertArrayEquals(new byte[2 * components(format)], gl.uploads.get(1).data); assertEquals(1, gl.uploads.get(2).offsetZ); assertArrayEquals(white(format, 2), gl.uploads.get(2).data); + gl.close(); } } @@ -239,6 +259,7 @@ public void testNullTextureAllocation() { gl.textureUtil(true, true).uploadTexture(image(format, 3, 2, null), GL.GL_TEXTURE_2D, 0, true); assertSrgb(gl.uploads.get(0), format); assertNull(gl.uploads.get(0).data); + gl.close(); } } @@ -258,6 +279,7 @@ public void testDeprecatedWholeSubImage() { assertArrayEquals(white(format, 2), upload.data); state.assertUnchanged(source); source.reset(); + gl.close(); } } @@ -274,21 +296,17 @@ public void testCroppedSubImageWithAndWithoutRowLength() { byte[] expected = format == Format.RGB565 ? bytes(255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 255, 255) : bytes(255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255); - assertEquals(rowLength ? 1 : 2, gl.uploads.size()); - if (rowLength) { - assertArrayEquals(expected, gl.uploads.get(0).data); - } else { - assertArrayEquals(Arrays.copyOfRange(expected, 0, expected.length / 2), - gl.uploads.get(0).data); - assertArrayEquals(Arrays.copyOfRange(expected, expected.length / 2, expected.length), - gl.uploads.get(1).data); - assertEquals(10, gl.uploads.get(1).offsetY); - } + assertEquals(1, gl.uploads.size()); + assertArrayEquals(expected, gl.uploads.get(0).data); + assertEquals(0, gl.uploads.get(0).bufferPosition); + assertEquals(expected.length, gl.uploads.get(0).bufferLimit); + assertEquals(0, gl.pixelStoreCalls); assertEquals(0, gl.rowLength); assertEquals(7, gl.uploads.get(0).offsetX); assertEquals(9, gl.uploads.get(0).offsetY); state.assertUnchanged(source); source.reset(); + gl.close(); } } } @@ -332,6 +350,7 @@ public void testRendererSubImageTransfersAcrossColorSpaces() { assertEquals(format == Format.RGB565 ? GL.GL_RGB : GL.GL_RGBA, update.format); state.assertUnchanged(sourceData); sourceData.reset(); + gl.close(); } } } @@ -352,6 +371,7 @@ public void testDesktopSubImagesKeepPackedTransfers() { : GL.GL_UNSIGNED_SHORT_5_5_5_1, upload.type); assertArrayEquals(rawBytes(source), upload.data); } + gl.close(); } } @@ -387,6 +407,7 @@ public void testUnconvertedSubImagesPreserveCallerStateWhenDriverConsumesData() state.assertUnchanged(source); source.reset(); assertEquals(1, source.position()); + gl.close(); } } } @@ -427,6 +448,7 @@ public void testRendererSubImageTransfersWithUnknownColorSpaces() { state.assertUnchanged(sourceData); sourceData.reset(); assertEquals(1, sourceData.position()); + gl.close(); } } } @@ -450,6 +472,7 @@ public void testDesktopLinearAndDisabledLinearizationKeepPackedTransfers() { assertArrayEquals(rawBytes(source), upload.data); assertEquals(control == 0 ? srgbInternal(format) : format == Format.RGB565 ? GL.GL_RGB565 : GL.GL_RGB5_A1, upload.internal); + gl.close(); } } } @@ -464,6 +487,7 @@ public void testOrdinarySrgbBytesRemainUnchanged() { gl.textureUtil(true, true).uploadTexture(image(format, 1, 1, source), GL.GL_TEXTURE_2D, 0, true); assertEquals(GL.GL_UNSIGNED_BYTE, gl.uploads.get(0).type); assertArrayEquals(expected, gl.uploads.get(0).data); + gl.close(); } } @@ -477,6 +501,7 @@ public void testSurplusCapacityIsNotConverted() { gl.textureUtil(true, true).uploadTexture(image(format, 1, 1, source), GL.GL_TEXTURE_2D, 0, true); assertArrayEquals(white(format, 1), gl.uploads.get(0).data); state.assertUnchanged(source); + gl.close(); } } @@ -500,6 +525,7 @@ public void testGles3WithoutCoreProfileAndWithoutSrgb() { : GL.GL_UNSIGNED_SHORT_5_5_5_1, upload.type); assertArrayEquals(rawBytes(source), upload.data); } + gl.close(); } } } @@ -530,9 +556,9 @@ public void testPackedScratchIsReusedAcrossLevelsSlicesAndSubImages() { util.uploadSubTexture(GL.GL_TEXTURE_2D, smaller, 0, 0, 0, 1, 0, 1, 1, true); for (Upload upload : gl.uploads) { assertSame(scratch, upload.buffer); - assertEquals(4 * components(format), upload.bufferCapacity); + assertEquals(SCRATCH_CAPACITY, upload.bufferCapacity); } - assertEquals(2 * components(format), gl.uploads.get(7).bufferLimit); + assertEquals(components(format), gl.uploads.get(7).bufferLimit); assertArrayEquals(new byte[components(format)], gl.uploads.get(7).data); util.uploadTexture(image(format, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); assertSame(scratch, gl.uploads.get(8).buffer); @@ -540,11 +566,12 @@ public void testPackedScratchIsReusedAcrossLevelsSlicesAndSubImages() { assertEquals(components(format), gl.uploads.get(8).bufferLimit); assertArrayEquals(white(format, 1), gl.uploads.get(8).data); util.cleanup(); + gl.close(); } } @Test - public void testPackedScratchGrowsThenReusesCapacityUntilCleanup() { + public void testPackedScratchHasFixedCapacityUntilCleanup() { RecordingGl gl = new RecordingGl(); TextureUtil util = gl.textureUtil(true, true); util.uploadTexture(image(Format.RGB565, 1, 1, packed(0)), GL.GL_TEXTURE_2D, 0, true); @@ -552,8 +579,8 @@ public void testPackedScratchGrowsThenReusesCapacityUntilCleanup() { util.uploadTexture(image(Format.RGB5A1, 2, 2, packed(0xffff, 0xffff, 0xffff, 0xffff)), GL.GL_TEXTURE_2D, 0, true); ByteBuffer larger = gl.uploads.get(1).buffer; - assertNotSame(first, larger); - assertEquals(16, gl.uploads.get(1).bufferCapacity); + assertSame(first, larger); + assertEquals(SCRATCH_CAPACITY, gl.uploads.get(1).bufferCapacity); util.uploadTexture(image(Format.RGB565, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); assertSame(larger, gl.uploads.get(2).buffer); assertEquals(0, gl.uploads.get(2).bufferPosition); @@ -563,9 +590,193 @@ public void testPackedScratchGrowsThenReusesCapacityUntilCleanup() { util.cleanup(); util.uploadTexture(image(Format.RGB5A1, 1, 1, packed(0xffff)), GL.GL_TEXTURE_2D, 0, true); assertNotSame(larger, gl.uploads.get(3).buffer); - assertEquals(4, gl.uploads.get(3).bufferCapacity); + assertEquals(SCRATCH_CAPACITY, gl.uploads.get(3).bufferCapacity); assertArrayEquals(white(Format.RGB5A1, 1), gl.uploads.get(3).data); util.cleanup(); + gl.close(); + } + + @Test + public void testExactScratchCapacityIsReused() { + RecordingGl gl = new RecordingGl(); + gl.captureUploadBytes = false; + TextureUtil util = gl.textureUtil(true, true); + ByteBuffer source = ByteBuffer.allocateDirect(SCRATCH_CAPACITY / 2); + util.uploadTexture(image(Format.RGB5A1, 4096, 2048, source), GL.GL_TEXTURE_2D, 0, true); + Upload exact = gl.uploads.get(0); + assertEquals(SCRATCH_CAPACITY, exact.bufferCapacity); + assertEquals(SCRATCH_CAPACITY, exact.bufferLimit); + util.uploadTexture(image(Format.RGB565, 1, 1, packed(0)), GL.GL_TEXTURE_2D, 0, true); + assertSame(exact.buffer, gl.uploads.get(1).buffer); + assertEquals(3, gl.uploads.get(1).bufferLimit); + gl.close(); + } + + @Test + public void testOversizedUploadsDoNotReplaceScratchOnSuccessOrFailure() { + for (int entryPoint = 0; entryPoint < 3; entryPoint++) { + for (boolean fail : new boolean[]{false, true}) { + RecordingGl gl = new RecordingGl(); + gl.captureUploadBytes = false; + gl.consumeUploadBuffer = true; + TextureUtil util = gl.textureUtil(true, true); + util.uploadTexture(image(Format.RGB565, 1, 1, packed(0)), GL.GL_TEXTURE_2D, 0, true); + final ByteBuffer scratch = gl.uploads.get(0).buffer; + int width = 4097; + int height = 2048; + ByteBuffer source = ByteBuffer.allocateDirect(width * height * 2).asReadOnlyBuffer(); + source.position(1).mark().position(2).limit(3); + Image image = image(Format.RGB5A1, width, height, source); + gl.failUpload = fail; + final int selectedEntryPoint = entryPoint; + Runnable upload = () -> { + if (selectedEntryPoint == 0) { + util.uploadTexture(image, GL.GL_TEXTURE_2D, 0, true); + } else if (selectedEntryPoint == 1) { + util.uploadSubTexture(image, GL.GL_TEXTURE_2D, 0, 0, 0, true); + } else { + util.uploadSubTexture(GL.GL_TEXTURE_2D, image, 0, 0, 0, + 0, 0, width, height, true); + } + }; + if (fail) { + assertThrows(IllegalStateException.class, upload::run); + } else { + upload.run(); + } + Upload oversized = gl.uploads.get(1); + assertNotSame(scratch, oversized.buffer); + assertEquals(width * height * 4, oversized.bufferCapacity); + assertEquals(width * height * 4, oversized.bufferLimit); + assertEquals(2, source.position()); + assertEquals(3, source.limit()); + source.reset(); + assertEquals(1, source.position()); + gl.failUpload = false; + util.uploadTexture(image(Format.RGB565, 1, 1, packed(0)), GL.GL_TEXTURE_2D, 0, true); + assertSame(scratch, gl.uploads.get(2).buffer); + assertEquals(SCRATCH_CAPACITY, gl.uploads.get(2).bufferCapacity); + assertEquals(3, gl.uploads.get(2).bufferLimit); + util.cleanup(); + gl.close(); + } + } + } + + @Test + public void testCropDoesNotRequireUnselectedSourcePixels() { + for (Format format : PACKED_FORMATS) { + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, false); + // Only the first selected row exists, although the image describes a much larger source. + ByteBuffer source = packed(0, 0xffff, 0xf800).asReadOnlyBuffer(); + source.position(1).mark().position(2).limit(3); + final SourceState state = new SourceState(source); + util.uploadSubTexture(GL.GL_TEXTURE_2D, image(format, 4096, 4096, source), + 0, 3, 5, 1, 0, 2, 1, true); + Upload upload = gl.uploads.get(0); + assertEquals(2 * components(format), upload.bufferLimit); + assertEquals(SCRATCH_CAPACITY, upload.bufferCapacity); + assertArrayEquals(format == Format.RGB565 ? bytes(255, 255, 255, 255, 0, 0) + : bytes(255, 255, 255, 255, 255, 0, 0, 0), upload.data); + state.assertUnchanged(source); + source.reset(); + assertEquals(1, source.position()); + gl.close(); + } + } + + @Test + public void testInvalidCropAndTruncatedSelectedRowsFailBeforeUpload() { + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); + ByteBuffer source = packed(0, 0xffff, 0, 0); + source.position(1).mark(); + final SourceState state = new SourceState(source); + Image image = image(Format.RGB565, 3, 3, source); + int[][] regions = {{-1, 0, 1, 1}, {0, -1, 1, 1}, {0, 0, -1, 1}, + {0, 0, 1, -1}, {3, 0, 1, 1}, {0, 3, 1, 1}, {1, 1, 2, 2}, + {Integer.MAX_VALUE, 0, 1, 1}, {0, 0, Integer.MAX_VALUE, 1}}; + for (int[] region : regions) { + assertThrows(IllegalArgumentException.class, + () -> util.uploadSubTexture(GL.GL_TEXTURE_2D, image, 0, 0, 0, + region[0], region[1], region[2], region[3], true)); + } + assertTrue(gl.uploads.isEmpty()); + state.assertUnchanged(source); + source.reset(); + gl.close(); + } + + @Test + public void testCroppedPackedUploadPreservesStateAfterFailure() { + for (Format format : PACKED_FORMATS) { + for (boolean rowLength : new boolean[]{false, true}) { + RecordingGl gl = new RecordingGl(); + gl.failUpload = true; + gl.consumeUploadBuffer = true; + TextureUtil util = gl.textureUtil(true, rowLength); + ByteBuffer source = packed(0, 0, 0, 0, 0xffff, 0xf800, 0, 0, 0xffff).asReadOnlyBuffer(); + source.position(1).mark().position(2).limit(3); + final SourceState state = new SourceState(source); + Runnable upload = () -> util.uploadSubTexture(GL.GL_TEXTURE_2D, + image(format, 3, 3, source), 0, 7, 9, 1, 1, 2, 2, true); + assertThrows(IllegalStateException.class, upload::run); + assertEquals(0, gl.rowLength); + assertEquals(0, gl.pixelStoreCalls); + assertEquals(4 * components(format), gl.uploads.get(0).bufferLimit); + state.assertUnchanged(source); + source.reset(); + assertEquals(1, source.position()); + gl.failUpload = false; + upload.run(); + assertSame(gl.uploads.get(0).buffer, gl.uploads.get(1).buffer); + assertEquals(0, gl.uploads.get(1).bufferPosition); + assertArrayEquals(gl.uploads.get(0).data, gl.uploads.get(1).data); + gl.close(); + } + } + } + + @Test + public void testPackedDimensionOverflowFailsBeforeUpload() { + RecordingGl gl = new RecordingGl(); + TextureUtil util = gl.textureUtil(true, true); + ByteBuffer source = packed(0xffff); + final SourceState state = new SourceState(source); + Image image = image(Format.RGB5A1, Integer.MAX_VALUE, 3, source); + assertThrows(ArithmeticException.class, + () -> util.uploadTexture(image, GL.GL_TEXTURE_2D, 0, true)); + assertThrows(ArithmeticException.class, + () -> util.uploadSubTexture(GL.GL_TEXTURE_2D, image, 0, 0, 0, 0, 2, 1, 1, true)); + assertTrue(gl.uploads.isEmpty()); + state.assertUnchanged(source); + gl.close(); + } + + @Test + public void testCroppedUnconvertedUploadRestoresRowLengthAfterFailure() { + for (boolean fail : new boolean[]{false, true}) { + RecordingGl gl = new RecordingGl(); + gl.failUpload = fail; + gl.consumeUploadBuffer = true; + TextureUtil util = gl.textureUtil(false, true); + ByteBuffer source = packed(0, 0, 0, 0, 0xffff, 0xf800, 0, 0, 0xffff); + source.position(1).mark(); + final SourceState state = new SourceState(source); + Runnable upload = () -> util.uploadSubTexture(GL.GL_TEXTURE_2D, + image(Format.RGB565, 3, 3, source), 0, 0, 0, 1, 1, 2, 2, true); + if (fail) { + assertThrows(IllegalStateException.class, upload::run); + } else { + upload.run(); + } + assertEquals(0, gl.rowLength); + assertEquals(2, gl.pixelStoreCalls); + state.assertUnchanged(source); + source.reset(); + gl.close(); + } } @Test @@ -582,6 +793,7 @@ public void testRendererCleanupReleasesConversionScratch() { assertNotSame(first, gl.uploads.get(1).buffer); assertArrayEquals(bytes(255, 255, 255), gl.uploads.get(1).data); renderer.cleanup(); + gl.close(); } @Test @@ -596,6 +808,7 @@ public void testIncompletePackedPixelFailsWithoutChangingSource() { assertTrue(gl.uploads.isEmpty()); state.assertUnchanged(source); source.reset(); + gl.close(); } private static Image image(Format format, int width, int height, ByteBuffer source) { @@ -688,12 +901,29 @@ private static final class Upload { private int bufferCapacity; } - private static final class RecordingGl implements InvocationHandler { + private final class RecordingGl implements InvocationHandler, AutoCloseable { + private final List cleanups = new ArrayList<>(); + private final List uploads = new ArrayList<>(); private int rowLength; + private int pixelStoreCalls; + private boolean captureUploadBytes = true; private boolean consumeUploadBuffer; private boolean failUpload; + private RecordingGl() { + contexts.add(this); + } + + @Override + public void close() { + for (Runnable cleanup : cleanups) { + cleanup.run(); + } + cleanups.clear(); + contexts.remove(this); + } + private T proxy(Class type) { return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, this)); } @@ -711,6 +941,7 @@ private TextureUtil textureUtil(boolean gles, boolean unpackRowLength) { private TextureUtil textureUtil(EnumSet caps) { TextureUtil util = new TextureUtil(proxy(GL.class), proxy(GL2.class), proxy(GLExt.class)); util.initialize(caps); + cleanups.add(util::cleanup); return util; } @@ -718,6 +949,7 @@ private GLRenderer renderer() { final GLRenderer renderer = new GLRenderer(proxy(GLES_30.class), proxy(GLExt.class), proxy(GLFbo.class)); renderer.initialize(); + cleanups.add(renderer::cleanup); assertTrue(renderer.getCaps().contains(Caps.OpenGLES30)); assertTrue(renderer.getCaps().contains(Caps.Srgb)); renderer.setLinearizeSrgbImages(true); @@ -751,6 +983,7 @@ public Object invoke(Object proxy, Method method, Object[] args) { ((IntBuffer) args[0]).put(0, 1); return null; case "glPixelStorei": + pixelStoreCalls++; if ((Integer) args[0] == GL.GL_UNPACK_ROW_LENGTH) { rowLength = (Integer) args[1]; } @@ -811,6 +1044,10 @@ private void record(String method, Object[] args) { upload.bufferPosition = source.position(); upload.bufferLimit = source.limit(); upload.bufferCapacity = source.capacity(); + if (!captureUploadBytes) { + uploads.add(upload); + return; + } int bpp = upload.type == GL.GL_UNSIGNED_BYTE ? (upload.format == GL.GL_RGB ? 3 : 4) : 2; int stride = rowLength == 0 ? upload.width : rowLength; upload.data = new byte[upload.width * upload.height * upload.depth * bpp];