From 308dced919d190d3bc89c4daa1311b7c86b8f059 Mon Sep 17 00:00:00 2001 From: Davis Rollman Date: Wed, 4 Nov 2026 20:45:56 -0500 Subject: [PATCH 1/2] Update texture-array layers without reuploading the full image Add a renderer overload for base-level layer rectangles with matching uncompressed formats and color spaces. Validate buffers, bounds, mipmap policy and packed expansion sizes before binding; retain source state and reactivate cached unit-zero bindings. Updates affect GPU storage only, so callers retain and replay their data after full uploads or context recreation. Keep the default renderer method compatible with external implementations and avoid requesting generated mipmaps for non-mipmapped array construction. Validate with recording-GL tests for resident, dirty, recreated and failed uploads, source aliases and malformed inputs. Include an asset-free finite native readback probe; native execution remains a separate manual check. --- .../main/java/com/jme3/renderer/Renderer.java | 31 + .../com/jme3/renderer/opengl/GLRenderer.java | 27 + .../com/jme3/renderer/opengl/TextureUtil.java | 78 +++ .../java/com/jme3/system/NullRenderer.java | 6 + .../java/com/jme3/texture/TextureArray.java | 4 +- .../opengl/TextureArrayUpdateTest.java | 636 ++++++++++++++++++ .../texture/TestTextureArrayUpdate.java | 316 +++++++++ 7 files changed, 1097 insertions(+), 1 deletion(-) create mode 100644 jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java create mode 100644 jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java diff --git a/jme3-core/src/main/java/com/jme3/renderer/Renderer.java b/jme3-core/src/main/java/com/jme3/renderer/Renderer.java index 0e9e30bfa1..efd8db1ddf 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/Renderer.java +++ b/jme3-core/src/main/java/com/jme3/renderer/Renderer.java @@ -44,6 +44,7 @@ import com.jme3.texture.Image; import com.jme3.texture.Image.Format; import com.jme3.texture.Texture; +import com.jme3.texture.TextureArray; import com.jme3.texture.TextureImage; import com.jme3.util.NativeObject; import java.nio.ByteBuffer; @@ -311,6 +312,36 @@ public void setTexture(int unit, Texture tex) */ public void modifyTexture(Texture tex, Image pixels, int x, int y); + /** + * Copies a complete 2D source image into a rectangle of one texture-array + * layer on the render thread. Only level-zero, uncompressed color images + * without multisampling or mipmaps are supported. Source and destination + * must have identical formats and color spaces, and the destination must + * use a non-mipmapped minification filter. Source pixels are tightly packed + * from byte zero of a sufficiently sized direct buffer; its position, + * limit, mark, byte order and contents are preserved. + * + *

This updates GPU storage only. It neither changes the destination's CPU + * image data nor marks the image dirty. Callers must retain authoritative + * CPU data or replay updates after texture/context recreation or any full + * image upload. An unuploaded or dirty destination is first fully uploaded + * using its populated CPU layer buffers. A resident, clean destination + * requires only one layer-region upload; other texels are unchanged. + * + * @param dest the destination texture array (not null) + * @param pixels the single-buffer 2D source image (not null) + * @param layer the destination layer index, starting at zero + * @param x the first destination column + * @param y the first destination row + * @throws IllegalArgumentException for invalid dimensions, bounds, buffers, + * or mismatched image formats/color spaces + * @throws UnsupportedOperationException if array updates or the specified + * image/mipmap policy are unsupported + */ + default void modifyTexture(TextureArray dest, Image pixels, int layer, int x, int y) { + throw new UnsupportedOperationException("Texture-array updates are not supported by this renderer"); + } + /** * Deletes a texture from the GPU. * 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 b2f43d7136..7f154b0042 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 @@ -58,6 +58,7 @@ import com.jme3.texture.Image; import com.jme3.texture.Texture; import com.jme3.texture.Texture2D; +import com.jme3.texture.TextureArray; import com.jme3.texture.Texture.ShadowCompareMode; import com.jme3.texture.Texture.WrapAxis; import com.jme3.texture.TextureImage; @@ -3535,6 +3536,32 @@ public void modifyTexture(Texture2D dest, Image src, int destX, int destY, srcX, srcY, areaW, areaH, linearizeSrgbImages, dest.getImage().getColorSpace()); } + @Override + public void modifyTexture(TextureArray dest, Image pixels, int layer, int x, int y) { + if (!caps.contains(Caps.TextureArray) || gl2 == null) { + throw new UnsupportedOperationException("Texture arrays are not supported by this renderer"); + } + if (dest == null || dest.getImage() == null || pixels == null) { + throw new IllegalArgumentException("Source and destination images must not be null"); + } + if (dest.getType() != Texture.Type.TwoDimensionalArray + || dest.getMinFilter().usesMipMapLevels()) { + throw new UnsupportedOperationException("Only non-mipmapped texture arrays can be updated"); + } + // Validation precedes binding: setTexture can allocate/reupload the entire array. + GLImageFormat format = texUtil.validateArrayLayerUpdate(dest.getImage(), pixels, + layer, x, y, linearizeSrgbImages); + try { + setTexture(0, dest); + } catch (TextureUnitException exception) { + throw new IllegalStateException("Renderer has no texture unit zero", exception); + } + // setTexture may reuse unit zero's cached binding without making that + // unit active. Subimage writes must target the actual active unit. + bindTextureAndUnit(GLExt.GL_TEXTURE_2D_ARRAY_EXT, dest.getImage(), 0); + texUtil.uploadArrayLayer(pixels, format, layer, x, y); + } + @Override public void deleteImage(Image image) { int texId = image.getId(); 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 0c57cacef4..1dec2267b3 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 @@ -563,4 +563,82 @@ void uploadSubTexture(int target, Image src, int index, int targetX, int targetY } + /** Validates a base-level array update without binding or issuing GL commands. */ + GLImageFormat validateArrayLayerUpdate(Image dest, Image src, int layer, int x, int y, + boolean linearizeSrgb) { + if (src.getFormat() == null || dest.getFormat() == null + || dest.getFormat() != src.getFormat() || dest.getColorSpace() != src.getColorSpace()) { + throw new IllegalArgumentException("Array update format and color space must match " + + "the destination"); + } + if (src.getFormat().isCompressed() || src.getFormat().isDepthFormat() + || src.getDepth() > 1 || dest.getDepth() > 1 + || src.getMultiSamples() > 1 || dest.getMultiSamples() > 1 + || src.getMipMapSizes() != null || dest.getMipMapSizes() != null + || src.isGeneratedMipmapsRequired() || dest.isGeneratedMipmapsRequired()) { + throw new UnsupportedOperationException("Only single-sample, non-mipmapped " + + "2D color layers can be updated"); + } + if (src.getWidth() <= 0 || src.getHeight() <= 0 + || dest.getWidth() <= 0 || dest.getHeight() <= 0 + || x < 0 || y < 0 + || x > dest.getWidth() - src.getWidth() + || y > dest.getHeight() - src.getHeight()) { + throw new IllegalArgumentException("The array update rectangle is outside the destination"); + } + if (dest.getData() == null || layer < 0 || layer >= dest.getData().size() + || src.getData() == null || src.getData().size() != 1) { + throw new IllegalArgumentException("Invalid destination layer or source image buffer count"); + } + validateLayerBuffer(src.getData(0), src); + validateLayerBuffer(dest.getData(layer), dest); + if (dest.isUpdateNeeded() || dest.getId() == -1) { + // Initial/full uploads already visit every layer. Resident updates stay O(1). + for (ByteBuffer data : dest.getData()) { + validateLayerBuffer(data, dest); + } + } + boolean srgb = dest.getColorSpace() == ColorSpace.sRGB && linearizeSrgb; + GLImageFormat format = getImageFormatWithError(src.getFormat(), srgb); + // Bound integer scratch calculations before setTexture can mutate native state. + if (requiresPackedExpansion(src.getFormat(), format)) { + long components = src.getFormat() == Format.RGB565 ? 3 : 4; + if ((long) src.getWidth() * src.getHeight() * components > Integer.MAX_VALUE + || ((dest.isUpdateNeeded() || dest.getId() == -1) + && (long) dest.getWidth() * dest.getHeight() * components > Integer.MAX_VALUE)) { + throw new IllegalArgumentException("Expanded array update or backing layer is too large"); + } + } + return format; + } + + private static void validateLayerBuffer(ByteBuffer data, Image image) { + long pixels = (long) image.getWidth() * image.getHeight(); + int bytesPerPixel = image.getFormat().getBitsPerPixel() / 8; + if (data == null || !data.isDirect() || pixels <= 0 || bytesPerPixel <= 0 + || pixels > data.capacity() / bytesPerPixel) { + throw new IllegalArgumentException("A complete direct buffer is required for each image layer"); + } + } + + /** Uploads one validated tightly packed patch. Renderer owns texture binding/unpack state. */ + void uploadArrayLayer(Image src, GLImageFormat format, int layer, int x, int y) { + ByteBuffer data = src.getData(0).duplicate(); + data.clear(); + data.limit(Math.multiplyExact(Math.multiplyExact(src.getWidth(), src.getHeight()), + src.getFormat().getBitsPerPixel() / 8)); + ByteBuffer expanded = null; + try { + if (requiresPackedExpansion(src.getFormat(), format)) { + expanded = expandPackedPixels(data, src.getFormat(), + Math.multiplyExact(src.getWidth(), src.getHeight())); + } + gl2.glTexSubImage3D(GLExt.GL_TEXTURE_2D_ARRAY_EXT, 0, x, y, layer, + src.getWidth(), src.getHeight(), 1, format.format, format.dataType, + expanded != null ? expanded : data); + } finally { + releasePackedPixels(expanded); + } + } + } diff --git a/jme3-core/src/main/java/com/jme3/system/NullRenderer.java b/jme3-core/src/main/java/com/jme3/system/NullRenderer.java index 162f347206..7b451deda3 100644 --- a/jme3-core/src/main/java/com/jme3/system/NullRenderer.java +++ b/jme3-core/src/main/java/com/jme3/system/NullRenderer.java @@ -49,6 +49,7 @@ import com.jme3.texture.FrameBuffer; import com.jme3.texture.Image; import com.jme3.texture.Texture; +import com.jme3.texture.TextureArray; import com.jme3.texture.TextureImage; import com.jme3.util.NativeObject; @@ -184,6 +185,11 @@ public void setTextureImage(int unit, TextureImage tex) throws TextureUnitExcept public void modifyTexture(Texture tex, Image pixels, int x, int y) { } + @Override + public void modifyTexture(TextureArray dest, Image pixels, int layer, int x, int y) { + // GPU-only operation: there is no storage to update in the null renderer. + } + @Override public void updateBufferData(VertexBuffer vb) { } diff --git a/jme3-core/src/main/java/com/jme3/texture/TextureArray.java b/jme3-core/src/main/java/com/jme3/texture/TextureArray.java index 644e094f70..9b7078ace0 100644 --- a/jme3-core/src/main/java/com/jme3/texture/TextureArray.java +++ b/jme3-core/src/main/java/com/jme3/texture/TextureArray.java @@ -79,7 +79,9 @@ public TextureArray(List images) { ColorSpace colorSpace = images.get(0).getColorSpace(); int[] mipMapSizes = images.get(0).getMipMapSizes(); Image arrayImage = new Image(format, width, height, null, colorSpace); - arrayImage.setMipMapSizes(mipMapSizes); + if (mipMapSizes != null) { + arrayImage.setMipMapSizes(mipMapSizes); + } for (Image img : images) { if (img.getHeight() != height || img.getWidth() != width) { diff --git a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java new file mode 100644 index 0000000000..a27657a585 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java @@ -0,0 +1,636 @@ +/* + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.renderer.Caps; +import com.jme3.renderer.TextureUnitException; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.TextureArray; +import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; +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.FloatBuffer; +import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** Verifies managed base-level array-layer uploads without a native GL context. */ +public class TextureArrayUpdateTest { + + private final List contexts = new ArrayList<>(); + + @AfterEach + public void cleanupRenderers() { + for (RecordingGl gl : new ArrayList<>(contexts)) { + gl.close(); + } + } + + @Test + public void standardArrayConstructionRequestsMipmapsOnlyForMipFilteredTextures() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + Image first = image(Image.Format.RGBA8, 2, 2, bytes(16, (byte) 1), ColorSpace.Linear); + Image second = image(Image.Format.RGBA8, 2, 2, bytes(16, (byte) 2), ColorSpace.Linear); + TextureArray array = new TextureArray(Arrays.asList(first, second)); + assertFalse(array.getImage().isGeneratedMipmapsRequired()); + renderer.modifyTexture(array, first, 1, 0, 0); + assertEquals(3, gl.subImage3dCalls); + array.setMinFilter(Texture.MinFilter.Trilinear); + assertTrue(array.getImage().isGeneratedMipmapsRequired()); + } + + @Test + public void updatesOnlyRequestedLayerAndRectangleAndKeepsImageGpuOnly() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + ArrayList initialLayers = new ArrayList<>(Arrays.asList( + bytes(64, (byte) 0x11), bytes(64, (byte) 0x22))); + TextureArray destination = textureArray(Image.Format.RGBA8, 4, 4, initialLayers, ColorSpace.Linear); + final byte[][] original = {contents(initialLayers.get(0)), contents(initialLayers.get(1))}; + Image patch = image(Image.Format.RGBA8, 2, 2, bytes(16, (byte) 0x7a), ColorSpace.Linear); + ByteBuffer source = patch.getData(0); + source.position(3).mark().position(5).limit(9); + final SourceState sourceState = new SourceState(source); + gl.clearCalls(); + + renderer.modifyTexture(destination, patch, 1, 1, 2); + + assertEquals(1, gl.arrayAllocations.size()); + assertEquals(3, gl.subImage3dCalls, "two initial slices and one patch are uploaded"); + Upload update = gl.lastSubImage(); + assertEquals(GLExt.GL_TEXTURE_2D_ARRAY_EXT, update.target); + assertEquals(0, update.level); + assertEquals(1, update.offsetX); + assertEquals(2, update.offsetY); + assertEquals(1, update.offsetZ); + assertEquals(2, update.width); + assertEquals(2, update.height); + assertEquals(1, update.depth); + assertArrayEquals(contents(source), update.data); + sourceState.assertUnchanged(source); + source.reset(); + assertEquals(3, source.position(), "the caller's mark is retained"); + source.position(5); + assertArrayEquals(original[0], contents(initialLayers.get(0))); + assertArrayEquals(original[1], contents(initialLayers.get(1))); + assertFalse(destination.getImage().isUpdateNeeded()); + + gl.clearCalls(); + renderer.modifyTexture(destination, patch, 0, 0, 1); + assertEquals(0, gl.arrayAllocations.size(), "resident updates do not reallocate storage"); + assertEquals(1, gl.subImage3dCalls, "resident update uploads only its requested patch"); + assertArrayEquals(original[0], contents(initialLayers.get(0))); + assertArrayEquals(original[1], contents(initialLayers.get(1))); + sourceState.assertUnchanged(source); + + source.position(3).mark().position(5).limit(9); + SourceState beforeFailure = new SourceState(source); + gl.failNextSubImage = true; + assertThrows(IllegalStateException.class, + () -> renderer.modifyTexture(destination, patch, 0, 0, 1)); + beforeFailure.assertUnchanged(source); + source.reset(); + assertEquals(3, source.position(), "failed uploads preserve the caller's mark too"); + } + + @Test + public void uploadsFloatPatchesWithFloatPixelType() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + renderer.setLinearizeSrgbImages(false); + ArrayList layers = new ArrayList<>(Arrays.asList( + floats(4, 1f), floats(4, 2f))); + TextureArray destination = textureArray(Image.Format.R32F, 2, 2, layers, ColorSpace.Linear); + Image patch = image(Image.Format.R32F, 1, 2, floats(2, 3.5f), ColorSpace.Linear); + gl.clearCalls(); + + renderer.modifyTexture(destination, patch, 0, 1, 0); + + Upload update = gl.lastSubImage(); + assertEquals(GL.GL_RED, update.format); + assertEquals(GL.GL_FLOAT, update.type); + assertEquals(1, update.offsetX); + assertEquals(0, update.offsetY); + assertEquals(0, update.offsetZ); + assertEquals(1, update.width); + assertEquals(2, update.height); + assertEquals(1, update.depth); + assertEquals(8, update.data.length); + } + + @Test + public void expandsPackedSrgbPatchForArrayLayer() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + renderer.setLinearizeSrgbImages(true); + ArrayList layers = new ArrayList<>(Arrays.asList( + shorts(2, 0), shorts(2, 0xffff))); + TextureArray destination = textureArray(Image.Format.RGB565, 2, 1, layers, ColorSpace.sRGB); + Image patch = image(Image.Format.RGB565, 2, 1, shorts(2, 0xf800, 0x07e0), ColorSpace.sRGB); + gl.clearCalls(); + + renderer.modifyTexture(destination, patch, 1, 0, 0); + + Upload update = gl.lastSubImage(); + assertEquals(GL.GL_RGB, update.format); + assertEquals(GL.GL_UNSIGNED_BYTE, update.type); + assertEquals(1, update.offsetZ); + assertArrayEquals(new byte[]{(byte) 255, 0, 0, 0, (byte) 255, 0}, update.data); + } + + @Test + public void arrayUpdateActivatesUnitZeroAndTargetsItsDestination() throws TextureUnitException { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + Image patch = image(Image.Format.RGBA8, 1, 1, bytes(4, (byte) 0x5a), ColorSpace.Linear); + + TextureArray destinationArray = textureArray(Image.Format.RGBA8, 2, 2, + new ArrayList<>(Arrays.asList(bytes(16, (byte) 3), bytes(16, (byte) 4))), ColorSpace.Linear); + assertSubImageTargetsUnitZero(gl, renderer, destinationArray, () -> + renderer.modifyTexture(destinationArray, patch, 1, 0, 0)); + } + + private void assertSubImageTargetsUnitZero(RecordingGl gl, GLRenderer renderer, + Texture destination, Runnable update) throws TextureUnitException { + renderer.setTexture(0, destination); + Texture2D otherTexture = new Texture2D(image( + Image.Format.RGBA8, 1, 1, bytes(4, (byte) 0x33), ColorSpace.Linear)); + renderer.setTexture(3, otherTexture); + gl.clearCalls(); + + update.run(); + + Upload upload = gl.lastSubImage(); + assertEquals(GL.GL_TEXTURE0, upload.activeTexture, + "subimage upload must reactivate unit zero after cached setTexture binding"); + assertEquals(destination.getImage().getId(), upload.boundTexture, + "subimage upload must target the destination bound on unit zero"); + } + + @Test + public void residentPatchChecksOnlyTheAddressedBackingLayer() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + ArrayList layers = new ArrayList<>(Arrays.asList( + bytes(16, (byte) 0), bytes(16, (byte) 1))); + TextureArray destination = textureArray(Image.Format.RGBA8, 2, 2, layers, ColorSpace.Linear); + Image patch = image(Image.Format.RGBA8, 1, 1, bytes(4, (byte) 2), ColorSpace.Linear); + renderer.modifyTexture(destination, patch, 0, 0, 0); // allocate and upload the baseline + + layers.set(1, ByteBuffer.allocate(16)); // not visited by a clean resident layer-0 update + gl.clearCalls(); + renderer.modifyTexture(destination, patch, 0, 1, 1); + + assertEquals(1, gl.subImage3dCalls, "resident update uploads only its requested patch"); + Upload update = gl.lastSubImage(); + assertEquals(0, update.offsetZ); + assertEquals(1, update.offsetX); + assertEquals(1, update.offsetY); + assertEquals(0, gl.arrayAllocations.size()); + } + + @Test + public void prevalidatesMalformedInputsAndMissingArrayCapability() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + TextureArray destination = textureArray(Image.Format.RGBA8, 2, 2, + new ArrayList<>(Arrays.asList(bytes(16, (byte) 0), bytes(16, (byte) 1))), ColorSpace.Linear); + Image patch = image(Image.Format.RGBA8, 1, 1, bytes(4, (byte) 2), ColorSpace.Linear); + List invalid = Arrays.asList( + () -> renderer.modifyTexture(destination, patch, -1, 0, 0), + () -> renderer.modifyTexture(destination, patch, 2, 0, 0), + () -> renderer.modifyTexture(destination, patch, 1, 2, 0), + () -> renderer.modifyTexture(destination, patch, 1, Integer.MAX_VALUE, 0)); + gl.clearCalls(); + for (Runnable invalidCall : invalid) { + assertThrows(IllegalArgumentException.class, invalidCall::run); + assertTrue(gl.calls.isEmpty(), "invalid input must fail before any GL call"); + } + + for (int[] size : new int[][]{{0, 1}, {-1, 1}, {Integer.MAX_VALUE, Integer.MAX_VALUE}}) { + Image malformed = image(Image.Format.RGBA8, size[0], size[1], + bytes(4, (byte) 0), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, malformed, 0, 0, 0)); + assertTrue(gl.calls.isEmpty(), "invalid dimensions must fail before GL calls"); + } + Image aliasesFormat = image(Image.Format.ABGR8, 1, 1, bytes(4, (byte) 0), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, aliasesFormat, 0, 0, 0)); + assertTrue(gl.calls.isEmpty(), "equal pixel size is insufficient for format compatibility"); + + Image shortImage = image(Image.Format.RGBA8, 2, 2, bytes(3, (byte) 3), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, shortImage, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + Image heapImage = image(Image.Format.RGBA8, 1, 1, + ByteBuffer.allocate(4), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, heapImage, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + Image mismatchedFormat = image(Image.Format.RGB8, 1, 1, bytes(3, (byte) 4), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, mismatchedFormat, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + Image mismatchedColorSpace = image(Image.Format.RGBA8, 1, 1, + bytes(4, (byte) 4), ColorSpace.sRGB); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, mismatchedColorSpace, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + + Image mipPatch = image(Image.Format.RGBA8, 2, 2, bytes(20, (byte) 5), ColorSpace.Linear); + mipPatch.setMipMapSizes(new int[]{16, 4}); + assertThrows(UnsupportedOperationException.class, + () -> renderer.modifyTexture(destination, mipPatch, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + + TextureArray mipFiltered = textureArray(Image.Format.RGBA8, 2, 2, + new ArrayList<>(Arrays.asList(bytes(16, (byte) 0), bytes(16, (byte) 1))), ColorSpace.Linear); + mipFiltered.setMinFilter(Texture.MinFilter.Trilinear); + assertThrows(UnsupportedOperationException.class, + () -> renderer.modifyTexture(mipFiltered, patch, 0, 0, 0)); + assertTrue(gl.calls.isEmpty()); + + TextureArray incomplete = textureArray(Image.Format.RGBA8, 2, 2, + new ArrayList<>(Arrays.asList(bytes(16, (byte) 0), bytes(3, (byte) 1))), ColorSpace.Linear); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(incomplete, patch, 0, 0, 0)); + assertTrue(gl.calls.isEmpty(), "all backing layers are checked before the initial upload"); + + RecordingGl gles2 = new RecordingGl("OpenGL ES 2.0"); + GLRenderer noArrayRenderer = gles2.renderer(false); + assertFalse(noArrayRenderer.getCaps().contains(Caps.TextureArray)); + gles2.clearCalls(); + assertThrows(UnsupportedOperationException.class, + () -> noArrayRenderer.modifyTexture(destination, patch, 0, 0, 0)); + assertTrue(gles2.calls.isEmpty(), "unsupported capability must fail before GL calls"); + } + + @Test + public void gpuPatchIsReplayedAfterImageNativeObjectReset() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + ArrayList layers = new ArrayList<>(Arrays.asList( + bytes(4, (byte) 0x10), bytes(4, (byte) 0x20))); + TextureArray destination = textureArray(Image.Format.RGBA8, 1, 1, layers, ColorSpace.Linear); + Image patch = image(Image.Format.RGBA8, 1, 1, bytes(4, (byte) 0x66), ColorSpace.Linear); + final byte[][] before = {contents(layers.get(0)), contents(layers.get(1))}; + + renderer.modifyTexture(destination, patch, 1, 0, 0); + assertEquals(1, gl.arrayAllocations.size()); + assertEquals(3, gl.subImage3dCalls, "two baseline layers and the patch are uploaded"); + renderer.deleteImage(destination.getImage()); + gl.clearCalls(); + + // Context recreation reuploads the CPU baseline; callers replay GPU-only patches. + renderer.modifyTexture(destination, patch, 1, 0, 0); + + assertEquals(1, gl.arrayAllocations.size()); + assertEquals(3, gl.subImage3dCalls, "recreation restores both CPU slices before replaying the patch"); + assertTrue(gl.calls.contains("glBindTexture"), "recreated storage needs a native rebind"); + assertArrayEquals(before[0], contents(layers.get(0))); + assertArrayEquals(before[1], contents(layers.get(1))); + } + + @Test + public void dirtyArrayRestoresBaselineBeforeApplyingPatchAndPreservesAliasedSource() { + RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); + GLRenderer renderer = gl.renderer(true); + ByteBuffer first = bytes(16, (byte) 1); + ByteBuffer second = bytes(16, (byte) 2); + TextureArray destination = textureArray(Image.Format.RGBA8, 2, 2, + new ArrayList<>(Arrays.asList(first, second)), ColorSpace.Linear); + Image patch = image(Image.Format.RGBA8, 2, 2, second, ColorSpace.Linear); + second.position(2).mark().position(3).limit(7); + SourceState state = new SourceState(second); + renderer.modifyTexture(destination, patch, 0, 0, 0); + state.assertUnchanged(second); + second.reset(); + assertEquals(2, second.position()); + second.position(3); + destination.getImage().setUpdateNeeded(); + gl.clearCalls(); + + renderer.modifyTexture(destination, patch, 0, 0, 0); + + assertEquals(1, gl.arrayAllocations.size()); + assertEquals(3, gl.subImage3dCalls, "dirty arrays restore both baseline layers before the patch"); + assertArrayEquals(contents(second), gl.lastSubImage().data); + assertFalse(destination.getImage().isUpdateNeeded()); + state.assertUnchanged(second); + } + + private static TextureArray textureArray(Image.Format format, int width, int height, + ArrayList layers, ColorSpace colorSpace) { + TextureArray texture = new TextureArray(); + texture.setImage(new Image(format, width, height, 0, layers, colorSpace)); + return texture; + } + + private static Image image(Image.Format format, int width, int height, + ByteBuffer data, ColorSpace colorSpace) { + return new Image(format, width, height, data, colorSpace); + } + + private static ByteBuffer bytes(int count, byte value) { + ByteBuffer buffer = BufferUtils.createByteBuffer(count); + while (buffer.hasRemaining()) { + buffer.put(value); + } + buffer.flip(); + return buffer; + } + + private static ByteBuffer floats(int count, float value) { + ByteBuffer buffer = BufferUtils.createByteBuffer(count * Float.BYTES).order(ByteOrder.nativeOrder()); + FloatBuffer floats = buffer.asFloatBuffer(); + while (floats.hasRemaining()) { + floats.put(value); + } + return buffer; + } + + private static ByteBuffer shorts(int count, int... values) { + ByteBuffer buffer = BufferUtils.createByteBuffer(count * Short.BYTES).order(ByteOrder.nativeOrder()); + for (int value : values) { + buffer.putShort((short) value); + } + buffer.flip(); + return buffer; + } + + private static byte[] contents(ByteBuffer buffer) { + ByteBuffer duplicate = buffer.duplicate(); + duplicate.clear(); + byte[] result = new byte[duplicate.remaining()]; + duplicate.get(result); + return result; + } + + private static final class SourceState { + private final int position; + private final int limit; + private final ByteOrder order; + private final byte[] contents; + + private SourceState(ByteBuffer source) { + position = source.position(); + limit = source.limit(); + order = source.order(); + contents = TextureArrayUpdateTest.contents(source); + } + + private void assertUnchanged(ByteBuffer source) { + assertEquals(position, source.position()); + assertEquals(limit, source.limit()); + assertEquals(order, source.order()); + assertArrayEquals(contents, TextureArrayUpdateTest.contents(source)); + } + } + + private static final class Upload { + int target; + int level; + int internal; + int format; + int type; + int offsetX; + int offsetY; + int offsetZ; + int width; + int height; + int depth = 1; + int activeTexture; + int boundTexture; + byte[] data; + } + + private final class RecordingGl implements InvocationHandler, AutoCloseable { + private final String version; + private final List calls = new ArrayList<>(); + private final List arrayAllocations = new ArrayList<>(); + private final List subImages = new ArrayList<>(); + private final Map boundTextures = new HashMap<>(); + private int activeTexture = GL.GL_TEXTURE0; + private boolean closed; + private boolean failNextSubImage; + private int subImage3dCalls; + private int nextTextureId = 1; + private GLRenderer renderer; + + private RecordingGl(String version) { + this.version = version; + contexts.add(this); + } + + private GLRenderer renderer(boolean textureArray) { + renderer = new GLRenderer(version.contains("3.0") + ? proxy(GL.class, GL2.class, GLES_30.class) + : proxy(GL.class, GL2.class), proxy(GLExt.class), proxy(GLFbo.class)); + renderer.initialize(); + if (textureArray) { + assertTrue(renderer.getCaps().contains(Caps.TextureArray)); + } + return renderer; + } + + private void clearCalls() { + calls.clear(); + arrayAllocations.clear(); + subImages.clear(); + subImage3dCalls = 0; + } + + private Upload lastSubImage() { + return subImages.get(subImages.size() - 1); + } + + private T proxy(Class type) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, this)); + } + + private GL proxy(Class... types) { + return (GL) Proxy.newProxyInstance(GL.class.getClassLoader(), types, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String name = method.getName(); + calls.add(name); + switch (name) { + case "glGetString": + int stringName = (Integer) args[0]; + if (stringName == GL.GL_VERSION) { + return version; + } + if (stringName == GL.GL_SHADING_LANGUAGE_VERSION) { + return version.contains("3.0") ? "OpenGL ES GLSL ES 3.00" : "OpenGL ES GLSL ES 1.00"; + } + if (stringName == GL.GL_EXTENSIONS) { + return ""; + } + return "TextureArrayUpdateTest"; + 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 "glGetFloat": + ((FloatBuffer) args[1]).put(0, 1f); + return null; + case "glGenTextures": + ((IntBuffer) args[0]).put(0, nextTextureId++); + return null; + case "glActiveTexture": + activeTexture = (Integer) args[0]; + return null; + case "glBindTexture": + boundTextures.put(bindingKey(activeTexture, (Integer) args[0]), (Integer) args[1]); + return null; + case "glGenVertexArrays": + ((IntBuffer) args[0]).put(0, 1); + return null; + case "glTexImage3D": + Upload allocation = record(name, args); + arrayAllocations.add(allocation); + return null; + case "glTexSubImage2D": + case "glTexSubImage3D": + Upload upload = record(name, args); + if (name.endsWith("3D")) { + subImage3dCalls++; + } + subImages.add(upload); + if (failNextSubImage) { + failNextSubImage = false; + ((ByteBuffer) args[args.length - 1]).position(1); + throw new IllegalStateException("simulated native upload failure"); + } + return null; + default: + if (method.getReturnType() == boolean.class) { + return false; + } + if (method.getReturnType() == int.class) { + return 0; + } + if (method.getReturnType() == float.class) { + return 0f; + } + if (method.getReturnType() == long.class) { + return 0L; + } + if (method.getReturnType() == void.class) { + return null; + } + throw new AssertionError("Unexpected GL call: " + method); + } + } + + private Upload record(String name, Object[] args) { + Upload upload = new Upload(); + upload.target = (Integer) args[0]; + upload.level = (Integer) args[1]; + upload.activeTexture = activeTexture; + upload.boundTexture = boundTextures.getOrDefault(bindingKey(activeTexture, upload.target), 0); + boolean subImage = name.contains("Sub"); + if (subImage && name.endsWith("3D")) { + upload.offsetX = (Integer) args[2]; + upload.offsetY = (Integer) args[3]; + upload.offsetZ = (Integer) args[4]; + upload.width = (Integer) args[5]; + upload.height = (Integer) args[6]; + upload.depth = (Integer) args[7]; + upload.format = (Integer) args[8]; + upload.type = (Integer) args[9]; + } else if (subImage) { + upload.offsetX = (Integer) args[2]; + upload.offsetY = (Integer) args[3]; + upload.width = (Integer) args[4]; + upload.height = (Integer) args[5]; + upload.format = (Integer) args[6]; + upload.type = (Integer) args[7]; + } else { + upload.internal = (Integer) args[2]; + upload.width = (Integer) args[3]; + upload.height = (Integer) args[4]; + upload.depth = (Integer) args[5]; + upload.format = (Integer) args[7]; + upload.type = (Integer) args[8]; + } + ByteBuffer data = (ByteBuffer) args[args.length - 1]; + if (data != null) { + ByteBuffer copy = data.duplicate(); + byte[] bytes = new byte[copy.remaining()]; + copy.get(bytes); + upload.data = bytes; + } + return upload; + } + + private String bindingKey(int unit, int target) { + return unit + ":" + target; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + if (renderer != null) { + renderer.cleanup(); + } + contexts.remove(this); + } + } +} diff --git a/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java b/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java new file mode 100644 index 0000000000..1de69b1cee --- /dev/null +++ b/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java @@ -0,0 +1,316 @@ +/* + * 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 jme3test.texture; + +import com.jme3.app.SimpleApplication; +import com.jme3.renderer.Caps; +import com.jme3.renderer.Renderer; +import com.jme3.system.AppSettings; +import com.jme3.texture.FrameBuffer; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.TextureArray; +import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.imageio.ImageIO; + +/** + * Asset-free native validation of incremental array updates. Optional arguments + * are the renderer name and output directory. Fails rather than skipping missing + * capabilities. Creates a small PNG from actual array-layer framebuffer readback. + */ +public final class TestTextureArrayUpdate extends SimpleApplication { + private final CountDownLatch finished = new CountDownLatch(1); + private final Path output; + private volatile Throwable failure; + private int comparisons; + + private TestTextureArrayUpdate(Path output) { + this.output = output; + } + + public static void main(String[] args) throws Exception { + String backend = args.length > 0 ? args[0] : AppSettings.LWJGL_OPENGL32; + Path output = Paths.get(args.length > 1 ? args[1] : "build/texture-array-update"); + TestTextureArrayUpdate app = new TestTextureArrayUpdate(output); + AppSettings settings = new AppSettings(true); + settings.setRenderer(backend); + settings.setResolution(320, 240); + settings.setAudioRenderer(null); + settings.setGammaCorrection(true); + settings.setGraphicsDebug(true); + if (args.length > 2 && "all-diagnostics".equals(args[2])) { + settings.setGraphicsTiming(true); + settings.setGraphicsTrace(true); + } + settings.setVSync(false); + app.setSettings(settings); + app.setShowSettings(false); + app.start(); + boolean completed = app.finished.await(45, TimeUnit.SECONDS); + if (!completed || app.failure != null) { + // Failed initialization may leave no render thread to service a blocking stop. + app.stop(false); + throw new IllegalStateException(completed ? "Native array update probe failed" + : "Native array update probe timed out", app.failure); + } + app.stop(true); + if (app.failure != null) { + throw new IllegalStateException("Native array update probe cleanup failed", app.failure); + } + } + + @Override + public void simpleInitApp() { + try { + flyCam.setEnabled(false); + Renderer renderer = renderManager.getRenderer(); + if (!renderer.getCaps().contains(Caps.TextureArray)) { + throw new IllegalStateException("Texture arrays are required for this probe"); + } + normalizedLayers(renderer); + floatingLayers(renderer); + packedSrgbLayers(renderer); + renderer.setFrameBuffer(null); + System.out.println("NATIVE_ARRAY_UPDATE_PASS comparisons=" + comparisons + + " output=" + output.toAbsolutePath()); + } catch (Throwable error) { + failure = error; + } finally { + finished.countDown(); + } + } + + @Override + public void handleError(String message, Throwable error) { + failure = error == null ? new IllegalStateException(message) : error; + System.err.println(message); + failure.printStackTrace(); + finished.countDown(); + } + + private void normalizedLayers(Renderer renderer) throws IOException { + int size = 8; + byte[] first = rgba(size, size, 32, 24, 16, 255); + byte[] second = rgba(size, size, 32, 64, 192, 255); + TextureArray array = array(image(Image.Format.RGBA8, size, size, first, ColorSpace.Linear), + image(Image.Format.RGBA8, size, size, second, ColorSpace.Linear)); + Image yellow = image(Image.Format.RGBA8, 3, 2, rgba(3, 2, 240, 200, 32, 255), ColorSpace.Linear); + Image violet = image(Image.Format.RGBA8, 1, 1, rgba(1, 1, 180, 32, 240, 255), ColorSpace.Linear); + ByteBuffer source = yellow.getData(0); + source.position(1).mark().limit(source.capacity() - 1); + renderer.modifyTexture(array, yellow, 1, 2, 3); + renderer.modifyTexture(array, violet, 1, 0, 0); + require(source.position() == 1 && source.limit() == source.capacity() - 1, "source position/limit"); + source.reset(); + require(source.position() == 1, "source mark"); + byte[] expected = second.clone(); + patch(expected, size, yellow, 2, 3); + patch(expected, size, violet, 0, 0); + byte[] actualFirst = readBytes(renderer, array, 0); + byte[] actualSecond = readBytes(renderer, array, 1); + equal(first, actualFirst, "untouched layer"); + equal(expected, actualSecond, "updated rectangle and untouched texels"); + equal(second, bytes(array.getImage().getData(1)), "destination CPU bytes remain unchanged"); + require(!array.getImage().isUpdateNeeded(), "resident update must not mark full image dirty"); + writePreview(actualFirst, actualSecond, size); + + // GPU-only patches are lost on recreation; retaining/replaying them is caller-owned. + renderer.deleteImage(array.getImage()); + equal(second, readBytes(renderer, array, 1), "recreation uses authoritative CPU backing"); + renderer.modifyTexture(array, yellow, 1, 2, 3); + renderer.modifyTexture(array, violet, 1, 0, 0); + equal(expected, readBytes(renderer, array, 1), "caller replay after recreation"); + equal(first, readBytes(renderer, array, 0), "replay preserves other layer"); + renderer.deleteImage(array.getImage()); + } + + private void floatingLayers(Renderer renderer) { + Image first = floats(4, 4, 123.25f); + Image second = floats(4, 4, -45.5f); + TextureArray array = array(first, second); + Image patch = floats(2, 1, 2000.125f); + patch.getData(0).putFloat(4, -.125f); + renderer.modifyTexture(array, patch, 1, 1, 2); + ByteBuffer untouched = readFloats(renderer, array, 0); + ByteBuffer modified = readFloats(renderer, array, 1); + for (int y = 0; y < 4; y++) { + for (int x = 0; x < 4; x++) { + int offset = (y * 4 + x) * 16; + require(untouched.getFloat(offset) == 123.25f, "untouched R32F height"); + float expected = y == 2 && x == 1 ? 2000.125f : y == 2 && x == 2 ? -.125f : -45.5f; + require(modified.getFloat(offset) == expected, "exact R32F patch height"); + } + } + renderer.deleteImage(array.getImage()); + } + + private void packedSrgbLayers(Renderer renderer) { + Image red = packed(2, 2, 0xf801); + TextureArray array = array(red, packed(2, 2, 0xf801)); + renderer.modifyTexture(array, packed(1, 1, 0x07c1), 1, 1, 0); + byte[] expected = rgba(2, 2, 255, 0, 0, 255); + expected[4] = 0; + expected[5] = (byte) 255; + equal(expected, readBytes(renderer, array, 1), "packed sRGB patch and untouched texels"); + equal(rgba(2, 2, 255, 0, 0, 255), readBytes(renderer, array, 0), "untouched packed sRGB layer"); + renderer.deleteImage(array.getImage()); + } + + private static TextureArray array(Image... layers) { + TextureArray array = new TextureArray(Arrays.asList(layers)); + array.setMinFilter(Texture.MinFilter.NearestNoMipMaps); + array.setMagFilter(Texture.MagFilter.Nearest); + return array; + } + + private static Image image(Image.Format format, int width, int height, byte[] bytes, ColorSpace space) { + ByteBuffer data = BufferUtils.createByteBuffer(bytes.length); + data.put(bytes).flip(); + return new Image(format, width, height, data, space); + } + + private static Image floats(int width, int height, float value) { + ByteBuffer data = BufferUtils.createByteBuffer(width * height * 4).order(ByteOrder.nativeOrder()); + for (int i = 0; i < width * height; i++) { + data.putFloat(value); + } + data.flip(); + return new Image(Image.Format.Luminance32F, width, height, data, ColorSpace.Linear); + } + + private static Image packed(int width, int height, int value) { + ByteBuffer data = BufferUtils.createByteBuffer(width * height * 2).order(ByteOrder.nativeOrder()); + for (int i = 0; i < width * height; i++) { + data.putShort((short) value); + } + data.flip(); + return new Image(Image.Format.RGB5A1, width, height, data, ColorSpace.sRGB); + } + + private static FrameBuffer framebuffer(TextureArray array, int layer) { + Image image = array.getImage(); + FrameBuffer framebuffer = new FrameBuffer(image.getWidth(), image.getHeight(), 1); + framebuffer.addColorTarget(FrameBuffer.FrameBufferTarget.newTarget(array).layer(layer)); + return framebuffer; + } + + private static byte[] readBytes(Renderer renderer, TextureArray array, int layer) { + FrameBuffer framebuffer = framebuffer(array, layer); + ByteBuffer data = BufferUtils.createByteBuffer(array.getImage().getWidth() * array.getImage().getHeight() * 4); + try { + renderer.readFrameBufferWithFormat(framebuffer, data, Image.Format.RGBA8); + return bytes(data); + } finally { + renderer.setFrameBuffer(null); + renderer.deleteFrameBuffer(framebuffer); + } + } + + private static ByteBuffer readFloats(Renderer renderer, TextureArray array, int layer) { + FrameBuffer framebuffer = framebuffer(array, layer); + ByteBuffer data = BufferUtils.createByteBuffer(array.getImage().getWidth() * array.getImage().getHeight() * 16) + .order(ByteOrder.nativeOrder()); + try { + renderer.readFrameBufferWithFormat(framebuffer, data, Image.Format.RGBA32F); + return data; + } finally { + renderer.setFrameBuffer(null); + renderer.deleteFrameBuffer(framebuffer); + } + } + + private static byte[] rgba(int width, int height, int r, int g, int b, int a) { + byte[] bytes = new byte[width * height * 4]; + for (int i = 0; i < bytes.length; i += 4) { + bytes[i] = (byte) r; + bytes[i + 1] = (byte) g; + bytes[i + 2] = (byte) b; + bytes[i + 3] = (byte) a; + } + return bytes; + } + + private static byte[] bytes(ByteBuffer data) { + ByteBuffer copy = data.duplicate(); + copy.clear(); + byte[] bytes = new byte[copy.remaining()]; + copy.get(bytes); + return bytes; + } + + private static void patch(byte[] dest, int width, Image source, int x, int y) { + byte[] data = bytes(source.getData(0)); + for (int row = 0; row < source.getHeight(); row++) { + System.arraycopy(data, row * source.getWidth() * 4, + dest, ((y + row) * width + x) * 4, source.getWidth() * 4); + } + } + + private void require(boolean condition, String description) { + comparisons++; + if (!condition) { + throw new IllegalStateException(description); + } + } + + private void equal(byte[] expected, byte[] actual, String description) { + require(Arrays.equals(expected, actual), description); + } + + private void writePreview(byte[] first, byte[] second, int size) throws IOException { + BufferedImage image = new BufferedImage(size * 2, size, BufferedImage.TYPE_INT_ARGB); + for (int layer = 0; layer < 2; layer++) { + byte[] data = layer == 0 ? first : second; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + int offset = (y * size + x) * 4; + int color = ((data[offset + 3] & 255) << 24) | ((data[offset] & 255) << 16) + | ((data[offset + 1] & 255) << 8) | (data[offset + 2] & 255); + image.setRGB(layer * size + x, size - y - 1, color); + } + } + } + Files.createDirectories(output); + ImageIO.write(image, "png", output.resolve("array-layers.png").toFile()); + } +} From b142d8e5633f35827ac43df2684b928f7b3c18c3 Mon Sep 17 00:00:00 2001 From: Davis Rollman Date: Thu, 12 Nov 2026 09:45:50 -0800 Subject: [PATCH 2/2] Update resident texture arrays without CPU backing A resident array can discard its CPU pixels while retaining GPU data and layer metadata. Only a full upload consumes destination pixels, so keep that validation on allocation/reupload rather than rejecting a patch. Strengthen the existing resident-data regression and extend native readback coverage to check patched and untouched texels after all CPU layers are discarded. Clarify the metadata and replay requirements. Validation: the resident regression fails before the fix. All 41 focused texture tests and style tasks pass. The finite native probe passes 45 comparisons each on desktop OpenGL and ANGLE. --- .../main/java/com/jme3/renderer/Renderer.java | 6 +++-- .../com/jme3/renderer/opengl/TextureUtil.java | 1 - .../opengl/TextureArrayUpdateTest.java | 22 ++++++++++++++----- .../texture/TestTextureArrayUpdate.java | 8 +++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/jme3-core/src/main/java/com/jme3/renderer/Renderer.java b/jme3-core/src/main/java/com/jme3/renderer/Renderer.java index efd8db1ddf..1cc6aa05ec 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/Renderer.java +++ b/jme3-core/src/main/java/com/jme3/renderer/Renderer.java @@ -325,8 +325,10 @@ public void setTexture(int unit, Texture tex) * image data nor marks the image dirty. Callers must retain authoritative * CPU data or replay updates after texture/context recreation or any full * image upload. An unuploaded or dirty destination is first fully uploaded - * using its populated CPU layer buffers. A resident, clean destination - * requires only one layer-region upload; other texels are unchanged. + * and requires complete CPU layer buffers. A resident, clean destination + * requires only one layer-region upload; other texels are unchanged. Its CPU + * layer buffers may be null, but its image data list must retain one entry + * per layer for bounds validation. * * @param dest the destination texture array (not null) * @param pixels the single-buffer 2D source image (not null) 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 1dec2267b3..97b81e1303 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 @@ -591,7 +591,6 @@ GLImageFormat validateArrayLayerUpdate(Image dest, Image src, int layer, int x, throw new IllegalArgumentException("Invalid destination layer or source image buffer count"); } validateLayerBuffer(src.getData(0), src); - validateLayerBuffer(dest.getData(layer), dest); if (dest.isUpdateNeeded() || dest.getId() == -1) { // Initial/full uploads already visit every layer. Resident updates stay O(1). for (ByteBuffer data : dest.getData()) { diff --git a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java index a27657a585..7aaa70be91 100644 --- a/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java +++ b/jme3-core/src/test/java/com/jme3/renderer/opengl/TextureArrayUpdateTest.java @@ -215,7 +215,7 @@ private void assertSubImageTargetsUnitZero(RecordingGl gl, GLRenderer renderer, } @Test - public void residentPatchChecksOnlyTheAddressedBackingLayer() { + public void residentPatchDoesNotRequireCpuBacking() { RecordingGl gl = new RecordingGl("OpenGL ES 3.0"); GLRenderer renderer = gl.renderer(true); ArrayList layers = new ArrayList<>(Arrays.asList( @@ -224,16 +224,26 @@ public void residentPatchChecksOnlyTheAddressedBackingLayer() { Image patch = image(Image.Format.RGBA8, 1, 1, bytes(4, (byte) 2), ColorSpace.Linear); renderer.modifyTexture(destination, patch, 0, 0, 0); // allocate and upload the baseline - layers.set(1, ByteBuffer.allocate(16)); // not visited by a clean resident layer-0 update + // Discard CPU pixels without changing the resident storage or layer count. + layers.replaceAll(data -> null); gl.clearCalls(); - renderer.modifyTexture(destination, patch, 0, 1, 1); + renderer.modifyTexture(destination, patch, 1, 1, 1); - assertEquals(1, gl.subImage3dCalls, "resident update uploads only its requested patch"); + assertEquals(1, gl.subImage3dCalls, "resident update uploads only its source patch"); + assertEquals(0, gl.arrayAllocations.size(), "discarding CPU pixels must not reallocate GPU storage"); Upload update = gl.lastSubImage(); - assertEquals(0, update.offsetZ); + assertEquals(1, update.offsetZ); assertEquals(1, update.offsetX); assertEquals(1, update.offsetY); - assertEquals(0, gl.arrayAllocations.size()); + assertArrayEquals(contents(patch.getData(0)), update.data); + assertFalse(destination.getImage().isUpdateNeeded()); + + // A later full upload still needs complete CPU backing, before any GL mutation. + destination.getImage().setUpdateNeeded(); + gl.clearCalls(); + assertThrows(IllegalArgumentException.class, + () -> renderer.modifyTexture(destination, patch, 1, 1, 1)); + assertTrue(gl.calls.isEmpty(), "missing full-upload data must fail before GL calls"); } @Test diff --git a/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java b/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java index 1de69b1cee..ba72b05312 100644 --- a/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java +++ b/jme3-examples/src/main/java/jme3test/texture/TestTextureArrayUpdate.java @@ -161,6 +161,14 @@ private void normalizedLayers(Renderer renderer) throws IOException { renderer.modifyTexture(array, violet, 1, 0, 0); equal(expected, readBytes(renderer, array, 1), "caller replay after recreation"); equal(first, readBytes(renderer, array, 0), "replay preserves other layer"); + + // A resident array can discard all CPU pixels and still receive patches. + array.getImage().getData().replaceAll(data -> null); + renderer.modifyTexture(array, violet, 0, size - 1, size - 1); + byte[] expectedFirst = first.clone(); + patch(expectedFirst, size, violet, size - 1, size - 1); + equal(expectedFirst, readBytes(renderer, array, 0), "CPU-free patch preserves untouched GPU texels"); + equal(expected, readBytes(renderer, array, 1), "CPU-free patch preserves the other GPU layer"); renderer.deleteImage(array.getImage()); }