Skip to content

Commit fc6b801

Browse files
authored
Update stb-image port version and improve StbImageLoader (#2772)
1 parent e77e5e5 commit fc6b801

3 files changed

Lines changed: 170 additions & 51 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ nifty-style-black = { module = "com.github.nifty-gui:nifty-style-black",
5353
spotbugs-gradle-plugin = "com.github.spotbugs.snom:spotbugs-gradle-plugin:6.4.8"
5454
vecmath = "javax.vecmath:vecmath:1.5.2"
5555

56-
stb-image = "org.ngengine:stb-image:2.30.4"
56+
stb-image = "org.ngengine:stb-image:2.30.5"
5757
imagewebp = "org.ngengine:image-webp-decoder:1.3.0"
5858
angle = { module = "org.ngengine:angle-natives", version.ref = "angle" }
5959
saferalloc = { module = "org.ngengine:saferalloc", version.ref = "saferalloc" }

jme3-plugins/src/main/java/com/jme3/texture/plugins/StbImageLoader.java

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -58,56 +58,8 @@ public Image load(byte[] data, boolean flipY) throws IOException {
5858

5959
boolean is16bit = info.is16Bit();
6060
boolean isFloat = info.isFloat();
61-
boolean sRGB = false;
6261

63-
64-
Image.Format jmeFormat;
65-
if (is16bit || isFloat) {
66-
switch (channels) {
67-
case 1:
68-
jmeFormat = Image.Format.Luminance16F;
69-
desiredChannels = 1;
70-
break;
71-
case 2:
72-
jmeFormat = Image.Format.Luminance16FAlpha16F;
73-
desiredChannels = 2;
74-
break;
75-
case 3:
76-
jmeFormat = Image.Format.RGB16F;
77-
desiredChannels = 3;
78-
break;
79-
case 4:
80-
jmeFormat = Image.Format.RGBA16F;
81-
desiredChannels = 4;
82-
break;
83-
default:
84-
throw new IOException("Unsupported number of channels: " + channels);
85-
86-
}
87-
} else {
88-
switch (channels) {
89-
case 1:
90-
jmeFormat = Image.Format.Luminance8;
91-
desiredChannels = 1;
92-
break;
93-
case 2:
94-
jmeFormat = Image.Format.Luminance8Alpha8;
95-
desiredChannels = 2;
96-
break;
97-
case 3:
98-
jmeFormat = Image.Format.RGB8;
99-
desiredChannels = 3;
100-
sRGB = true;
101-
break;
102-
case 4:
103-
jmeFormat = Image.Format.RGBA8;
104-
desiredChannels = 4;
105-
sRGB = true;
106-
break;
107-
default:
108-
throw new IOException("Unsupported number of channels: " + channels);
109-
}
110-
}
62+
Image.Format jmeFormat = selectFormat(channels, is16bit, isFloat);
11163

11264
StbImageResult imgData;
11365
if (isFloat){
@@ -118,6 +70,7 @@ public Image load(byte[] data, boolean flipY) throws IOException {
11870
imgData = decoder.load(desiredChannels);
11971
}
12072

73+
boolean sRGB = (jmeFormat == Image.Format.RGB8 || jmeFormat == Image.Format.RGBA8);
12174
ByteBuffer jmeImageBuffer = convertImageData(imgData, jmeFormat);
12275

12376
return new Image(jmeFormat, width, height, jmeImageBuffer, sRGB ? ColorSpace.sRGB : ColorSpace.Linear);
@@ -138,6 +91,36 @@ public Object load(AssetInfo assetInfo) throws IOException {
13891
}
13992
}
14093

94+
/**
95+
* Maps a channel count and bit-depth to the corresponding {@link Image.Format}.
96+
*
97+
* @param channels number of channels (1–4)
98+
* @param is16bit {@code true} for 16-bit-per-channel source data
99+
* @param isFloat {@code true} for floating-point source data
100+
* @return the matching {@link Image.Format}
101+
* @throws IOException if {@code channels} is outside the supported range
102+
*/
103+
private static Image.Format selectFormat(int channels, boolean is16bit, boolean isFloat)
104+
throws IOException {
105+
if (is16bit || isFloat) {
106+
switch (channels) {
107+
case 1: return Image.Format.Luminance16F;
108+
case 2: return Image.Format.Luminance16FAlpha16F;
109+
case 3: return Image.Format.RGB16F;
110+
case 4: return Image.Format.RGBA16F;
111+
default: throw new IOException("Unsupported number of channels: " + channels);
112+
}
113+
} else {
114+
switch (channels) {
115+
case 1: return Image.Format.Luminance8;
116+
case 2: return Image.Format.Luminance8Alpha8;
117+
case 3: return Image.Format.RGB8;
118+
case 4: return Image.Format.RGBA8;
119+
default: throw new IOException("Unsupported number of channels: " + channels);
120+
}
121+
}
122+
}
123+
141124
private ByteBuffer convertImageData(StbImageResult imgData, Image.Format jmeFormat) {
142125
int outputSize = jmeFormat.getBitsPerPixel() / 8 * imgData.getWidth() * imgData.getHeight();
143126
ByteBuffer jmeImageBuffer = BufferUtils.createByteBuffer(outputSize);
@@ -151,7 +134,7 @@ private ByteBuffer convertImageData(StbImageResult imgData, Image.Format jmeForm
151134
return jmeImageBuffer;
152135
}
153136

154-
int sampleCount = imgData.getWidth() * imgData.getHeight() * imgData.getRequestedChannels();
137+
int sampleCount = imgData.getWidth() * imgData.getHeight() * imgData.getChannels();
155138
if (imgData.is16Bit()) {
156139
for (int i = 0; i < sampleCount; i++) {
157140
float value = (source.getShort() & 0xFFFF) / 65535f;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright (c) 2024 jMonkeyEngine
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are
7+
* met:
8+
*
9+
* * Redistributions of source code must retain the above copyright
10+
* notice, this list of conditions and the following disclaimer.
11+
*
12+
* * Redistributions in binary form must reproduce the above copyright
13+
* notice, this list of conditions and the following disclaimer in the
14+
* documentation and/or other materials provided with the distribution.
15+
*
16+
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors
17+
* may be used to endorse or promote products derived from this software
18+
* without specific prior written permission.
19+
*
20+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
22+
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
23+
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
24+
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25+
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26+
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
27+
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
28+
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
29+
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31+
*/
32+
package com.jme3.texture.plugins;
33+
34+
import com.jme3.texture.Image;
35+
import org.junit.jupiter.api.Assertions;
36+
import org.junit.jupiter.api.Test;
37+
38+
import java.io.IOException;
39+
40+
/**
41+
* Tests for {@link StbImageLoader}, covering formats whose channel count is
42+
* not reported by the decoder's {@code info()} method (e.g. TGA).
43+
*/
44+
public class StbImageLoaderTest {
45+
46+
/**
47+
* Creates a minimal, valid TGA image as a byte array.
48+
*
49+
* @param width image width in pixels
50+
* @param height image height in pixels
51+
* @param bpp bits per pixel: 24 (RGB) or 32 (RGBA)
52+
* @return raw TGA bytes
53+
*/
54+
private static byte[] makeTga(int width, int height, int bpp) {
55+
int bytesPerPixel = bpp / 8;
56+
int pixelDataSize = width * height * bytesPerPixel;
57+
// TGA header is 18 bytes, followed by pixel data
58+
byte[] tga = new byte[18 + pixelDataSize];
59+
60+
tga[0] = 0; // ID length
61+
tga[1] = 0; // Color map type: none
62+
tga[2] = 2; // Image type: uncompressed true-color
63+
// Color map spec (5 bytes): all zero
64+
// Image spec:
65+
tga[8] = 0; tga[9] = 0; // X origin
66+
tga[10] = 0; tga[11] = 0; // Y origin
67+
tga[12] = (byte) (width & 0xFF); tga[13] = (byte) ((width >> 8) & 0xFF);
68+
tga[14] = (byte) (height & 0xFF); tga[15] = (byte) ((height >> 8) & 0xFF);
69+
tga[16] = (byte) bpp; // Bits per pixel
70+
tga[17] = 0x20; // Image descriptor: top-left origin
71+
72+
// Fill pixel data with a simple pattern (stored as BGR or BGRA in TGA)
73+
for (int i = 0; i < width * height; i++) {
74+
int base = 18 + i * bytesPerPixel;
75+
tga[base] = (byte) 0xFF; // B
76+
tga[base + 1] = (byte) 0x80; // G
77+
tga[base + 2] = (byte) 0x40; // R
78+
if (bpp == 32) {
79+
tga[base + 3] = (byte) 0xFF; // A
80+
}
81+
}
82+
return tga;
83+
}
84+
85+
/**
86+
* Verifies that a 24-bit (RGB) TGA image loads without throwing an exception
87+
* and is decoded as {@link Image.Format#RGB8}.
88+
*
89+
* <p>This is a regression test for the bug where TGA images caused an
90+
* {@code IOException("Unsupported number of channels: 0")} because the
91+
* stb-image {@code TgaDecoder.info()} did not set the channel count.
92+
*/
93+
@Test
94+
public void testLoad24BitTga() throws IOException {
95+
byte[] tgaData = makeTga(2, 2, 24);
96+
StbImageLoader loader = new StbImageLoader();
97+
Image image = loader.load(tgaData, false);
98+
99+
Assertions.assertNotNull(image, "Image must not be null");
100+
Assertions.assertEquals(Image.Format.RGB8, image.getFormat(),
101+
"24-bit TGA should be decoded as RGB8");
102+
Assertions.assertEquals(2, image.getWidth());
103+
Assertions.assertEquals(2, image.getHeight());
104+
}
105+
106+
/**
107+
* Verifies that a grayscale (8-bit, 1-channel) TGA image loads correctly
108+
* as {@link Image.Format#Luminance8}.
109+
*/
110+
@Test
111+
public void testLoadGrayscaleTga() throws IOException {
112+
// Build a 1-channel (grayscale) TGA: image type 3 = uncompressed grayscale
113+
int width = 2, height = 2;
114+
byte[] tga = new byte[18 + width * height];
115+
tga[0] = 0; // ID length
116+
tga[1] = 0; // Color map type: none
117+
tga[2] = 3; // Image type: uncompressed grayscale
118+
tga[12] = (byte) width;
119+
tga[14] = (byte) height;
120+
tga[16] = 8; // 8 bits per pixel
121+
tga[17] = 0x20; // top-left origin
122+
// Fill pixel data
123+
for (int i = 0; i < width * height; i++) {
124+
tga[18 + i] = (byte) (i * 64);
125+
}
126+
127+
StbImageLoader loader = new StbImageLoader();
128+
Image image = loader.load(tga, false);
129+
130+
Assertions.assertNotNull(image, "Image must not be null");
131+
Assertions.assertEquals(Image.Format.Luminance8, image.getFormat(),
132+
"Grayscale TGA should be decoded as Luminance8");
133+
Assertions.assertEquals(width, image.getWidth());
134+
Assertions.assertEquals(height, image.getHeight());
135+
}
136+
}

0 commit comments

Comments
 (0)