diff --git a/.gitignore b/.gitignore index 3d9235f..3eabad0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,15 @@ /jsplat-io-spz/.settings /jsplat-io-spz/.classpath -/jsplat-io-spz-gltf/target -/jsplat-io-spz-gltf/.project -/jsplat-io-spz-gltf/.settings -/jsplat-io-spz-gltf/.classpath +/jsplat-io-gltf/target +/jsplat-io-gltf/.project +/jsplat-io-gltf/.settings +/jsplat-io-gltf/.classpath + +/jsplat-io-gltf-spz/target +/jsplat-io-gltf-spz/.project +/jsplat-io-gltf-spz/.settings +/jsplat-io-gltf-spz/.classpath /jsplat-viewer/target /jsplat-viewer/.project diff --git a/README.md b/README.md index eb05346..ec4db96 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Java libraries for Gaussian splats. - [`jsplat-io-gsplat`](./jsplat-io-gsplat) - [gsplat](https://github.com/antimatter15/splat) format - [`jsplat-io-ply`](./jsplat-io-ply) - PLY files - [`jsplat-io-spz`](./jsplat-io-spz) - [SPZ](https://github.com/nianticlabs/spz) format - - [`jsplat-io-spz-gltf`](./jsplat-io-spz-gltf) - glTF with [`KHR_spz_gaussian_splats_compression`](https://github.com/KhronosGroup/glTF/pull/2490) + - [`jsplat-io-gltf`](./jsplat-io-gltf) - glTF with [`KHR_gaussian_splatting`](https://github.com/KhronosGroup/glTF/pull/2490) + - [`jsplat-io-gltf-spz`](./jsplat-io-gltf-spz) - glTF with [`KHR_spz_gaussian_splatting_compression_spz_2`](https://github.com/KhronosGroup/glTF/pull/2531) A basic viewer implementation can be found in [`jsplat-viewer`](./jsplat-viewer), with the actual implementation based on LWJGL in [`jsplat-viewer-lwjgl`](./jsplat-viewer-lwjgl). diff --git a/jsplat-app/pom.xml b/jsplat-app/pom.xml index 342c20e..df42899 100644 --- a/jsplat-app/pom.xml +++ b/jsplat-app/pom.xml @@ -35,7 +35,12 @@ de.javagl - jsplat-io-spz-gltf + jsplat-io-gltf-spz + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-gltf 0.0.1-SNAPSHOT diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java new file mode 100644 index 0000000..bc6727b --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java @@ -0,0 +1,190 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Container; +import java.io.File; + +import javax.swing.BorderFactory; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.Document; + +import de.javagl.common.ui.GuiUtils; + +/** + * A base class for a panel that serves as an accessory for the save file + * chooser, enabled based on the current file extension. + */ +class ExtensionBasedSaveOptions extends JPanel +{ + /** + * Serial UID + */ + private static final long serialVersionUID = 370867818308684812L; + + /** + * The file chooser + */ + private JFileChooser fileChooser; + + /** + * The extension that should cause this panel to become enabled, including + * the dot. + */ + private String extensionWithDot; + + /** + * The file name text field, obtained from the file chooser (quirky) + */ + private JTextField fileNameTextField; + + /** + * Creates a new instance + * + * @param fileChooser The file chooser + * @param title The title + * @param extensionWithDot The file extension, including the dot + */ + ExtensionBasedSaveOptions(JFileChooser fileChooser, String title, + String extensionWithDot) + { + super(new BorderLayout()); + this.fileChooser = fileChooser; + this.extensionWithDot = extensionWithDot.toLowerCase(); + + setBorder(BorderFactory.createTitledBorder(title)); + + fileChooser.addPropertyChangeListener(e -> updateOptions()); + + fileNameTextField = findFileNameTextField(fileChooser); + installDocumentListener(); + + updateOptions(); + } + + /** + * Install a document listener to the file name text field to update the + * options when the file name changes + */ + private void installDocumentListener() + { + if (fileNameTextField == null) + { + return; + } + Document document = fileNameTextField.getDocument(); + document.addDocumentListener(new DocumentListener() + { + @Override + public void removeUpdate(DocumentEvent e) + { + updateOptions(); + } + + @Override + public void insertUpdate(DocumentEvent e) + { + updateOptions(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + updateOptions(); + } + }); + } + + /** + * Returns the current file name from the file name text field + * + * @return The file name + */ + private String getCurrentFileName() + { + if (fileNameTextField != null) + { + return fileNameTextField.getText(); + } + File file = fileChooser.getSelectedFile(); + if (file != null) + { + return file.toString(); + } + return null; + } + + /** + * Update the options based on the current file name + */ + private void updateOptions() + { + String fileName = getCurrentFileName(); + if (fileName == null) + { + GuiUtils.setDeepEnabled(this, false); + return; + } + boolean isPly = fileName.toLowerCase().endsWith(extensionWithDot); + GuiUtils.setDeepEnabled(this, isPly); + } + + /** + * Find the first text field in the given container, returning + * null if none is found + * + * @param container The container + * @return The text field + */ + private static JTextField findFileNameTextField(Container container) + { + for (Component component : container.getComponents()) + { + if (component instanceof JTextField) + { + return (JTextField) component; + } + else if (component instanceof Container) + { + Container childContainer = (Container) component; + JTextField textField = findFileNameTextField(childContainer); + if (textField != null) + { + return textField; + } + } + } + return null; + } + +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java new file mode 100644 index 0000000..5a27a88 --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java @@ -0,0 +1,91 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.awt.BorderLayout; +import java.awt.GridLayout; + +import javax.swing.ButtonGroup; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JRadioButton; + +/** + * A panel that serves as an accessory for the save file chooser, to select the + * compression that should be applied in GLB files + */ +class GlbSaveOptions extends ExtensionBasedSaveOptions +{ + /** + * Serial UID + */ + private static final long serialVersionUID = 37867883086984812L; + + /** + * The button for no compression + */ + private JRadioButton noneButton; + + /** + * The button for SPZ compression + */ + private JRadioButton spzButton; + + /** + * Creates a new instance + * + * @param fileChooser The file chooser + */ + GlbSaveOptions(JFileChooser fileChooser) + { + super(fileChooser, "GLB save options", ".glb"); + + noneButton = new JRadioButton("No compression"); + noneButton.setSelected(true); + spzButton = new JRadioButton("SPZ compression"); + + ButtonGroup group = new ButtonGroup(); + group.add(noneButton); + group.add(spzButton); + + JPanel panel = new JPanel(new GridLayout(0, 1)); + panel.add(noneButton); + panel.add(spzButton); + add(panel, BorderLayout.NORTH); + } + + /** + * Returns whether SPZ compression should be applied + * + * @return The state + */ + boolean shouldApplySpzCompression() + { + return spzButton.isSelected(); + } + +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSplatListReader.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSplatListReader.java new file mode 100644 index 0000000..8b55689 --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSplatListReader.java @@ -0,0 +1,121 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import de.javagl.jgltf.impl.v2.GlTF; +import de.javagl.jgltf.model.io.GltfAsset; +import de.javagl.jgltf.model.io.GltfAssetReader; +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.SplatListReader; +import de.javagl.jsplat.io.gltf.GltfSplatReader; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatReader; + +/** + * Internal implementation of a SplatListReader for GLB data. + * + * Yeah, it's a bit quirky: It reads the GLB data and creates a glTF asset, + * checks whether this asset uses the SPZ compression extension, and either + * dispatches to a {@link GltfSplatReader} or {@link GltfSpzSplatReader}. + */ +class GlbSplatListReader implements SplatListReader +{ + @Override + public List readList(InputStream inputStream) + throws IOException + { + byte data[] = readFully(inputStream); + boolean usesSpz = usesSpz(data); + if (usesSpz) + { + GltfSpzSplatReader sr = new GltfSpzSplatReader(); + return sr.readList(new ByteArrayInputStream(data)); + } + GltfSplatReader sr = new GltfSplatReader(); + return sr.readList(new ByteArrayInputStream(data)); + } + + /** + * Returns whether the given GLB data uses the + * KHR_gaussian_splatting_compression_spz_2 extension + * + * @param glbData The GLB data + * @return The result + */ + private static boolean usesSpz(byte glbData[]) + { + GltfAssetReader ar = new GltfAssetReader(); + try (ByteArrayInputStream bais = new ByteArrayInputStream(glbData)) + { + GltfAsset gltfAsset = ar.readWithoutReferences(bais); + GlTF gltf = (GlTF) gltfAsset.getGltf(); + List extensionsUsed = gltf.getExtensionsUsed(); + if (extensionsUsed == null) + { + return false; + } + if (extensionsUsed + .contains("KHR_gaussian_splatting_compression_spz_2")) + { + return true; + } + } + catch (IOException e) + { + return false; + } + return false; + } + + /** + * Read the given input stream into a byte array + * + * @param inputStream The input stream + * @return The byte array + * @throws IOException If an IO error occurs + */ + private static byte[] readFully(InputStream inputStream) throws IOException + { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] data = new byte[16384]; + while (true) + { + int read = inputStream.read(data, 0, data.length); + if (read == -1) + { + break; + } + baos.write(data, 0, read); + } + return baos.toByteArray(); + } +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java index 6b3a162..2033099 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java @@ -26,6 +26,7 @@ */ package de.javagl.jsplat.app; +import java.awt.GridLayout; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.io.File; @@ -49,6 +50,7 @@ import javax.swing.JMenuBar; import javax.swing.JMenuItem; import javax.swing.JOptionPane; +import javax.swing.JPanel; import javax.swing.JSeparator; import javax.swing.filechooser.FileNameExtensionFilter; @@ -58,6 +60,8 @@ import de.javagl.jsplat.app.common.UriLoading; import de.javagl.jsplat.app.common.UriTransferHandler; import de.javagl.jsplat.app.common.UriUtils; +import de.javagl.jsplat.io.gltf.GltfSplatWriter; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatWriter; import de.javagl.jsplat.io.gsplat.GsplatSplatReader; import de.javagl.jsplat.io.gsplat.GsplatSplatWriter; import de.javagl.jsplat.io.ply.PlySplatReader; @@ -65,8 +69,6 @@ import de.javagl.jsplat.io.ply.PlySplatWriter.PlyFormat; import de.javagl.jsplat.io.spz.SpzSplatReader; import de.javagl.jsplat.io.spz.SpzSplatWriter; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatReader; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatWriter; import de.javagl.swing.tasks.SwingTask; import de.javagl.swing.tasks.SwingTaskExecutors; @@ -184,7 +186,12 @@ public void actionPerformed(ActionEvent e) * The {@link PlySaveOptions} used as an accessory for the save file chooser */ private PlySaveOptions plySaveOptions; - + + /** + * The {@link GlbSaveOptions} used as an accessory for the save file chooser + */ + private GlbSaveOptions glbSaveOptions; + /** * The {@link JSplatApplicationPanel} */ @@ -195,7 +202,6 @@ public void actionPerformed(ActionEvent e) */ private List currentSplats; - /** * Default constructor */ @@ -218,8 +224,14 @@ public void actionPerformed(ActionEvent e) "glb")); saveFileChooser = new JFileChooser("."); + + JPanel accessory = new JPanel(new GridLayout(0, 1)); plySaveOptions = new PlySaveOptions(saveFileChooser); - saveFileChooser.setAccessory(plySaveOptions); + glbSaveOptions = new GlbSaveOptions(saveFileChooser); + accessory.add(plySaveOptions); + accessory.add(glbSaveOptions); + saveFileChooser.setAccessory(accessory); + saveFileChooser.setFileFilter(new FileNameExtensionFilter( "Splat Files (.splat, .ply, .spz, .glb)", "splat", "ply", "spz", "glb")); @@ -394,7 +406,7 @@ private static SplatListReader findReader(String fileName) } if (name.endsWith("glb")) { - return new SpzGltfSplatReader(); + return new GlbSplatListReader(); } logger.warning( "Could not determine type from file name for '" + fileName + "'"); @@ -442,7 +454,7 @@ private void saveFile(File file) } saveInBackground(writer, file); } - + /** * Find a writer for the file with the given name, based on the file * extension, case-insensitively. If no writer can be found, then @@ -460,7 +472,7 @@ private SplatListWriter findWriter(String fileName) } if (name.endsWith("ply")) { - PlyFormat plyFormat = plySaveOptions.getPlyFormat(); + PlyFormat plyFormat = plySaveOptions.getPlyFormat(); return new PlySplatWriter(plyFormat); } if (name.endsWith("spz")) @@ -469,7 +481,11 @@ private SplatListWriter findWriter(String fileName) } if (name.endsWith("glb")) { - return new SpzGltfSplatWriter(); + if (glbSaveOptions.shouldApplySpzCompression()) + { + return new GltfSpzSplatWriter(); + } + return new GltfSplatWriter(); } logger.warning( "Could not determine type from file name for '" + fileName + "'"); diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java index 7528af8..bfb7364 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java @@ -27,39 +27,25 @@ package de.javagl.jsplat.app; import java.awt.BorderLayout; -import java.awt.Component; -import java.awt.Container; import java.awt.GridLayout; -import java.io.File; -import javax.swing.BorderFactory; import javax.swing.ButtonGroup; import javax.swing.JFileChooser; import javax.swing.JPanel; import javax.swing.JRadioButton; -import javax.swing.JTextField; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; -import javax.swing.text.Document; -import de.javagl.common.ui.GuiUtils; import de.javagl.jsplat.io.ply.PlySplatWriter.PlyFormat; /** * A panel that serves as an accessory for the save file chooser, to select the * {@link PlyFormat} that should be used for saving a PLY file */ -class PlySaveOptions extends JPanel +class PlySaveOptions extends ExtensionBasedSaveOptions { /** * Serial UID */ - private static final long serialVersionUID = 3708678183086984812L; - - /** - * The file chooser - */ - private JFileChooser fileChooser; + private static final long serialVersionUID = 370867883086984812L; /** * The binary LE button @@ -76,11 +62,6 @@ class PlySaveOptions extends JPanel */ private JRadioButton asciiButton; - /** - * The file name text field, obtained from the file chooser (quirky) - */ - private JTextField fileNameTextField; - /** * Creates a new instance * @@ -88,10 +69,7 @@ class PlySaveOptions extends JPanel */ PlySaveOptions(JFileChooser fileChooser) { - super(new BorderLayout()); - this.fileChooser = fileChooser; - - setBorder(BorderFactory.createTitledBorder("PLY save options")); + super(fileChooser, "PLY save options", ".ply"); binaryLeButton = new JRadioButton("Binary (Little Endian)"); binaryLeButton.setSelected(true); @@ -108,13 +86,6 @@ class PlySaveOptions extends JPanel panel.add(binaryBeButton); panel.add(asciiButton); add(panel, BorderLayout.NORTH); - - fileChooser.addPropertyChangeListener(e -> updateOptions()); - - fileNameTextField = findFileNameTextField(fileChooser); - installDocumentListener(); - - updateOptions(); } /** @@ -139,99 +110,4 @@ PlyFormat getPlyFormat() return PlyFormat.BINARY_LITTLE_ENDIAN; } - /** - * Install a document listener to the file name text field to update the - * options when the file name changes - */ - private void installDocumentListener() - { - if (fileNameTextField == null) - { - return; - } - Document document = fileNameTextField.getDocument(); - document.addDocumentListener(new DocumentListener() - { - @Override - public void removeUpdate(DocumentEvent e) - { - updateOptions(); - } - - @Override - public void insertUpdate(DocumentEvent e) - { - updateOptions(); - } - - @Override - public void changedUpdate(DocumentEvent e) - { - updateOptions(); - } - }); - } - - /** - * Returns the current file name from the file name text field - * - * @return The file name - */ - private String getCurrentFileName() - { - if (fileNameTextField != null) - { - return fileNameTextField.getText(); - } - File file = fileChooser.getSelectedFile(); - if (file != null) - { - return file.toString(); - } - return null; - } - - /** - * Update the options based on the current file name - */ - private void updateOptions() - { - String fileName = getCurrentFileName(); - if (fileName == null) - { - GuiUtils.setDeepEnabled(this, false); - return; - } - boolean isPly = fileName.toLowerCase().endsWith(".ply"); - GuiUtils.setDeepEnabled(this, isPly); - } - - /** - * Find the first text field in the given container, returning - * null if none is found - * - * @param container The container - * @return The text field - */ - private static JTextField findFileNameTextField(Container container) - { - for (Component component : container.getComponents()) - { - if (component instanceof JTextField) - { - return (JTextField) component; - } - else if (component instanceof Container) - { - Container childContainer = (Container) component; - JTextField textField = findFileNameTextField(childContainer); - if (textField != null) - { - return textField; - } - } - } - return null; - } - } diff --git a/jsplat-examples/pom.xml b/jsplat-examples/pom.xml index dbe8569..5cf9b26 100644 --- a/jsplat-examples/pom.xml +++ b/jsplat-examples/pom.xml @@ -34,7 +34,12 @@ de.javagl - jsplat-io-spz-gltf + jsplat-io-gltf + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-gltf-spz 0.0.1-SNAPSHOT diff --git a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatGltfRoundtripExperiments.java b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatGltfRoundtripExperiments.java index 842ca97..42cfc75 100644 --- a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatGltfRoundtripExperiments.java +++ b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatGltfRoundtripExperiments.java @@ -13,8 +13,8 @@ import de.javagl.jsplat.MutableSplat; import de.javagl.jsplat.Splat; import de.javagl.jsplat.Splats; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatReader; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatWriter; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatReader; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatWriter; /** * Experiments for writing and reading SPZ-compressed Gaussian splats in glTF. @@ -32,16 +32,13 @@ public class JSplatGltfRoundtripExperiments */ public static void main(String[] args) throws IOException { - // Whether the KHR_gaussian_splatting base extension should be used. - boolean useBaseExtension = true; - List splatsA = UnitCubeSplats.create(); - SpzGltfSplatWriter w = new SpzGltfSplatWriter(useBaseExtension); + GltfSpzSplatWriter w = new GltfSpzSplatWriter(); ByteArrayOutputStream os = new ByteArrayOutputStream(); w.writeList(splatsA, os); - - SpzGltfSplatReader r = new SpzGltfSplatReader(useBaseExtension); + + GltfSpzSplatReader r = new GltfSpzSplatReader(); ByteArrayInputStream is = new ByteArrayInputStream(os.toByteArray()); List splatsB = r.readList(is); diff --git a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatRoundtrips.java b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatRoundtrips.java index a848c50..40ecc60 100644 --- a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatRoundtrips.java +++ b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/JSplatRoundtrips.java @@ -46,24 +46,27 @@ public class JSplatRoundtrips public static void main(String[] args) throws IOException { LoggerUtil.initLogging(); - + Files.createDirectories(Paths.get(BASE_DIRECTORY)); FORMATS = Arrays.asList(SplatFormat.GSPLAT, SplatFormat.PLY_BINARY_LE, - SplatFormat.PLY_ASCII, SplatFormat.SPZ); + SplatFormat.PLY_BINARY_BE, SplatFormat.PLY_ASCII, SplatFormat.SPZ, + SplatFormat.SPZ_GLTF, SplatFormat.GLTF); + +// writeAll("rotations2D", SplatGrids.createRotations2D()); +// writeAll("rotations", SplatGrids.createRotations()); +// writeAll("shs1", SplatGrids.createShs1()); +// writeAll("shs2", SplatGrids.createShs2()); +// writeAll("shs3", SplatGrids.createShs3()); +// writeAll("unitCube", UnitCubeSplats.create()); + - writeAll("rotations2D", SplatGrids.createRotations2D()); - writeAll("rotations", SplatGrids.createRotations()); - writeAll("shs1", SplatGrids.createShs1()); - writeAll("shs2", SplatGrids.createShs2()); - writeAll("shs3", SplatGrids.createShs3()); writeAll("unitCube", UnitCubeSplats.create()); - - //roundtripAll("unitCube"); + roundtripAll("unitCube"); - //float epsilon = 1e-6f; - //float epsilon = 0.02f; - //verifySingle(SplatGrids.createRotations(), SplatFormat.SPZ, epsilon); + // float epsilon = 1e-6f; + // float epsilon = 0.02f; + // verifySingle(SplatGrids.createRotations(), SplatFormat.SPZ, epsilon); } private static List createFormats() diff --git a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatFormat.java b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatFormat.java index 256d2b8..91a085a 100644 --- a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatFormat.java +++ b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatFormat.java @@ -38,5 +38,11 @@ enum SplatFormat /** * SPZ format embedded in binary glTF */ - SPZ_GLTF + SPZ_GLTF, + + /** + * glTF format (without compression), stored as binary glTF + */ + GLTF + } \ No newline at end of file diff --git a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/Utils.java b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/Utils.java index 4c6a810..f273db3 100644 --- a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/Utils.java +++ b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/Utils.java @@ -18,6 +18,10 @@ import de.javagl.jsplat.Splat; import de.javagl.jsplat.SplatListReader; import de.javagl.jsplat.SplatListWriter; +import de.javagl.jsplat.io.gltf.GltfSplatReader; +import de.javagl.jsplat.io.gltf.GltfSplatWriter; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatReader; +import de.javagl.jsplat.io.gltf.spz.GltfSpzSplatWriter; import de.javagl.jsplat.io.gsplat.GsplatSplatReader; import de.javagl.jsplat.io.gsplat.GsplatSplatWriter; import de.javagl.jsplat.io.ply.PlySplatReader; @@ -25,8 +29,6 @@ import de.javagl.jsplat.io.ply.PlySplatWriter.PlyFormat; import de.javagl.jsplat.io.spz.SpzSplatReader; import de.javagl.jsplat.io.spz.SpzSplatWriter; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatReader; -import de.javagl.jsplat.io.spz.gltf.SpzGltfSplatWriter; /** * Utilities for the JSplat examples @@ -58,7 +60,9 @@ static SplatListReader createReader(SplatFormat format) case SPZ: return new SpzSplatReader(); case SPZ_GLTF: - return new SpzGltfSplatReader(); + return new GltfSpzSplatReader(); + case GLTF: + return new GltfSplatReader(); } logger.severe("Unknown format: " + format); return null; @@ -85,7 +89,9 @@ static SplatListWriter createWriter(SplatFormat format) case SPZ: return new SpzSplatWriter(); case SPZ_GLTF: - return new SpzGltfSplatWriter(); + return new GltfSpzSplatWriter(); + case GLTF: + return new GltfSplatWriter(); } logger.severe("Unknown format: " + format); return null; @@ -127,6 +133,7 @@ static String createFileExtension(SplatFormat format) case SPZ: return "spz"; case SPZ_GLTF: + case GLTF: return "glb"; } logger.severe("Unknown format: " + format); diff --git a/jsplat-io-gltf-spz/README.md b/jsplat-io-gltf-spz/README.md new file mode 100644 index 0000000..5aa4659 --- /dev/null +++ b/jsplat-io-gltf-spz/README.md @@ -0,0 +1,4 @@ +# jsplat-io-gltf-spz + +Classes for reading and writing Gaussian splats in glTF format, +using the KHR_gaussian_splatting_compression_spz_2 extension. \ No newline at end of file diff --git a/jsplat-io-spz-gltf/pom.xml b/jsplat-io-gltf-spz/pom.xml similarity index 96% rename from jsplat-io-spz-gltf/pom.xml rename to jsplat-io-gltf-spz/pom.xml index f5256b8..982736e 100644 --- a/jsplat-io-spz-gltf/pom.xml +++ b/jsplat-io-gltf-spz/pom.xml @@ -3,7 +3,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - jsplat-io-spz-gltf + jsplat-io-gltf-spz ${project.groupId}:${project.artifactId} A splat library for Java diff --git a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatReader.java b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatReader.java similarity index 57% rename from jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatReader.java rename to jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatReader.java index c26cda8..0e2f992 100644 --- a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatReader.java +++ b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatReader.java @@ -24,15 +24,17 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ -package de.javagl.jsplat.io.spz.gltf; +package de.javagl.jsplat.io.gltf.spz; import java.io.IOException; import java.io.InputStream; import java.nio.Buffer; import java.nio.ByteBuffer; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.logging.Logger; import de.javagl.jgltf.impl.v2.BufferView; import de.javagl.jgltf.impl.v2.GlTF; @@ -50,36 +52,34 @@ /** * Implementation of a {@link SplatListReader} that reads glTF data with the - * SPZ-based Gaussian splat extension. - * - * NOTE: This class is preliminary, and tries to track the development of - * https://github.com/KhronosGroup/glTF/pull/2490. Some details of this - * class depend on compile-time flags that may change in the future. + * Gaussian splat data using the KHR_gaussian_splatting_compression_spz_2 + * extension. */ -public final class SpzGltfSplatReader implements SplatListReader +public final class GltfSpzSplatReader implements SplatListReader { /** - * Preliminary configuration settings + * The logger used in this class */ - private final SpzGltfConfig config; - + private static final Logger logger = + Logger.getLogger(GltfSpzSplatReader.class.getName()); + /** - * Creates a new instance + * The base extension name (and attribute prefix) */ - public SpzGltfSplatReader() - { - this(false); - } + private static final String BASE_NAME = "KHR_gaussian_splatting"; + + /** + * The extension name + */ + private static final String NAME = + "KHR_gaussian_splatting_compression_spz_2"; /** * Creates a new instance - * - * @param useBaseExtension Experimental - * @deprecated Experimental support for the base extension */ - public SpzGltfSplatReader(boolean useBaseExtension) + public GltfSpzSplatReader() { - this.config = new SpzGltfConfig(useBaseExtension); + // Default constructor } @Override @@ -157,83 +157,152 @@ private static ByteBuffer extractBufferViewData(GltfAssetV2 gltfAsset, byteOffset = 0; } Integer byteLength = bufferView.getByteLength(); - - // Workaround for NoSuchMethodError when compiling and + + // Workaround for NoSuchMethodError when compiling and // using with newer JDKs - ((Buffer)binaryData).limit(byteOffset + byteLength); - ((Buffer)binaryData).position(byteOffset); + ((Buffer) binaryData).limit(byteOffset + byteLength); + ((Buffer) binaryData).position(byteOffset); ByteBuffer spzData = binaryData.slice(); return spzData; } /** - * Returns the bufferView from the - * KHR_spz_gaussian_splats_compression extension object of the - * given primitive, or null if this object does not have this - * extension. + * Returns the bufferView index for the SPZ data from the given + * mesh primitive. + * + * This will try to fall back to different legacy extension versions. Not + * all of these legacy versions may be fully supported... * - * @param primitive The {@link MeshPrimitive} + * @param meshPrimitive The {@link MeshPrimitive} * @return The buffer view index */ - private Integer getExtensionBufferViewIndex(MeshPrimitive primitive) + private Integer getExtensionBufferViewIndex(MeshPrimitive meshPrimitive) + { + Integer bufferViewIndex = + getFinalExtensionBufferViewIndex(meshPrimitive); + if (bufferViewIndex != null) + { + return bufferViewIndex; + } + return getLegacyExtensionBufferViewIndex(meshPrimitive); + } + + /** + * Returns the index of the buffer view that stores the SPZ data, using the + * "final" form of the extension specification. + * + * If it can not be found, then null is returned. + * + * Details omitted here. + * + * @param meshPrimitive The mesh primitive + * @return The index + */ + private static Integer + getFinalExtensionBufferViewIndex(MeshPrimitive meshPrimitive) { - Map extensions = primitive.getExtensions(); + Map extensions = meshPrimitive.getExtensions(); if (extensions == null) { return null; } - - if (config.USE_BASE_EXTENSION) + Map baseExtension = getMapOptional(extensions, BASE_NAME); + Map baseExtensionExtensions = + getMapOptional(baseExtension, "extensions"); + Map extension = getMapOptional(baseExtensionExtensions, NAME); + Integer bufferViewIndex = getIntegerOptional(extension, "bufferView"); + return bufferViewIndex; + } + + /** + * Returns the index of the buffer view that stores the SPZ data, using + * various "legacy" forms of the extension. + * + * If it can not be found, then null is returned. + * + * Details omitted here. + * + * @param meshPrimitive The mesh primitive + * @return The index + */ + private static Integer + getLegacyExtensionBufferViewIndex(MeshPrimitive meshPrimitive) + { + Map extensions = meshPrimitive.getExtensions(); + if (extensions == null) { - // Lots of ugly, untyped code here. - // It could be worse. - // It could be JavaScript. - Object baseExtensionObject = - extensions.get("KHR_gaussian_splatting"); - if (baseExtensionObject == null) - { - return null; - } - if (!(baseExtensionObject instanceof Map)) - { - return null; - } - Map baseExtension = (Map) baseExtensionObject; - Object baseExtensionsObject = baseExtension.get("extensions"); - if (!(baseExtensionsObject instanceof Map)) - { - return null; - } - Map baseExtensions = (Map) baseExtensionsObject; - Object extension = - baseExtensions.get(config.SPZ_EXTENSION_NAME); - if (!(extension instanceof Map)) - { - return null; - } - Map extensionMap = (Map) extension; - Object bufferViewIndexObject = extensionMap.get("bufferView"); - if (!(bufferViewIndexObject instanceof Number)) + return null; + } + List legacyNames = Arrays.asList("KHR_spz_compression", + "KHR_spz_gaussian_splats_compression", + "KHR_gaussian_splatting_spz_compression"); + for (String legacyName : legacyNames) + { + Map extension = getMapOptional(extensions, legacyName); + Integer bufferViewIndex = + getIntegerOptional(extension, "bufferView"); + if (bufferViewIndex != null) { - return null; + logger.warning( + "Fetching SPZ data from legacy extension with name " + + legacyName + " - this extension version may " + + "not be fully supported"); + return bufferViewIndex; } - Number bufferViewIndexNumber = (Number) bufferViewIndexObject; - return bufferViewIndexNumber.intValue(); } - Object extension = - extensions.get(config.SPZ_EXTENSION_NAME); - if (!(extension instanceof Map)) + return null; + } + + /** + * Returns the value for the given key from the given map, if that value is + * a map. If the given map is null or the value is not a map, + * then null is returned. + * + * It could be worse. It could be JavaScript. + * + * @param map The map + * @param key The key + * @return The result + */ + private static Map getMapOptional(Map map, String key) + { + if (map == null) + { + return null; + } + Object object = map.get(key); + if (!(object instanceof Map)) + { + return null; + } + Map result = (Map) object; + return result; + } + + /** + * Returns the integer value for the given key from the given map, if that + * value is a number. If the given map is null or the value is + * not a number, then null is returned. + * + * It could be worse. It could be JavaScript. + * + * @param map The map + * @param key The key + * @return The result + */ + private static Integer getIntegerOptional(Map map, String key) + { + if (map == null) { return null; } - Map extensionMap = (Map) extension; - Object bufferViewIndexObject = extensionMap.get("bufferView"); - if (!(bufferViewIndexObject instanceof Number)) + Object object = map.get(key); + if (!(object instanceof Number)) { return null; } - Number bufferViewIndexNumber = (Number) bufferViewIndexObject; - return bufferViewIndexNumber.intValue(); + Number result = (Number) object; + return result.intValue(); } /** diff --git a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatWriter.java b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java similarity index 71% rename from jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatWriter.java rename to jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java index b58cc72..29b24b3 100644 --- a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfSplatWriter.java +++ b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java @@ -24,7 +24,7 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ -package de.javagl.jsplat.io.spz.gltf; +package de.javagl.jsplat.io.gltf.spz; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -49,43 +49,35 @@ import de.javagl.jgltf.model.io.v2.GltfAssetV2; import de.javagl.jsplat.Splat; import de.javagl.jsplat.SplatListWriter; +import de.javagl.jsplat.Splats; import de.javagl.jsplat.io.spz.GaussianCloudSplats; import de.javagl.jspz.GaussianCloud; import de.javagl.jspz.SpzWriter; import de.javagl.jspz.SpzWriters; /** - * Implementation of a {@link SplatListWriter} that writes glTF data with + * Implementation of a {@link SplatListWriter} that writes glTF data with * SPZ-compressed Gaussian splats. - * - * NOTE: This class is preliminary, and tries to track the development of - * https://github.com/KhronosGroup/glTF/pull/2490. Some details of this - * class depend on compile-time flags that may change in the future. */ -public final class SpzGltfSplatWriter implements SplatListWriter +public final class GltfSpzSplatWriter implements SplatListWriter { /** - * Preliminary configuration settings + * The base extension name (and attribute prefix) */ - private final SpzGltfConfig config; - + private static final String BASE_NAME = "KHR_gaussian_splatting"; + /** - * Creates a new instance + * The extension name */ - public SpzGltfSplatWriter() - { - this(false); - } + private static final String NAME = + "KHR_gaussian_splatting_compression_spz_2"; /** * Creates a new instance - * - * @param useBaseExtension Experimental - * @deprecated Experimental support for the base extension */ - public SpzGltfSplatWriter(boolean useBaseExtension) + public GltfSpzSplatWriter() { - this.config = new SpzGltfConfig(useBaseExtension); + // Default constructor } @Override @@ -109,11 +101,11 @@ public void writeList(List splats, /** * Create a binary glTF asset that uses the - * KHR_spz_gaussian_splats_compression extension to define + * KHR_gaussian_splatting_compression_spz_2 extension to define * Gaussian Splats * * @param numPoints The number of points - * @param shDegree The shpherical harmonics degree + * @param shDegree The spherical harmonics degree * @param boundingBox The bounding box * @param spzBytes The SPZ data * @return The asset @@ -163,11 +155,9 @@ private GltfAssetV2 createGltfAsset(int numPoints, int shDegree, gltf.addAccessors(scale); // Add the spherical harmonics accessors - int numCoeffsPerDegree[] = - { 3, 5, 7 }; for (int d = 0; d < shDegree; d++) { - int numCoeffs = numCoeffsPerDegree[d]; + int numCoeffs = Splats.coefficientsForDegree(d + 1); for (int n = 0; n < numCoeffs; n++) { Accessor sh = new Accessor(); @@ -196,40 +186,31 @@ private GltfAssetV2 createGltfAsset(int numPoints, int shDegree, // Add all accessors to the mesh primitive int a = 0; primitive.addAttributes("POSITION", a++); - primitive.addAttributes("COLOR_0", a++); - String ATTRIBUTE_PREFIX = config.ATTRIBUTE_PREFIX; - primitive.addAttributes(ATTRIBUTE_PREFIX + "ROTATION", a++); - primitive.addAttributes(ATTRIBUTE_PREFIX + "SCALE", a++); + primitive.addAttributes(BASE_NAME + ":" + "SCALE", a++); + primitive.addAttributes(BASE_NAME + ":" + "ROTATION", a++); + primitive.addAttributes(BASE_NAME + ":" + "OPACITY", a++); - for (int d = 0; d < shDegree; d++) + for (int d = 0; d <= shDegree; d++) { - int numCoeffs = numCoeffsPerDegree[d]; + int numCoeffs = Splats.coefficientsForDegree(d); for (int n = 0; n < numCoeffs; n++) { - String s = "SH_DEGREE_" + (d + 1) + "_COEF_" + n; - primitive.addAttributes(ATTRIBUTE_PREFIX + s, a++); + String s = "SH_DEGREE_" + d + "_COEF_" + n; + primitive.addAttributes(BASE_NAME + ":" + s, a++); } } // Add the extension object to the primitive Map spzExtension = new LinkedHashMap(); spzExtension.put("bufferView", 0); - if (config.USE_BASE_EXTENSION) - { - Map baseExtension = - new LinkedHashMap(); - Map innerExtensions = - new LinkedHashMap(); - innerExtensions.put(config.SPZ_EXTENSION_NAME, spzExtension); - baseExtension.put("extensions", innerExtensions); - primitive.addExtensions("KHR_gaussian_splatting", - baseExtension); - } - else - { - primitive.addExtensions(config.SPZ_EXTENSION_NAME, spzExtension); - } - + + Map baseExtension = new LinkedHashMap(); + Map innerExtensions = + new LinkedHashMap(); + innerExtensions.put(NAME, spzExtension); + baseExtension.put("extensions", innerExtensions); + primitive.addExtensions(BASE_NAME, baseExtension); + // Add the mesh Mesh mesh = new Mesh(); mesh.addPrimitives(primitive); @@ -238,35 +219,8 @@ private GltfAssetV2 createGltfAsset(int numPoints, int shDegree, // Add the node Node node = new Node(); node.setMesh(0); - - // The node needs a matrix, and it may have to be one that - // converts Z-up to Y-up, as of version 1.131 of CesiumJS - // @formatter:off - float zUpToYup[] = new float[] - { - 1.0f, 0.0f, 0.0f, 0.0f, - 0.0f, 0.0f, -1.0f, 0.0f, - 0.0f, 1.0f, 0.0f, 0.0f, - 0.0f, 0.0f, 0.0f, 1.0f - }; - float identity[] = new float[] - { - 1.0f, 0.0f, 0.0f, 0.0f, - 0.0f, 1.0f, 0.0f, 0.0f, - 0.0f, 0.0f, 1.0f, 0.0f, - 0.0f, 0.0f, 0.0f, 1.0f - }; - // @formatter:on - if (config.APPLY_UP_AXIS_TRANSFORMS) - { - node.setMatrix(zUpToYup); - } - else - { - node.setMatrix(identity); - } gltf.addNodes(node); - + // Add the scene Scene scene = new Scene(); scene.addNodes(0); @@ -274,18 +228,13 @@ private GltfAssetV2 createGltfAsset(int numPoints, int shDegree, gltf.setScene(0); // Add information about the used/required extension - if (config.USE_BASE_EXTENSION) - { - gltf.addExtensionsUsed("KHR_gaussian_splatting"); - gltf.addExtensionsRequired("KHR_gaussian_splatting"); - } - gltf.addExtensionsUsed(config.SPZ_EXTENSION_NAME); - gltf.addExtensionsRequired(config.SPZ_EXTENSION_NAME); - + gltf.addExtensionsUsed(BASE_NAME); + gltf.addExtensionsUsed(NAME); + // Build the actual asset ByteBuffer binaryData = ByteBuffer.wrap(spzBytes); GltfAssetV2 gltfAsset = new GltfAssetV2(gltf, binaryData); - + return gltfAsset; } diff --git a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/package-info.java b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/package-info.java similarity index 60% rename from jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/package-info.java rename to jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/package-info.java index 49399af..e3e1aa3 100644 --- a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/package-info.java +++ b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/package-info.java @@ -1,4 +1,4 @@ /** * Readers and writers for Gaussian splats in glTF */ -package de.javagl.jsplat.io.spz.gltf; +package de.javagl.jsplat.io.gltf.spz; diff --git a/jsplat-io-gltf/README.md b/jsplat-io-gltf/README.md new file mode 100644 index 0000000..26a9364 --- /dev/null +++ b/jsplat-io-gltf/README.md @@ -0,0 +1,4 @@ +# jsplat-io-gltf + +Classes for reading and writing Gaussian splats in glTF format, +using the KHR_gaussian_splattting extension. \ No newline at end of file diff --git a/jsplat-io-gltf/pom.xml b/jsplat-io-gltf/pom.xml new file mode 100644 index 0000000..3d517db --- /dev/null +++ b/jsplat-io-gltf/pom.xml @@ -0,0 +1,41 @@ + + 4.0.0 + + jsplat-io-gltf + + ${project.groupId}:${project.artifactId} + A splat library for Java + https://github.com/javagl/JSplat + + + de.javagl + jsplat-parent + 0.0.1-SNAPSHOT + + + + + de.javagl + jsplat + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-spz + 0.0.1-SNAPSHOT + + + de.javagl + jgltf-model + 2.0.4 + + + de.javagl + jgltf-model-builder + 2.0.4 + + + + \ No newline at end of file diff --git a/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatReader.java b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatReader.java new file mode 100644 index 0000000..2552a12 --- /dev/null +++ b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatReader.java @@ -0,0 +1,318 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.io.gltf; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.FloatBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import de.javagl.jgltf.model.AccessorModel; +import de.javagl.jgltf.model.GltfModel; +import de.javagl.jgltf.model.MeshModel; +import de.javagl.jgltf.model.MeshPrimitiveModel; +import de.javagl.jgltf.model.io.GltfModelReader; +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.SplatListReader; +import de.javagl.jsplat.Splats; + +/** + * Implementation of a {@link SplatListReader} that reads glTF data with the + * KHR_gaussian_splatting Gaussian splat extension. + * + * NOTE: This class is preliminary and limited in terms of its capabilities: It + * assumes a single mesh primitive that contains splat attributes for now, and + * simply returns the splats from the first primitive. + */ +public final class GltfSplatReader implements SplatListReader +{ + /** + * The logger used in this class + */ + private static final Logger logger = + Logger.getLogger(GltfSplatReader.class.getName()); + + /** + * The extension name (and attribute prefix) + */ + private static final String NAME = "KHR_gaussian_splatting"; + + /** + * Creates a new instance + */ + public GltfSplatReader() + { + // Default constructor + } + + @Override + public List readList(InputStream inputStream) + throws IOException + { + GltfModelReader r = new GltfModelReader(); + GltfModel gltfModel = r.readWithoutReferences(inputStream); + + List meshModels = gltfModel.getMeshModels(); + for (MeshModel meshModel : meshModels) + { + List meshPrimitiveModels = + meshModel.getMeshPrimitiveModels(); + for (MeshPrimitiveModel meshPrimitiveModel : meshPrimitiveModels) + { + Map extensions = + meshPrimitiveModel.getExtensions(); + Object extension = extensions.get(NAME); + if (extension != null) + { + return readListFrom(meshPrimitiveModel); + } + } + } + throw new IOException( + "No mesh primitive with Gaussian splats found in input data"); + } + + /** + * Read a list of splats from the given mesh primitive model, assuming + * that it contains valid KHR_gaussian_splatting attributes. + * + * @param meshPrimitiveModel The mesh primitive model + * @return The splats + */ + private static List + readListFrom(MeshPrimitiveModel meshPrimitiveModel) + { + Map attributes = + meshPrimitiveModel.getAttributes(); + + String positionName = "POSITION"; + AccessorModel positionAccessor = attributes.get(positionName); + if (positionAccessor == null) + { + logger.severe("No POSITION accessor found in mesh primitive"); + return Collections.emptyList(); + } + + String scaleName = NAME + ":" + "SCALE"; + AccessorModel scaleAccessor = attributes.get(scaleName); + if (scaleAccessor == null) + { + logger.severe( + "No " + scaleName + " accessor found in mesh primitive"); + return Collections.emptyList(); + } + + String rotationName = NAME + ":" + "ROTATION"; + AccessorModel rotationAccessor = attributes.get(rotationName); + if (rotationAccessor == null) + { + logger.severe( + "No " + rotationName + " accessor found in mesh primitive"); + return Collections.emptyList(); + } + + String opacityName = NAME + ":" + "OPACITY"; + AccessorModel opacityAccessor = attributes.get(opacityName); + if (opacityAccessor == null) + { + logger.severe( + "No " + opacityName + " accessor found in mesh primitive"); + return Collections.emptyList(); + } + + List shAccessors = new ArrayList(); + int maxDegrees = 4; + int shDegree = 0; + for (int d = 0; d < maxDegrees; d++) + { + int numCoefficients = Splats.coefficientsForDegree(d); + for (int c = 0; c < numCoefficients; c++) + { + String name = NAME + ":" + "SH_DEGREE_" + d + "_COEF_" + c; + AccessorModel shAccessor = attributes.get(name); + if (shAccessor != null) + { + shAccessors.add(shAccessor); + shDegree = d; + } + } + } + + // There are no sanity checks here. It simply assumes that all the + // accessors have the same counts. Leave that to the validator... + int count = positionAccessor.getCount(); + List splats = new ArrayList(); + for (int i = 0; i < count; i++) + { + MutableSplat splat = Splats.create(shDegree); + splats.add(splat); + } + + FloatBuffer positionBuffer = readAsFloatBuffer(positionAccessor); + writePositions(positionBuffer, splats); + + FloatBuffer scaleBuffer = readAsFloatBuffer(scaleAccessor); + writeScales(scaleBuffer, splats); + + FloatBuffer rotationBuffer = readAsFloatBuffer(rotationAccessor); + writeRotations(rotationBuffer, splats); + + FloatBuffer opacityBuffer = readAsFloatBuffer(opacityAccessor); + writeOpacities(opacityBuffer, splats); + + int shIndex = 0; + for (int d = 0; d <= shDegree; d++) + { + int numCoefficients = Splats.coefficientsForDegree(d); + for (int c = 0; c < numCoefficients; c++) + { + AccessorModel shAccessor = shAccessors.get(shIndex); + FloatBuffer shBuffer = readAsFloatBuffer(shAccessor); + writeSh(shBuffer, splats, d, c); + shIndex++; + } + } + + return splats; + } + + + /** + * Returns the data from the given accessor model as a float buffer, tightly + * packed, applying dequantization as necessary. + * + * @param accessorModel The accessor model + * @return The buffer + * @throws IllegalArgumentException If the component type of the given + * accessor model is neither float, nor signed/unsigned byte/short. + */ + private static FloatBuffer readAsFloatBuffer(AccessorModel accessorModel) + { + return Quantization.readAsFloatBuffer(accessorModel); + } + + /** + * Write the positions from the given buffer with splats.size() * 3 elements + * into the given splats + * + * @param b The buffer + * @param splats The splats + */ + private static void writePositions(FloatBuffer b, + List splats) + { + for (int i = 0; i < splats.size(); i++) + { + MutableSplat s = splats.get(i); + s.setPositionX(b.get(i * 3 + 0)); + s.setPositionY(b.get(i * 3 + 1)); + s.setPositionZ(b.get(i * 3 + 2)); + } + } + + /** + * Write the scales from the given buffer with splats.size() * 3 elements + * into the given splats + * + * @param b The buffer + * @param splats The splats + */ + private static void writeScales(FloatBuffer b, + List splats) + { + for (int i = 0; i < splats.size(); i++) + { + MutableSplat s = splats.get(i); + s.setScaleX(b.get(i * 3 + 0)); + s.setScaleY(b.get(i * 3 + 1)); + s.setScaleZ(b.get(i * 3 + 2)); + } + } + + /** + * Write the rotations from the given buffer with splats.size() * 4 elements + * into the given splats + * + * @param b The buffer + * @param splats The splats + */ + private static void writeRotations(FloatBuffer b, + List splats) + { + for (int i = 0; i < splats.size(); i++) + { + MutableSplat s = splats.get(i); + s.setRotationX(b.get(i * 4 + 0)); + s.setRotationY(b.get(i * 4 + 1)); + s.setRotationZ(b.get(i * 4 + 2)); + s.setRotationW(b.get(i * 4 + 3)); + } + } + + /** + * Write the opacities from the given buffer with splats.size() elements + * into the given splats + * + * @param b The buffer + * @param splats The splats + */ + private static void writeOpacities(FloatBuffer b, + List splats) + { + for (int i = 0; i < splats.size(); i++) + { + MutableSplat s = splats.get(i); + s.setOpacity(b.get(i)); + } + } + + /** + * Write the specified spherical harmonics from the given buffer with + * splats.size() * 3 elements into the given splats + * + * @param b The buffer + * @param splats The splats + * @param degree The degree + * @param coefficient The coefficient + */ + private static void writeSh(FloatBuffer b, + List splats, int degree, int coefficient) + { + int index = Splats.dimensionForCoefficient(degree, coefficient); + for (int i = 0; i < splats.size(); i++) + { + MutableSplat s = splats.get(i); + s.setShX(index, b.get(i * 3 + 0)); + s.setShY(index, b.get(i * 3 + 1)); + s.setShZ(index, b.get(i * 3 + 2)); + } + } +} diff --git a/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatWriter.java b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatWriter.java new file mode 100644 index 0000000..97dabcc --- /dev/null +++ b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/GltfSplatWriter.java @@ -0,0 +1,286 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.io.gltf; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.FloatBuffer; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import de.javagl.jgltf.model.GltfModel; +import de.javagl.jgltf.model.SceneModel; +import de.javagl.jgltf.model.creation.AccessorModels; +import de.javagl.jgltf.model.creation.GltfModelBuilder; +import de.javagl.jgltf.model.creation.MeshPrimitiveBuilder; +import de.javagl.jgltf.model.creation.SceneModels; +import de.javagl.jgltf.model.impl.DefaultAccessorModel; +import de.javagl.jgltf.model.impl.DefaultExtensionsModel; +import de.javagl.jgltf.model.impl.DefaultGltfModel; +import de.javagl.jgltf.model.impl.DefaultMeshPrimitiveModel; +import de.javagl.jgltf.model.io.GltfModelWriter; +import de.javagl.jsplat.Splat; +import de.javagl.jsplat.SplatListWriter; +import de.javagl.jsplat.Splats; + +/** + * Implementation of a {@link SplatListWriter} that writes glTF data with + * Gaussian splats using the KHR_gaussian_splatting extension + * + * NOTE: This class is preliminary and limited in terms of its capabilities: It + * assumes a single mesh primitive that contains splat attributes for now. + */ +public final class GltfSplatWriter implements SplatListWriter +{ + /** + * The extension name (and attribute prefix) + */ + private static final String NAME = "KHR_gaussian_splatting"; + + /** + * Creates a new instance + */ + public GltfSplatWriter() + { + // Default constructor + } + + @Override + public void writeList(List splats, + OutputStream outputStream) throws IOException + { + GltfModel gltfModel = createGltfModel(splats); + GltfModelWriter w = new GltfModelWriter(); + w.writeBinary(gltfModel, outputStream); + } + + /** + * Create a binary glTF asset that uses the + * KHR_gaussian_splatting extension to define Gaussian Splats + * + * @param splats The splats + * @return The asset + */ + private DefaultGltfModel createGltfModel(List splats) + { + DefaultMeshPrimitiveModel meshPrimitiveModel = + createMeshPrimitiveModel(splats); + + SceneModel sceneModel = + SceneModels.createFromMeshPrimitive(meshPrimitiveModel); + + GltfModelBuilder b = GltfModelBuilder.create(); + b.addSceneModel(sceneModel); + DefaultGltfModel gltfModel = b.build(); + + DefaultExtensionsModel extensionsModel = gltfModel.getExtensionsModel(); + extensionsModel.addExtensionsUsed(Arrays.asList(NAME)); + + return gltfModel; + } + + /** + * Create a mesh primitive model from the given splats, using the attribtes + * that are defined in the KHR_gaussian_splatting extension + * + * @param splats The splats + * @return The mesh primitive model + */ + private DefaultMeshPrimitiveModel + createMeshPrimitiveModel(List splats) + { + MeshPrimitiveBuilder mpb = MeshPrimitiveBuilder.create(); + + FloatBuffer position = readPositions(splats); + DefaultAccessorModel positionAccessor = + AccessorModels.createFloat3D(position); + mpb.addAttribute("POSITION", positionAccessor); + + FloatBuffer scale = readScales(splats); + DefaultAccessorModel scaleAccessor = + AccessorModels.createFloat3D(scale); + mpb.addAttribute(NAME + ":" + "SCALE", scaleAccessor); + + FloatBuffer rotation = readRotations(splats); + DefaultAccessorModel rotationAccessor = + AccessorModels.createFloat4D(rotation); + mpb.addAttribute(NAME + ":" + "ROTATION", rotationAccessor); + + FloatBuffer opacity = readOpacities(splats); + DefaultAccessorModel opacityAccessor = + AccessorModels.createFloatScalar(opacity); + mpb.addAttribute(NAME + ":" + "OPACITY", opacityAccessor); + + int shDegree = splats.get(0).getShDegree(); + for (int d = 0; d <= shDegree; d++) + { + int numCoefficients = Splats.coefficientsForDegree(d); + for (int c = 0; c < numCoefficients; c++) + { + FloatBuffer sh = readSh(splats, d, c); + DefaultAccessorModel shAccessor = + AccessorModels.createFloat3D(sh); + + String name = NAME + ":" + "SH_DEGREE_" + d + "_COEF_" + c; + mpb.addAttribute(name, shAccessor); + } + } + + mpb.setPoints(); + + // Create the mesh primitive + DefaultMeshPrimitiveModel meshPrimitiveModel = mpb.build(); + + // Manually add the extension (there is no model-level representation + // of this extension yet) + Map extension = createExtension(); + meshPrimitiveModel.addExtension(NAME, extension); + return meshPrimitiveModel; + } + + /** + * Create a map that represents an unspecified default + * KHR_gaussian_splatting extension object + * + * @return The extension object + */ + private Map createExtension() + { + Map extension = new LinkedHashMap(); + extension.put("kernel", "ellipse"); + extension.put("colorSpace", "BT.709-sRGB"); + extension.put("sortingMethod", "cameraDistance"); + extension.put("projection", "perspective"); + return extension; + } + + /** + * Read the positions from the given splats, as a buffer with splats.size() + * * 3 elements + * + * @param splats The splats + * @return The result + */ + private static FloatBuffer readPositions(List splats) + { + FloatBuffer b = FloatBuffer.allocate(splats.size() * 3); + for (int i = 0; i < splats.size(); i++) + { + Splat s = splats.get(i); + b.put(i * 3 + 0, s.getPositionX()); + b.put(i * 3 + 1, s.getPositionY()); + b.put(i * 3 + 2, s.getPositionZ()); + } + return b; + } + + /** + * Read the scales from the given splats, as a buffer with splats.size() * 3 + * elements + * + * @param splats The splats + * @return The result + */ + private static FloatBuffer readScales(List splats) + { + FloatBuffer b = FloatBuffer.allocate(splats.size() * 3); + for (int i = 0; i < splats.size(); i++) + { + Splat s = splats.get(i); + b.put(i * 3 + 0, s.getScaleX()); + b.put(i * 3 + 1, s.getScaleY()); + b.put(i * 3 + 2, s.getScaleZ()); + } + return b; + } + + /** + * Read the rotations from the given splats, as a buffer with splats.size() + * * 4 elements + * + * @param splats The splats + * @return The result + */ + private static FloatBuffer readRotations(List splats) + { + FloatBuffer b = FloatBuffer.allocate(splats.size() * 4); + for (int i = 0; i < splats.size(); i++) + { + Splat s = splats.get(i); + b.put(i * 4 + 0, s.getRotationX()); + b.put(i * 4 + 1, s.getRotationY()); + b.put(i * 4 + 2, s.getRotationZ()); + b.put(i * 4 + 3, s.getRotationW()); + } + return b; + } + + /** + * Read the opacities from the given splats, as a buffer with splats.size() + * elements + * + * @param splats The splats + * @return The result + */ + private static FloatBuffer readOpacities(List splats) + { + FloatBuffer b = FloatBuffer.allocate(splats.size()); + for (int i = 0; i < splats.size(); i++) + { + Splat s = splats.get(i); + b.put(i, s.getOpacity()); + } + return b; + } + + /** + * Read the specified spherical harmonics from the given splats, as a buffer + * with splats.size() * 3 elements + * + * @param splats The splats + * @param degree The degree + * @param coefficient The coefficient + * @return The result + */ + private static FloatBuffer readSh(List splats, int degree, + int coefficient) + { + int index = Splats.dimensionForCoefficient(degree, coefficient); + FloatBuffer b = FloatBuffer.allocate(splats.size() * 3); + for (int i = 0; i < splats.size(); i++) + { + Splat s = splats.get(i); + b.put(i * 3 + 0, s.getShX(index)); + b.put(i * 3 + 1, s.getShY(index)); + b.put(i * 3 + 2, s.getShZ(index)); + } + return b; + } + +} diff --git a/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/Quantization.java b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/Quantization.java new file mode 100644 index 0000000..dee42f9 --- /dev/null +++ b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/Quantization.java @@ -0,0 +1,226 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.io.gltf; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; +import java.nio.ShortBuffer; + +import de.javagl.jgltf.model.AccessorData; +import de.javagl.jgltf.model.AccessorModel; +import de.javagl.jgltf.model.GltfConstants; + +/** + * Internal utility methods for quantization. + * + * These methods will sooner or later become part of JglTF. + */ +class Quantization +{ + /** + * Returns the data from the given accessor model as a float buffer, tightly + * packed, applying dequantization as necessary. + * + * @param accessorModel The accessor model + * @return The buffer + * @throws IllegalArgumentException If the component type of the given + * accessor model is neither float, nor signed/unsigned byte/short. + */ + static FloatBuffer readAsFloatBuffer(AccessorModel accessorModel) + { + AccessorData accessorData = accessorModel.getAccessorData(); + int componentType = accessorModel.getComponentType(); + if (componentType == GltfConstants.GL_FLOAT) + { + ByteBuffer inputByteBuffer = accessorData.createByteBuffer(); + return inputByteBuffer.asFloatBuffer(); + } + if (componentType == GltfConstants.GL_SHORT) + { + ByteBuffer inputByteBuffer = accessorData.createByteBuffer(); + ShortBuffer shortBuffer = inputByteBuffer.asShortBuffer(); + return dequantizeShortBuffer(shortBuffer); + } + if (componentType == GltfConstants.GL_UNSIGNED_SHORT) + { + ByteBuffer inputByteBuffer = accessorData.createByteBuffer(); + ShortBuffer shortBuffer = inputByteBuffer.asShortBuffer(); + return dequantizeUnsignedShortBuffer(shortBuffer); + } + if (componentType == GltfConstants.GL_BYTE) + { + ByteBuffer inputByteBuffer = accessorData.createByteBuffer(); + return dequantizeByteBuffer(inputByteBuffer); + } + if (componentType == GltfConstants.GL_UNSIGNED_BYTE) + { + ByteBuffer inputByteBuffer = accessorData.createByteBuffer(); + return dequantizeUnsignedByteBuffer(inputByteBuffer); + } + throw new IllegalArgumentException( + "Component type " + GltfConstants.stringFor(componentType) + + " cannot be converted to float"); + } + + /** + * Dequantize the given buffer into a float buffer, treating each element of + * the input as a signed byte. + * + * @param byteBuffer The input buffer + * @return The result + */ + private static FloatBuffer dequantizeByteBuffer(ByteBuffer byteBuffer) + { + FloatBuffer floatBuffer = FloatBuffer.allocate(byteBuffer.capacity()); + for (int i = 0; i < byteBuffer.capacity(); i++) + { + byte c = byteBuffer.get(i); + float f = dequantizeByte(c); + floatBuffer.put(i, f); + } + return floatBuffer; + } + + /** + * Dequantize the given buffer into a float buffer, treating each element of + * the input as an unsigned byte. + * + * @param byteBuffer The input buffer + * @return The result + */ + private static FloatBuffer + dequantizeUnsignedByteBuffer(ByteBuffer byteBuffer) + { + FloatBuffer floatBuffer = FloatBuffer.allocate(byteBuffer.capacity()); + for (int i = 0; i < byteBuffer.capacity(); i++) + { + byte c = byteBuffer.get(i); + float f = dequantizeUnsignedByte(c); + floatBuffer.put(i, f); + } + return floatBuffer; + } + + /** + * Dequantize the given buffer into a float buffer, treating each element of + * the input as a signed short. + * + * @param shortBuffer The input buffer + * @return The result + */ + private static FloatBuffer dequantizeShortBuffer(ShortBuffer shortBuffer) + { + FloatBuffer floatBuffer = FloatBuffer.allocate(shortBuffer.capacity()); + for (int i = 0; i < shortBuffer.capacity(); i++) + { + short c = shortBuffer.get(i); + float f = dequantizeShort(c); + floatBuffer.put(i, f); + } + return floatBuffer; + } + + /** + * Dequantize the given buffer into a float buffer, treating each element of + * the input as an unsigned short. + * + * @param shortBuffer The input buffer + * @return The result + */ + private static FloatBuffer + dequantizeUnsignedShortBuffer(ShortBuffer shortBuffer) + { + FloatBuffer floatBuffer = FloatBuffer.allocate(shortBuffer.capacity()); + for (int i = 0; i < shortBuffer.capacity(); i++) + { + short c = shortBuffer.get(i); + float f = dequantizeUnsignedShort(c); + floatBuffer.put(i, f); + } + return floatBuffer; + } + + /** + * Dequantize the given signed byte into a floating point value + * + * @param c The input + * @return The result + */ + private static float dequantizeByte(byte c) + { + float f = Math.max(c / 127.0f, -1.0f); + return f; + } + + /** + * Dequantize the given unsigned byte into a floating point value + * + * @param c The input + * @return The result + */ + private static float dequantizeUnsignedByte(byte c) + { + int i = Byte.toUnsignedInt(c); + float f = i / 255.0f; + return f; + } + + /** + * Dequantize the given signed short into a floating point value + * + * @param c The input + * @return The result + */ + private static float dequantizeShort(short c) + { + float f = Math.max(c / 32767.0f, -1.0f); + return f; + } + + /** + * + * Dequantize the given unsigned byte into a floating point value + * + * @param c The input + * @return The result + */ + private static float dequantizeUnsignedShort(short c) + { + int i = Short.toUnsignedInt(c); + float f = i / 65535.0f; + return f; + } + + /** + * Private constructor to prevent instantiation + */ + private Quantization() + { + // Private constructor to prevent instantiation + } + +} diff --git a/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/package-info.java b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/package-info.java new file mode 100644 index 0000000..a4e5425 --- /dev/null +++ b/jsplat-io-gltf/src/main/java/de/javagl/jsplat/io/gltf/package-info.java @@ -0,0 +1,4 @@ +/** + * Readers and writers for Gaussian splats in glTF + */ +package de.javagl.jsplat.io.gltf; diff --git a/jsplat-io-spz-gltf/README.md b/jsplat-io-spz-gltf/README.md deleted file mode 100644 index beaeb4a..0000000 --- a/jsplat-io-spz-gltf/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# jsplat-io-spz-gltf - -Classes for reading and writing Gaussian splats in glTF format, -using the KHR_spz_gaussian_splats_compression extension. \ No newline at end of file diff --git a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfConfig.java b/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfConfig.java deleted file mode 100644 index da2e702..0000000 --- a/jsplat-io-spz-gltf/src/main/java/de/javagl/jsplat/io/spz/gltf/SpzGltfConfig.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * www.javagl.de - JSplat - * - * Copyright 2025 Marco Hutter - http://www.javagl.de - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - */ -package de.javagl.jsplat.io.spz.gltf; - -/** - * Internal class with flags, attempting to track the development of - * https://github.com/KhronosGroup/glTF/pull/2490 and certain quirks and - * assumptions that are made by CesiumJS. - */ -class SpzGltfConfig -{ - /** - * A flag to insert certain up-axis conversion matrices in the - * glTF, to handle certain expectations that are made by CesiumJS. - */ - final boolean APPLY_UP_AXIS_TRANSFORMS = true; - - /** - * Whether the glTF should be created with the KHR_gaussian_splatting - * base extension. - * - * This is currently false by default, because there is no - * support for the base extension in CesiumJS (as of 2025-08-23). - */ - boolean USE_BASE_EXTENSION; - - /** - * The string that is used as a prefix for the glTF attribute names. - * - * This is used for disambiguating the attribute names, as part of - * https://github.com/KhronosGroup/glTF/issues/2111 - */ - String ATTRIBUTE_PREFIX; - - /** - * The name of the SPZ extension. - * - * This will be KHR_spz_gaussian_splats_compression by default, - * and KHR_gaussian_splatting_compression_spz when - * {@link #USE_BASE_EXTENSION} is true. - */ - String SPZ_EXTENSION_NAME; - - /** - * Default constructor - * - * @param useBaseExtension Whether the base extension should be used - */ - SpzGltfConfig(boolean useBaseExtension) - { - this.USE_BASE_EXTENSION = useBaseExtension; - if (USE_BASE_EXTENSION) - { - ATTRIBUTE_PREFIX = "KHR_gaussian_splatting:"; - SPZ_EXTENSION_NAME = "KHR_gaussian_splatting_compression_spz"; - } - else - { - ATTRIBUTE_PREFIX = "_"; - SPZ_EXTENSION_NAME = "KHR_spz_gaussian_splats_compression"; - } - } - -} diff --git a/jsplat/src/main/java/de/javagl/jsplat/Splats.java b/jsplat/src/main/java/de/javagl/jsplat/Splats.java index 7d8be7f..600555f 100644 --- a/jsplat/src/main/java/de/javagl/jsplat/Splats.java +++ b/jsplat/src/main/java/de/javagl/jsplat/Splats.java @@ -269,6 +269,56 @@ public static int dimensionsForDegree(int degree) return (degree + 1) * (degree + 1); } + /** + * Returns the number of spherical harmonics coefficients for the given + * degree. + * + * @param degree The degree + * @return The number of coefficients + */ + public static int coefficientsForDegree(int degree) + { + return degree * 2 + 1; + } + + /** + * Returns the index of the dimension for the specified spherical harmonics + * coefficient. + * + *

+     * (degree 0, coefficient 0) : 0
+     * 
+     * (degree 1, coefficient 0) : 1
+     * (degree 1, coefficient 1) : 2
+     * (degree 1, coefficient 2) : 3
+     * 
+     * (degree 2, coefficient 0) : 4
+     * (degree 2, coefficient 1) : 5
+     * (degree 2, coefficient 2) : 6
+     * (degree 2, coefficient 3) : 7
+     * (degree 2, coefficient 4) : 8
+     * 
+     * (degree 3, coefficient 0) : 9
+     * ...
+     * (degree 3, coefficient 6) : 15
+     * 
+ * + * @param degree The degree + * @param coefficient The coefficient + * @return The dimension + */ + public static int dimensionForCoefficient(int degree, int coefficient) + { + int index = 0; + for (int d = 0; d < degree; d++) + { + int dd = Splats.coefficientsForDegree(d); + index += dd; + } + index += coefficient; + return index; + } + /** * Returns whether the given lists of splats are epsilon-equal * @@ -303,10 +353,9 @@ public static boolean equalsEpsilon(List sas, /** * Returns whether the given splats are epsilon-equal. * - * This involves special treatment for the scalar (rotation) component - * of the quaternions: It will treat them as actual rotation angles, - * meaning that values like 1.0 and -1.0 will be considered to be - * equal. + * This involves special treatment for the scalar (rotation) component of + * the quaternions: It will treat them as actual rotation angles, meaning + * that values like 1.0 and -1.0 will be considered to be equal. * * @param sa The first splat * @param sb The second splat @@ -367,7 +416,7 @@ public static boolean equalsEpsilon(Splat sa, Splat sb, float epsilon) // Special treatment for rotation component: The difference // modulo 1.0 is computed, and should be epsilon-equal to 0 float wa = sa.getRotationW(); - float wb = sb.getRotationW(); + float wb = sb.getRotationW(); float wd = 1.0f - Math.abs(Math.abs(wa - wb) - 1.0f); if (!equalsEpsilon(wd, 0.0f, epsilon)) { @@ -398,14 +447,14 @@ public static boolean equalsEpsilon(Splat sa, Splat sb, float epsilon) } return true; } - + /** * Returns whether the given splats are strictly epsilon-equal. * * This means that it will compare the actual values of the splats, - * regardless of their semantics. For rotation quaternions, a - * scalar value of 1.0 and -1.0 describe the same rotation, and this - * will not be taken into account here. + * regardless of their semantics. For rotation quaternions, a scalar value + * of 1.0 and -1.0 describe the same rotation, and this will not be + * taken into account here. * * @param sa The first splat * @param sb The second splat @@ -491,7 +540,6 @@ public static boolean strictEqualsEpsilon(Splat sa, Splat sb, float epsilon) } return true; } - /** * Returns whether the given values are epsilon-equal diff --git a/pom.xml b/pom.xml index d63ef8f..dc0c63f 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,8 @@ jsplat-io-gsplat jsplat-io-ply jsplat-io-spz - jsplat-io-spz-gltf + jsplat-io-gltf + jsplat-io-gltf-spz