Compressing models in J3O format

After the v1.1.0 release of More Advanced Vehicles I looked into why the project takes up so much space on my hard drive.

On my Linux system (ext4 filesystem with 1KB blocks), a freshly cloned repo takes up 151448 blocks, which seems like a lot for such a simple project.

du|sort -r showed that over half the disk space was taken up by a single 62-MByte file: “src/main/resources/Models/cruiser_wheel/cruiser_wheel.gltf.j3o”, a C-G model in J3O format. I supposed this might be a very complicated model, so I loaded it into Maud and examined it. It contains 2 meshes, 7 textures, and very little else!

The meshes have 231 and 290 vertices respectively, so probably the textures account for most of those 62 MBytes. They’re not abnormally large (2-D, with 1024 to 2048 pixels on each side) but they do take up a lot of space. The reason, it seems, is because they lack keys.

A texture’s key consists of its asset path plus a few other details that allow JME to load the texture from an image asset, typically in PNG or JPEG format. According to Texture.java, JME has 2 ways to serialize a texture to a J3O asset. If the texture lacks a key, JME writes the entire com.jme3.texture.Image to the J3O. But if it has a key, JME writes that instead, and when the J3O is loaded, the image is read from the (separate) image asset, which should be highly compressed.

Writing images instead of keys is advantageous because it yields a self-contained J3O asset, one that doesn’t depend on any other assets. But it has 2 major drawbacks:

  1. it defeats JME’s asset cache and
  2. JME serializes the image as an uncompressed buffer.

Adding keys to the cruiser-wheel textures shrank the J3O file to just 68 KBytes, a 925x reduction! And the 7 images, when written to PNG files, total less than 8 MBytes:

sgold:~/Git/Maud/Written Assets/Textures/w$ ls -l
total 7840
-rw-rw-r-- 1 sgold sgold  111305 Dec 15 07:18 0.png
-rw-rw-r-- 1 sgold sgold 1100017 Dec 15 07:18 1.png
-rw-rw-r-- 1 sgold sgold 1686268 Dec 15 07:18 2.png
-rw-rw-r-- 1 sgold sgold 2312010 Dec 15 07:18 3.png
-rw-rw-r-- 1 sgold sgold 1099733 Dec 15 07:19 4.png
-rw-rw-r-- 1 sgold sgold 1686268 Dec 15 07:19 5.png
-rw-rw-r-- 1 sgold sgold   10664 Dec 15 07:19 6.png

PNG does a great job of compressing texture images, especially “6.png”, which appears to be pure white!

Moreover, it turns out that “2.png” and “5.png” are identical; using texture keys, a single copy can be shared between the 2 geometries, saving space both on disk and in RAM. And furthermore, it appears that most of these textures are duplicates (or flipped duplicates) of textures that already exist in the project’s “src/main/resources/Models/GT/textures” folder, which should allow further savings.

Since I didn’t generate the project’s J3Os, I don’t know why some of them have texture keys and some don’t. I’m adding a “create texture key” feature to Maud. And if JMEC and the SDK aren’t aware of keyless textures, perhaps they should be.

The bottom line: if disk space matters to you, make sure all your textures have keys!

12 Likes

I ran into the same issue in Outside Engine. We have some code that extracts the textures from the material on import if they were embedded in the gltf file. From there the challenge is to repath the keys when you move the j3o file. I wrote this little snippet to do that:

public void useOriginalMats(String relocatePath) {
        for (Geometry geo : meshes) {
            Material mat = originalMaterials.get(geo.getName()).clone();
            for (MatParam param : mat.getParams()) {
                //System.out.println("Mat param pre " + param.toString());
                if (param.getVarType() == VarType.Texture2D || param.getVarType() == VarType.Texture3D || param.getVarType() == VarType.TextureArray || param.getVarType() == VarType.TextureCubeMap) {
                    //Update path
                    if (param.getValue() instanceof Texture) {
                        TextureKey currentKey = (TextureKey) ((Texture) param.getValue()).getKey();
                        if (currentKey == null) {
                            continue;
                        }
                        relocatePath = relocatePath.replace("\\", "/");
                        String fileName = FilenameUtils.getName(currentKey.getName());
                        TextureKey key = new TextureKey(relocatePath + "/" + fileName);
                        key.setAnisotropy(currentKey.getAnisotropy());
                        key.setFlipY(currentKey.isFlipY());
                        key.setGenerateMips(currentKey.isGenerateMips());
                        key.setTextureTypeHint(currentKey.getTextureTypeHint());
                        ((Texture) param.getValue()).setKey(key);
                        ((Texture) param.getValue()).setName(relocatePath + "/" + fileName);
                       // System.out.println("Updated " + ((Texture) param.getValue()).toString());

                        //Update matparamtex
                        if (param instanceof MatParamTexture) {
                            ((MatParamTexture) param).setTextureValue(((Texture) param.getValue()));
                        }
                    } else {
                        LOGGER.warning("Ignoring texture type material parameter: " + param);
                    }
                } else {
                    LOGGER.fine("Ignoring retargeting parameter: " + param);
                }
                //System.out.println("Mat param post " + param.toString());
            }
            geo.setMaterial(mat);
        }
    }
1 Like

Nice.

Perhaps there’s some way to tell the glTF importer (or the blender-to-glTF exporter) whether or not you want a self-contained asset. Or perhaps there should be.

Here is the code I use to extract the textures. I am sure there is a better way as this only supports png files, but it is a start.

public void extractTextures(String path) {
        for (Material mat : originalMaterials.values()) {
            for (MatParam param : mat.getParams()) {
                if (param.getVarType() == VarType.Texture2D || param.getVarType() == VarType.Texture3D
                        || param.getVarType() == VarType.TextureArray || param.getVarType() == VarType.TextureCubeMap) {
                    if (param.getValue() instanceof Texture) {
                        Texture tex = (Texture) param.getValue();
                        String file = path + File.separator + tex.getName();
                        Image image = tex.getImage();
                        byte[] raw = image.getData(0).array();
                        try (ByteArrayInputStream dataStream = new ByteArrayInputStream(raw)) {
                            BufferedImage bufferedImage = ImageIO.read(dataStream);
                            ImageIO.write(bufferedImage, "png", new File(file));
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }
1 Like

I wrote my own utility method to extract a pixel from a com.jme3.texture.Image.

I like your way (using ImageIO) but I worry that it might not work for most of the image formats that JME supports. Does ImageIO.read() assume the data stream is in BGR8 format?

1 Like

It will figure out most normal supported formats. But ImageIO has a low of downsides. It does rely on AWT for one. Second, it usually has issues with jpg because of the many, many different jpg formats that exists (thank you Adobe).

I am looking at your your solution, as it probably handles reading other formats better, which can then be easily converted to png. Does RenderedImage renderedImage = MaudUtil.render(image, flipY); require the image to actually render on the screen?

1 Like

Does RenderedImage renderedImage = MaudUtil.render(image, flipY); require the image to actually render on the screen?

No, it doesn’t. I considered using an off-screen render (which would handle all JME image formats) but decided it was too difficult.

Note that randomly choosing PNG files probably isn’t the proper way of doing things. There are quite some factors as well as channels that may or may not benefit from png or want dds (think: mipmaps)

Ultimatively, it is up to the asset creator to not embed the textures.
If we can prevent the gltf exporter to not pack the textures, that’d be fine (I guess it depends on if it’s a bin file or whether textures have been packed in blender).

1 Like

In this case, the original creator didn’t embed the textures. I suspect James embedded them when he separated the wheels from the chassis. I’m have no idea why he did that embedding.

Straightening out that glitch saved 50 MBytes.

I was going to say the JMEC will do this with a simple four line script to set the missing texture keys but reading the code I think I may have left that out:

…it does handle materials and nodes if so annotated and it wouldn’t take much to add textures.

I think hhe gltf exporter should already support separate textures, though.

How about displaying a diagnostic message in case of missing texture keys?

If you mean in JMEC, it’s not really a bug to have embedded textures. And for those who want to see it, it’s a straight forward script to write to add to the command line.

The issue is that just to provide the diagnostic, JMEC would have to drill deeper into the data than it does now by default. The same visitor-style drill in that a script would do.

It does make me wonder what happens if a script chooses to set the key of a material that has embedded textures. It’s one thing to save a Material+embedded textures in a j3o but it gets weird if you try to write that as a j3m.

1 Like