diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..d11da70
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "o-voxel/third_party/eigen"]
+ path = o-voxel/third_party/eigen
+ url = https://gitlab.com/libeigen/eigen.git
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7965606
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+ MIT License
+
+ Copyright (c) Microsoft Corporation.
+
+ 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
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e97aee1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,222 @@
+
+
+# Native and Compact Structured Latents for 3D Generation
+
+
+
+
+
+
+https://github.com/user-attachments/assets/5ee056e4-73a9-4fd8-bf60-59cae90d3dfc
+
+*(Compressed version due to GitHub size limits. See the full-quality video on our project page!)*
+
+**TRELLIS.2** is a state-of-the-art large 3D generative model (4B parameters) designed for high-fidelity **image-to-3D** generation. It leverages a novel "field-free" sparse voxel structure termed **O-Voxel** to reconstruct and generate arbitrary 3D assets with complex topologies, sharp features, and full PBR materials.
+
+
+## ✨ Features
+
+### 1. High Quality, Resolution & Efficiency
+Our 4B-parameter model generates high-resolution fully textured assets with exceptional fidelity and efficiency using vanilla DiTs. It utilizes a Sparse 3D VAE with 16× spatial downsampling to encode assets into a compact latent space.
+
+| Resolution | Total Time* | Breakdown (Shape + Mat) |
+| :--- | :--- | :--- |
+| **512³** | **~3s** | 2s + 1s |
+| **1024³** | **~17s** | 10s + 7s |
+| **1536³** | **~60s** | 35s + 25s |
+
+*Tested on NVIDIA H100 GPU.
+
+### 2. Arbitrary Topology Handling
+The **O-Voxel** representation breaks the limits of iso-surface fields. It robustly handles complex structures without lossy conversion:
+* ✅ **Open Surfaces** (e.g., clothing, leaves)
+* ✅ **Non-manifold Geometry**
+* ✅ **Internal Enclosed Structures**
+
+### 3. Rich Texture Modeling
+Beyond basic colors, TRELLIS.2 models arbitrary surface attributes including **Base Color, Roughness, Metallic, and Opacity**, enabling photorealistic rendering and transparency support.
+
+### 4. Minimalist Processing
+Data processing is streamlined for instant conversions that are fully **rendering-free** and **optimization-free**.
+* **< 10s** (Single CPU): Textured Mesh → O-Voxel
+* **< 100ms** (CUDA): O-Voxel → Textured Mesh
+
+
+## 🗺️ Roadmap
+
+- [x] Paper release
+- [x] Release image-to-3D inference code
+- [x] Release pretrained checkpoints (4B)
+- [x] Hugging Face Spaces demo
+- [ ] Release shape-conditioned texture generation inference code (Current schdule: before 12/24/2025)
+- [ ] Release training code (Current schdule: before 12/31/2025)
+
+
+## 🛠️ Installation
+
+### Prerequisites
+- **System**: The code is currently tested only on **Linux**.
+- **Hardware**: An NVIDIA GPU with at least 24GB of memory is necessary. The code has been verified on NVIDIA A100 and H100 GPUs.
+- **Software**:
+ - The [CUDA Toolkit](https://developer.nvidia.com/cuda-toolkit-archive) is needed to compile certain packages. Recommended version is 12.4.
+ - [Conda](https://docs.anaconda.com/miniconda/install/#quick-command-line-install) is recommended for managing dependencies.
+ - Python version 3.8 or higher is required.
+
+### Installation Steps
+1. Clone the repo:
+ ```sh
+ git clone -b main https://github.com/microsoft/TRELLIS.2.git --recursive
+ cd TRELLIS.2
+ ```
+
+2. Install the dependencies:
+
+ **Before running the following command there are somethings to note:**
+ - By adding `--new-env`, a new conda environment named `trellis2` will be created. If you want to use an existing conda environment, please remove this flag.
+ - By default the `trellis2` environment will use pytorch 2.6.0 with CUDA 12.4. If you want to use a different version of CUDA, you can remove the `--new-env` flag and manually install the required dependencies. Refer to [PyTorch](https://pytorch.org/get-started/previous-versions/) for the installation command.
+ - If you have multiple CUDA Toolkit versions installed, `CUDA_HOME` should be set to the correct version before running the command. For example, if you have CUDA Toolkit 12.4 and 13.0 installed, you can run `export CUDA_HOME=/usr/local/cuda-12.4` before running the command.
+ - By default, the code uses the `flash-attn` backend for attention. For GPUs do not support `flash-attn` (e.g., NVIDIA V100), you can install `xformers` manually and set the `ATTN_BACKEND` environment variable to `xformers` before running the code. See the [Minimal Example](#minimal-example) for more details.
+ - The installation may take a while due to the large number of dependencies. Please be patient. If you encounter any issues, you can try to install the dependencies one by one, specifying one flag at a time.
+ - If you encounter any issues during the installation, feel free to open an issue or contact us.
+
+ Create a new conda environment named `trellis2` and install the dependencies:
+ ```sh
+ . ./setup.sh --new-env --basic --flash-attn --nvdiffrast --nvdiffrec --cumesh --o-voxel --flexgemm
+ ```
+ The detailed usage of `setup.sh` can be found by running `. ./setup.sh --help`.
+ ```sh
+ Usage: setup.sh [OPTIONS]
+ Options:
+ -h, --help Display this help message
+ --new-env Create a new conda environment
+ --basic Install basic dependencies
+ --flash-attn Install flash-attention
+ --cumesh Install cumesh
+ --o-voxel Install o-voxel
+ --flexgemm Install flexgemm
+ --nvdiffrast Install nvdiffrast
+ --nvdiffrec Install nvdiffrec
+ ```
+
+## 📦 Pretrained Weights
+
+The pretrained model **TRELLIS.2-4B** is available on Hugging Face. Please refer to the model card there for more details.
+
+| Model | Parameters | Resolution | Link |
+| :--- | :--- | :--- | :--- |
+| **TRELLIS.2-4B** | 4 Billion | 512³ - 1536³ | [Hugging Face](https://huggingface.co/microsoft/TRELLIS.2-4B) |
+
+
+## 🚀 Usage
+
+### 1. Image to 3D Generation
+
+#### Minimal Example
+
+Here is an [example](example.py) of how to use the pretrained models for 3D asset generation.
+
+```python
+import os
+os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
+os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True" # Can save GPU memory
+import cv2
+import imageio
+from PIL import Image
+import torch
+from trellis2.pipelines import Trellis2ImageTo3DPipeline
+from trellis2.utils import render_utils
+from trellis2.renderers import EnvMap
+import o_voxel
+
+# 1. Setup Environment Map
+envmap = EnvMap(torch.tensor(
+ cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB),
+ dtype=torch.float32, device='cuda'
+))
+
+# 2. Load Pipeline
+pipeline = Trellis2ImageTo3DPipeline.from_pretrained("microsoft/TRELLIS.2-4B")
+pipeline.cuda()
+
+# 3. Load Image & Run
+image = Image.open("assets/example_image/T.png")
+mesh = pipeline.run(image)[0]
+mesh.simplify(16777216) # nvdiffrast limit
+
+# 4. Render Video
+video = render_utils.make_pbr_vis_frames(render_utils.render_video(mesh, envmap=envmap))
+imageio.mimsave("sample.mp4", video, fps=15)
+
+# 5. Export to GLB
+glb = o_voxel.postprocess.to_glb(
+ vertices = mesh.vertices,
+ faces = mesh.faces,
+ attr_volume = mesh.attrs,
+ coords = mesh.coords,
+ attr_layout = mesh.layout,
+ voxel_size = mesh.voxel_size,
+ aabb = [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
+ decimation_target = 1000000,
+ texture_size = 4096,
+ remesh = True,
+ remesh_band = 1,
+ remesh_project = 0,
+ verbose = True
+)
+glb.export("sample.glb", extension_webp=True)
+```
+
+Upon execution, the script generates the following files:
+ - `sample.mp4`: A video visualizing the generated 3D asset with PBR materials and environmental lighting.
+ - `sample.glb`: The extracted PBR-ready 3D asset in GLB format.
+
+**Note:** The `.glb` file is exported in `OPAQUE` mode by default. Although the alpha channel is preserved within the texture map, it is not active initially. To enable transparency, import the asset into your 3D software and manually connect the texture's alpha channel to the material's opacity or alpha input.
+
+#### Web Demo
+
+[app.py](app.py) provides a simple web demo for image to 3D asset generation. you can run the demo with the following command:
+```sh
+python app.py
+```
+
+Then, you can access the demo at the address shown in the terminal.
+
+### 2. PBR Texture Generation
+
+Will be released soon. Please stay tuned!
+
+## 🧩 Related Packages
+
+TRELLIS.2 is built upon several specialized high-performance packages developed by our team:
+
+* **[O-Voxel](o-voxel):**
+ Core library handling the logic for converting between textured meshes and the O-Voxel representation, ensuring instant bidirectional transformation.
+* **[FlexGEMM](https://github.com/JeffreyXiang/FlexGEMM):**
+ Efficient sparse convolution implementation based on Triton, enabling rapid processing of sparse voxel structures.
+* **[CuMesh](https://github.com/JeffreyXiang/CuMesh):**
+ CUDA-accelerated mesh utilities used for high-speed post-processing, remeshing, decimation, and UV-unwrapping.
+
+
+## ⚖️ License
+
+This model and code are released under the **[MIT License](LICENSE)**.
+
+Please note that certain dependencies operate under separate license terms:
+
+- [**nvdiffrast**](https://github.com/NVlabs/nvdiffrast): Utilized for rendering generated 3D assets. This package is governed by its own [License](https://github.com/NVlabs/nvdiffrast/blob/main/LICENSE.txt).
+
+- [**nvdiffrec**](https://github.com/NVlabs/nvdiffrec): Implements the split-sum renderer for PBR materials. This package is governed by its own [License](https://github.com/NVlabs/nvdiffrec/blob/main/LICENSE.txt).
+
+## 📚 Citation
+
+If you find this model useful for your research, please cite our work:
+
+```bibtex
+@article{
+ xiang2025trellis2,
+ title={Native and Compact Structured Latents for 3D Generation},
+ author={Xiang, Jianfeng and Chen, Xiaoxue and Xu, Sicheng and Wang, Ruicheng and Lv, Zelong and Deng, Yu and Zhu, Hongyuan and Dong, Yue and Zhao, Hao and Yuan, Nicholas Jing and Yang, Jiaolong},
+ journal={Tech report},
+ year={2025}
+}
+```
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..e751608
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+
+
+## Security
+
+Microsoft takes the security of our software products and services seriously, which
+includes all source code repositories in our GitHub organizations.
+
+**Please do not report security vulnerabilities through public GitHub issues.**
+
+For security reporting information, locations, contact information, and policies,
+please review the latest guidance for Microsoft repositories at
+[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md).
+
+
\ No newline at end of file
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..b6dea25
--- /dev/null
+++ b/app.py
@@ -0,0 +1,645 @@
+import gradio as gr
+
+import os
+os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
+os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
+from datetime import datetime
+import shutil
+import cv2
+from typing import *
+import torch
+import numpy as np
+from PIL import Image
+import base64
+import io
+from trellis2.modules.sparse import SparseTensor
+from trellis2.pipelines import Trellis2ImageTo3DPipeline
+from trellis2.renderers import EnvMap
+from trellis2.utils import render_utils
+import o_voxel
+
+
+MAX_SEED = np.iinfo(np.int32).max
+TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
+MODES = [
+ {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
+ {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
+ {"name": "Base color", "icon": "assets/app/basecolor.png", "render_key": "base_color"},
+ {"name": "HDRI forest", "icon": "assets/app/hdri_forest.png", "render_key": "shaded_forest"},
+ {"name": "HDRI sunset", "icon": "assets/app/hdri_sunset.png", "render_key": "shaded_sunset"},
+ {"name": "HDRI courtyard", "icon": "assets/app/hdri_courtyard.png", "render_key": "shaded_courtyard"},
+]
+STEPS = 8
+DEFAULT_MODE = 3
+DEFAULT_STEP = 3
+
+
+css = """
+/* Overwrite Gradio Default Style */
+.stepper-wrapper {
+ padding: 0;
+}
+
+.stepper-container {
+ padding: 0;
+ align-items: center;
+}
+
+.step-button {
+ flex-direction: row;
+}
+
+.step-connector {
+ transform: none;
+}
+
+.step-number {
+ width: 16px;
+ height: 16px;
+}
+
+.step-label {
+ position: relative;
+ bottom: 0;
+}
+
+.wrap.center.full {
+ inset: 0;
+ height: 100%;
+}
+
+.wrap.center.full.translucent {
+ background: var(--block-background-fill);
+}
+
+.meta-text-center {
+ display: block !important;
+ position: absolute !important;
+ top: unset !important;
+ bottom: 0 !important;
+ right: 0 !important;
+ transform: unset !important;
+}
+
+/* Previewer */
+.previewer-container {
+ position: relative;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ width: 100%;
+ height: 722px;
+ margin: 0 auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.previewer-container .tips-icon {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ z-index: 10;
+ border-radius: 10px;
+ color: #fff;
+ background-color: var(--color-accent);
+ padding: 3px 6px;
+ user-select: none;
+}
+
+.previewer-container .tips-text {
+ position: absolute;
+ right: 10px;
+ top: 50px;
+ color: #fff;
+ background-color: var(--color-accent);
+ border-radius: 10px;
+ padding: 6px;
+ text-align: left;
+ max-width: 300px;
+ z-index: 10;
+ transition: all 0.3s;
+ opacity: 0%;
+ user-select: none;
+}
+
+.previewer-container .tips-text p {
+ font-size: 14px;
+ line-height: 1.2;
+}
+
+.tips-icon:hover + .tips-text {
+ display: block;
+ opacity: 100%;
+}
+
+/* Row 1: Display Modes */
+.previewer-container .mode-row {
+ width: 100%;
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+.previewer-container .mode-btn {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ cursor: pointer;
+ opacity: 0.5;
+ transition: all 0.2s;
+ border: 2px solid #ddd;
+ object-fit: cover;
+}
+.previewer-container .mode-btn:hover { opacity: 0.9; transform: scale(1.1); }
+.previewer-container .mode-btn.active {
+ opacity: 1;
+ border-color: var(--color-accent);
+ transform: scale(1.1);
+}
+
+/* Row 2: Display Image */
+.previewer-container .display-row {
+ margin-bottom: 20px;
+ min-height: 400px;
+ width: 100%;
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+.previewer-container .previewer-main-image {
+ max-width: 100%;
+ max-height: 100%;
+ flex-grow: 1;
+ object-fit: contain;
+ display: none;
+}
+.previewer-container .previewer-main-image.visible {
+ display: block;
+}
+
+/* Row 3: Custom HTML Slider */
+.previewer-container .slider-row {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 0 10px;
+}
+
+.previewer-container input[type=range] {
+ -webkit-appearance: none;
+ width: 100%;
+ max-width: 400px;
+ background: transparent;
+}
+.previewer-container input[type=range]::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 8px;
+ cursor: pointer;
+ background: #ddd;
+ border-radius: 5px;
+}
+.previewer-container input[type=range]::-webkit-slider-thumb {
+ height: 20px;
+ width: 20px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ cursor: pointer;
+ -webkit-appearance: none;
+ margin-top: -6px;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ transition: transform 0.1s;
+}
+.previewer-container input[type=range]::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+}
+
+/* Overwrite Previewer Block Style */
+.gradio-container .padded:has(.previewer-container) {
+ padding: 0 !important;
+}
+
+.gradio-container:has(.previewer-container) [data-testid="block-label"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+"""
+
+
+head = """
+
+"""
+
+
+empty_html = f"""
+
+"""
+
+
+def image_to_base64(image):
+ buffered = io.BytesIO()
+ image = image.convert("RGB")
+ image.save(buffered, format="jpeg", quality=85)
+ img_str = base64.b64encode(buffered.getvalue()).decode()
+ return f"data:image/jpeg;base64,{img_str}"
+
+
+def start_session(req: gr.Request):
+ user_dir = os.path.join(TMP_DIR, str(req.session_hash))
+ os.makedirs(user_dir, exist_ok=True)
+
+
+def end_session(req: gr.Request):
+ user_dir = os.path.join(TMP_DIR, str(req.session_hash))
+ shutil.rmtree(user_dir)
+
+
+def preprocess_image(image: Image.Image) -> Image.Image:
+ """
+ Preprocess the input image.
+
+ Args:
+ image (Image.Image): The input image.
+
+ Returns:
+ Image.Image: The preprocessed image.
+ """
+ processed_image = pipeline.preprocess_image(image)
+ return processed_image
+
+
+def pack_state(latents: Tuple[SparseTensor, SparseTensor, int]) -> dict:
+ shape_slat, tex_slat, res = latents
+ return {
+ 'shape_slat_feats': shape_slat.feats.cpu().numpy(),
+ 'tex_slat_feats': tex_slat.feats.cpu().numpy(),
+ 'coords': shape_slat.coords.cpu().numpy(),
+ 'res': res,
+ }
+
+
+def unpack_state(state: dict) -> Tuple[SparseTensor, SparseTensor, int]:
+ shape_slat = SparseTensor(
+ feats=torch.from_numpy(state['shape_slat_feats']).cuda(),
+ coords=torch.from_numpy(state['coords']).cuda(),
+ )
+ tex_slat = shape_slat.replace(torch.from_numpy(state['tex_slat_feats']).cuda())
+ return shape_slat, tex_slat, state['res']
+
+
+def get_seed(randomize_seed: bool, seed: int) -> int:
+ """
+ Get the random seed.
+ """
+ return np.random.randint(0, MAX_SEED) if randomize_seed else seed
+
+
+def image_to_3d(
+ image: Image.Image,
+ seed: int,
+ resolution: str,
+ ss_guidance_strength: float,
+ ss_guidance_rescale: float,
+ ss_sampling_steps: int,
+ ss_rescale_t: float,
+ shape_slat_guidance_strength: float,
+ shape_slat_guidance_rescale: float,
+ shape_slat_sampling_steps: int,
+ shape_slat_rescale_t: float,
+ tex_slat_guidance_strength: float,
+ tex_slat_guidance_rescale: float,
+ tex_slat_sampling_steps: int,
+ tex_slat_rescale_t: float,
+ req: gr.Request,
+ progress=gr.Progress(track_tqdm=True),
+) -> str:
+ # --- Sampling ---
+ outputs, latents = pipeline.run(
+ image,
+ seed=seed,
+ preprocess_image=False,
+ sparse_structure_sampler_params={
+ "steps": ss_sampling_steps,
+ "guidance_strength": ss_guidance_strength,
+ "guidance_rescale": ss_guidance_rescale,
+ "rescale_t": ss_rescale_t,
+ },
+ shape_slat_sampler_params={
+ "steps": shape_slat_sampling_steps,
+ "guidance_strength": shape_slat_guidance_strength,
+ "guidance_rescale": shape_slat_guidance_rescale,
+ "rescale_t": shape_slat_rescale_t,
+ },
+ tex_slat_sampler_params={
+ "steps": tex_slat_sampling_steps,
+ "guidance_strength": tex_slat_guidance_strength,
+ "guidance_rescale": tex_slat_guidance_rescale,
+ "rescale_t": tex_slat_rescale_t,
+ },
+ pipeline_type={
+ "512": "512",
+ "1024": "1024_cascade",
+ "1536": "1536_cascade",
+ }[resolution],
+ return_latent=True,
+ )
+ mesh = outputs[0]
+ mesh.simplify(16777216) # nvdiffrast limit
+ images = render_utils.render_snapshot(mesh, resolution=1024, r=2, fov=36, nviews=STEPS, envmap=envmap)
+ state = pack_state(latents)
+ torch.cuda.empty_cache()
+
+ # --- HTML Construction ---
+ # The Stack of 48 Images
+ images_html = ""
+ for m_idx, mode in enumerate(MODES):
+ for s_idx in range(STEPS):
+ # ID Naming Convention: view-m{mode}-s{step}
+ unique_id = f"view-m{m_idx}-s{s_idx}"
+
+ # Logic: Only Mode 0, Step 0 is visible initially
+ is_visible = (m_idx == DEFAULT_MODE and s_idx == DEFAULT_STEP)
+ vis_class = "visible" if is_visible else ""
+
+ # Image Source
+ img_base64 = image_to_base64(Image.fromarray(images[mode['render_key']][s_idx]))
+
+ # Render the Tag
+ images_html += f"""
+
+ """
+
+ # Button Row HTML
+ btns_html = ""
+ for idx, mode in enumerate(MODES):
+ active_class = "active" if idx == DEFAULT_MODE else ""
+ # Note: onclick calls the JS function defined in Head
+ btns_html += f"""
+
+ """
+
+ # Assemble the full component
+ full_html = f"""
+
+
+
💡Tips
+
+
● Render Mode - Click on the circular buttons to switch between different render modes.
+
● View Angle - Drag the slider to change the view angle.
+
+
+
+
+
+ {images_html}
+
+
+
+
+ {btns_html}
+
+
+
+
+
+
+
+ """
+
+ return state, full_html
+
+
+def extract_glb(
+ state: dict,
+ decimation_target: int,
+ texture_size: int,
+ req: gr.Request,
+ progress=gr.Progress(track_tqdm=True),
+) -> Tuple[str, str]:
+ """
+ Extract a GLB file from the 3D model.
+
+ Args:
+ state (dict): The state of the generated 3D model.
+ decimation_target (int): The target face count for decimation.
+ texture_size (int): The texture resolution.
+
+ Returns:
+ str: The path to the extracted GLB file.
+ """
+ user_dir = os.path.join(TMP_DIR, str(req.session_hash))
+ shape_slat, tex_slat, res = unpack_state(state)
+ mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
+ glb = o_voxel.postprocess.to_glb(
+ vertices=mesh.vertices,
+ faces=mesh.faces,
+ attr_volume=mesh.attrs,
+ coords=mesh.coords,
+ attr_layout=pipeline.pbr_attr_layout,
+ grid_size=res,
+ aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
+ decimation_target=decimation_target,
+ texture_size=texture_size,
+ remesh=True,
+ remesh_band=1,
+ remesh_project=0,
+ use_tqdm=True,
+ )
+ now = datetime.now()
+ timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
+ os.makedirs(user_dir, exist_ok=True)
+ glb_path = os.path.join(user_dir, f'sample_{timestamp}.glb')
+ glb.export(glb_path, extension_webp=True)
+ torch.cuda.empty_cache()
+ return glb_path, glb_path
+
+
+with gr.Blocks(delete_cache=(600, 600)) as demo:
+ gr.Markdown("""
+ ## Image to 3D Asset with [TRELLIS.2](https://microsoft.github.io/trellis.2)
+ * Upload an image (preferably with an alpha-masked foreground object) and click Generate to create a 3D asset.
+ * Click Extract GLB to export and download the generated GLB file if you're satisfied with the result. Otherwise, try another time.
+ """)
+
+ with gr.Row():
+ with gr.Column(scale=1, min_width=360):
+ image_prompt = gr.Image(label="Image Prompt", format="png", image_mode="RGBA", type="pil", height=400)
+
+ resolution = gr.Radio(["512", "1024", "1536"], label="Resolution", value="1024")
+ seed = gr.Slider(0, MAX_SEED, label="Seed", value=0, step=1)
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
+ decimation_target = gr.Slider(100000, 1000000, label="Decimation Target", value=500000, step=10000)
+ texture_size = gr.Slider(1024, 4096, label="Texture Size", value=2048, step=1024)
+
+ generate_btn = gr.Button("Generate")
+
+ with gr.Accordion(label="Advanced Settings", open=False):
+ gr.Markdown("Stage 1: Sparse Structure Generation")
+ with gr.Row():
+ ss_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
+ ss_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.7, step=0.01)
+ ss_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
+ ss_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=5.0, step=0.1)
+ gr.Markdown("Stage 2: Shape Generation")
+ with gr.Row():
+ shape_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
+ shape_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.5, step=0.01)
+ shape_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
+ shape_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
+ gr.Markdown("Stage 3: Material Generation")
+ with gr.Row():
+ tex_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=1.0, step=0.1)
+ tex_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.0, step=0.01)
+ tex_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
+ tex_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
+
+ with gr.Column(scale=10):
+ with gr.Walkthrough(selected=0) as walkthrough:
+ with gr.Step("Preview", id=0):
+ preview_output = gr.HTML(empty_html, label="3D Asset Preview", show_label=True, container=True)
+ extract_btn = gr.Button("Extract GLB")
+ with gr.Step("Extract", id=1):
+ glb_output = gr.Model3D(label="Extracted GLB", height=724, show_label=True, display_mode="solid", clear_color=(0.25, 0.25, 0.25, 1.0))
+ download_btn = gr.DownloadButton(label="Download GLB")
+
+ with gr.Column(scale=1, min_width=172):
+ examples = gr.Examples(
+ examples=[
+ f'assets/example_image/{image}'
+ for image in os.listdir("assets/example_image")
+ ],
+ inputs=[image_prompt],
+ fn=preprocess_image,
+ outputs=[image_prompt],
+ run_on_click=True,
+ examples_per_page=18,
+ )
+
+ output_buf = gr.State()
+
+
+ # Handlers
+ demo.load(start_session)
+ demo.unload(end_session)
+
+ image_prompt.upload(
+ preprocess_image,
+ inputs=[image_prompt],
+ outputs=[image_prompt],
+ )
+
+ generate_btn.click(
+ get_seed,
+ inputs=[randomize_seed, seed],
+ outputs=[seed],
+ ).then(
+ lambda: gr.Walkthrough(selected=0), outputs=walkthrough
+ ).then(
+ image_to_3d,
+ inputs=[
+ image_prompt, seed, resolution,
+ ss_guidance_strength, ss_guidance_rescale, ss_sampling_steps, ss_rescale_t,
+ shape_slat_guidance_strength, shape_slat_guidance_rescale, shape_slat_sampling_steps, shape_slat_rescale_t,
+ tex_slat_guidance_strength, tex_slat_guidance_rescale, tex_slat_sampling_steps, tex_slat_rescale_t,
+ ],
+ outputs=[output_buf, preview_output],
+ )
+
+ extract_btn.click(
+ lambda: gr.Walkthrough(selected=1), outputs=walkthrough
+ ).then(
+ extract_glb,
+ inputs=[output_buf, decimation_target, texture_size],
+ outputs=[glb_output, download_btn],
+ )
+
+
+# Launch the Gradio app
+if __name__ == "__main__":
+ os.makedirs(TMP_DIR, exist_ok=True)
+
+ # Construct ui components
+ btn_img_base64_strs = {}
+ for i in range(len(MODES)):
+ icon = Image.open(MODES[i]['icon'])
+ MODES[i]['icon_base64'] = image_to_base64(icon)
+
+ pipeline = Trellis2ImageTo3DPipeline.from_pretrained('microsoft/TRELLIS.2-4B')
+ pipeline.cuda()
+
+ envmap = {
+ 'forest': EnvMap(torch.tensor(
+ cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB),
+ dtype=torch.float32, device='cuda'
+ )),
+ 'sunset': EnvMap(torch.tensor(
+ cv2.cvtColor(cv2.imread('assets/hdri/sunset.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB),
+ dtype=torch.float32, device='cuda'
+ )),
+ 'courtyard': EnvMap(torch.tensor(
+ cv2.cvtColor(cv2.imread('assets/hdri/courtyard.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB),
+ dtype=torch.float32, device='cuda'
+ )),
+ }
+
+ demo.launch(css=css, head=head)
diff --git a/assets/app/basecolor.png b/assets/app/basecolor.png
new file mode 100644
index 0000000..e7dbeaf
Binary files /dev/null and b/assets/app/basecolor.png differ
diff --git a/assets/app/clay.png b/assets/app/clay.png
new file mode 100644
index 0000000..e02866a
Binary files /dev/null and b/assets/app/clay.png differ
diff --git a/assets/app/hdri_city.png b/assets/app/hdri_city.png
new file mode 100644
index 0000000..43e1c2a
Binary files /dev/null and b/assets/app/hdri_city.png differ
diff --git a/assets/app/hdri_courtyard.png b/assets/app/hdri_courtyard.png
new file mode 100644
index 0000000..4261ad6
Binary files /dev/null and b/assets/app/hdri_courtyard.png differ
diff --git a/assets/app/hdri_forest.png b/assets/app/hdri_forest.png
new file mode 100644
index 0000000..7617fe1
Binary files /dev/null and b/assets/app/hdri_forest.png differ
diff --git a/assets/app/hdri_interior.png b/assets/app/hdri_interior.png
new file mode 100644
index 0000000..e00c1d6
Binary files /dev/null and b/assets/app/hdri_interior.png differ
diff --git a/assets/app/hdri_night.png b/assets/app/hdri_night.png
new file mode 100644
index 0000000..f0423d2
Binary files /dev/null and b/assets/app/hdri_night.png differ
diff --git a/assets/app/hdri_studio.png b/assets/app/hdri_studio.png
new file mode 100644
index 0000000..0f5a4e8
Binary files /dev/null and b/assets/app/hdri_studio.png differ
diff --git a/assets/app/hdri_sunrise.png b/assets/app/hdri_sunrise.png
new file mode 100644
index 0000000..9cee3bb
Binary files /dev/null and b/assets/app/hdri_sunrise.png differ
diff --git a/assets/app/hdri_sunset.png b/assets/app/hdri_sunset.png
new file mode 100644
index 0000000..bd67070
Binary files /dev/null and b/assets/app/hdri_sunset.png differ
diff --git a/assets/app/normal.png b/assets/app/normal.png
new file mode 100644
index 0000000..352e92b
Binary files /dev/null and b/assets/app/normal.png differ
diff --git a/assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp b/assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp
new file mode 100644
index 0000000..4522b59
Binary files /dev/null and b/assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp differ
diff --git a/assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp b/assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp
new file mode 100644
index 0000000..712c0e0
Binary files /dev/null and b/assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp differ
diff --git a/assets/example_image/0f168a4b1b6e96c72e9627c97a212c27a4572250ff58e25703b9d0c2bc74191a.webp b/assets/example_image/0f168a4b1b6e96c72e9627c97a212c27a4572250ff58e25703b9d0c2bc74191a.webp
new file mode 100644
index 0000000..d565e59
Binary files /dev/null and b/assets/example_image/0f168a4b1b6e96c72e9627c97a212c27a4572250ff58e25703b9d0c2bc74191a.webp differ
diff --git a/assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp b/assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp
new file mode 100644
index 0000000..07fa20c
Binary files /dev/null and b/assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp differ
diff --git a/assets/example_image/154c88671d9e8785bd909e9283bc87fb2709ac7ce13890832603ea7533981a46.webp b/assets/example_image/154c88671d9e8785bd909e9283bc87fb2709ac7ce13890832603ea7533981a46.webp
new file mode 100644
index 0000000..3c3ac5d
Binary files /dev/null and b/assets/example_image/154c88671d9e8785bd909e9283bc87fb2709ac7ce13890832603ea7533981a46.webp differ
diff --git a/assets/example_image/1c359e94f2d699055c78487c90626cf5f1d7460c8fc04e60a286507e5286a28d.webp b/assets/example_image/1c359e94f2d699055c78487c90626cf5f1d7460c8fc04e60a286507e5286a28d.webp
new file mode 100644
index 0000000..e596271
Binary files /dev/null and b/assets/example_image/1c359e94f2d699055c78487c90626cf5f1d7460c8fc04e60a286507e5286a28d.webp differ
diff --git a/assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp b/assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp
new file mode 100644
index 0000000..679c0e7
Binary files /dev/null and b/assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp differ
diff --git a/assets/example_image/25d412fe36aab9f33913bc9f5e2fb1ff6458bdb286bf14397162c672c95d3697.webp b/assets/example_image/25d412fe36aab9f33913bc9f5e2fb1ff6458bdb286bf14397162c672c95d3697.webp
new file mode 100644
index 0000000..79631f5
Binary files /dev/null and b/assets/example_image/25d412fe36aab9f33913bc9f5e2fb1ff6458bdb286bf14397162c672c95d3697.webp differ
diff --git a/assets/example_image/26717a7dad644a5cf7554e8e6d06cf82d3dd9bbae31620b36cc7eb38b8de7ac9.webp b/assets/example_image/26717a7dad644a5cf7554e8e6d06cf82d3dd9bbae31620b36cc7eb38b8de7ac9.webp
new file mode 100644
index 0000000..b833f8e
Binary files /dev/null and b/assets/example_image/26717a7dad644a5cf7554e8e6d06cf82d3dd9bbae31620b36cc7eb38b8de7ac9.webp differ
diff --git a/assets/example_image/290af2dd390c95db88a35b8062fdd2ac1a9c28edc6533bc6a26ab2c83c523c61.webp b/assets/example_image/290af2dd390c95db88a35b8062fdd2ac1a9c28edc6533bc6a26ab2c83c523c61.webp
new file mode 100644
index 0000000..c9c24a4
Binary files /dev/null and b/assets/example_image/290af2dd390c95db88a35b8062fdd2ac1a9c28edc6533bc6a26ab2c83c523c61.webp differ
diff --git a/assets/example_image/2bb0932314bae71eec94d0d01a20d3f761ade9664e013b9a9a43c00a2f44163a.webp b/assets/example_image/2bb0932314bae71eec94d0d01a20d3f761ade9664e013b9a9a43c00a2f44163a.webp
new file mode 100644
index 0000000..58d29d8
Binary files /dev/null and b/assets/example_image/2bb0932314bae71eec94d0d01a20d3f761ade9664e013b9a9a43c00a2f44163a.webp differ
diff --git a/assets/example_image/3723615e3766742ae35b09517152a58c36d62b707bc60d7f76f8a6c922add2c0.webp b/assets/example_image/3723615e3766742ae35b09517152a58c36d62b707bc60d7f76f8a6c922add2c0.webp
new file mode 100644
index 0000000..a05f7c6
Binary files /dev/null and b/assets/example_image/3723615e3766742ae35b09517152a58c36d62b707bc60d7f76f8a6c922add2c0.webp differ
diff --git a/assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp b/assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp
new file mode 100644
index 0000000..7411bc6
Binary files /dev/null and b/assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp differ
diff --git a/assets/example_image/39488b45bb4820ff0f31bb07cb8d0a19ebd991adbcb22a10fc89ee41c59219ee.webp b/assets/example_image/39488b45bb4820ff0f31bb07cb8d0a19ebd991adbcb22a10fc89ee41c59219ee.webp
new file mode 100644
index 0000000..5f00cc2
Binary files /dev/null and b/assets/example_image/39488b45bb4820ff0f31bb07cb8d0a19ebd991adbcb22a10fc89ee41c59219ee.webp differ
diff --git a/assets/example_image/454e7d8a30486c0635369936e7bec5677b78ae5f436d0e46af0d533738be859f.webp b/assets/example_image/454e7d8a30486c0635369936e7bec5677b78ae5f436d0e46af0d533738be859f.webp
new file mode 100644
index 0000000..84c7d46
Binary files /dev/null and b/assets/example_image/454e7d8a30486c0635369936e7bec5677b78ae5f436d0e46af0d533738be859f.webp differ
diff --git a/assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp b/assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp
new file mode 100644
index 0000000..8f5902c
Binary files /dev/null and b/assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp differ
diff --git a/assets/example_image/4dae7ef0224e9305533c4801ce8144d5b3a89d883ca5d35bdb0aebb860ff705f.webp b/assets/example_image/4dae7ef0224e9305533c4801ce8144d5b3a89d883ca5d35bdb0aebb860ff705f.webp
new file mode 100644
index 0000000..016b45b
Binary files /dev/null and b/assets/example_image/4dae7ef0224e9305533c4801ce8144d5b3a89d883ca5d35bdb0aebb860ff705f.webp differ
diff --git a/assets/example_image/50b70c5f88a5961d2c786158655d2fce5c3b214b2717956500a66a4e5b5fbe37.webp b/assets/example_image/50b70c5f88a5961d2c786158655d2fce5c3b214b2717956500a66a4e5b5fbe37.webp
new file mode 100644
index 0000000..2dd5403
Binary files /dev/null and b/assets/example_image/50b70c5f88a5961d2c786158655d2fce5c3b214b2717956500a66a4e5b5fbe37.webp differ
diff --git a/assets/example_image/51b1b31d40476b123db70a51ae0b5f8b8d0db695b616bc2ec4e6324eb178fc14.webp b/assets/example_image/51b1b31d40476b123db70a51ae0b5f8b8d0db695b616bc2ec4e6324eb178fc14.webp
new file mode 100644
index 0000000..9886218
Binary files /dev/null and b/assets/example_image/51b1b31d40476b123db70a51ae0b5f8b8d0db695b616bc2ec4e6324eb178fc14.webp differ
diff --git a/assets/example_image/52284bf45134c59a94be150a5b18b9cc3619ada4b30ded8d8d0288383b8c016f.webp b/assets/example_image/52284bf45134c59a94be150a5b18b9cc3619ada4b30ded8d8d0288383b8c016f.webp
new file mode 100644
index 0000000..fd577dc
Binary files /dev/null and b/assets/example_image/52284bf45134c59a94be150a5b18b9cc3619ada4b30ded8d8d0288383b8c016f.webp differ
diff --git a/assets/example_image/5a020584b95cf3db3b6420e9b09fb93e7c0f4046e61076e5b4c65c63dc1f5837.webp b/assets/example_image/5a020584b95cf3db3b6420e9b09fb93e7c0f4046e61076e5b4c65c63dc1f5837.webp
new file mode 100644
index 0000000..1b66d25
Binary files /dev/null and b/assets/example_image/5a020584b95cf3db3b6420e9b09fb93e7c0f4046e61076e5b4c65c63dc1f5837.webp differ
diff --git a/assets/example_image/5a6c81d3b2afca4323e4b8b379e2cf06d18371a57fc8c5dc24b57e60e3216690.webp b/assets/example_image/5a6c81d3b2afca4323e4b8b379e2cf06d18371a57fc8c5dc24b57e60e3216690.webp
new file mode 100644
index 0000000..4c4cb39
Binary files /dev/null and b/assets/example_image/5a6c81d3b2afca4323e4b8b379e2cf06d18371a57fc8c5dc24b57e60e3216690.webp differ
diff --git a/assets/example_image/5c80e5e03a3b60b6f03eaf555ba1dafc0e4230c472d7e8c8e2c5ca0a0dfcef10.webp b/assets/example_image/5c80e5e03a3b60b6f03eaf555ba1dafc0e4230c472d7e8c8e2c5ca0a0dfcef10.webp
new file mode 100644
index 0000000..79b9221
Binary files /dev/null and b/assets/example_image/5c80e5e03a3b60b6f03eaf555ba1dafc0e4230c472d7e8c8e2c5ca0a0dfcef10.webp differ
diff --git a/assets/example_image/61fea9d08e0bd9a067c9f696621dc89165afb5aab318d0701bc025d7863dabf0.webp b/assets/example_image/61fea9d08e0bd9a067c9f696621dc89165afb5aab318d0701bc025d7863dabf0.webp
new file mode 100644
index 0000000..57b1643
Binary files /dev/null and b/assets/example_image/61fea9d08e0bd9a067c9f696621dc89165afb5aab318d0701bc025d7863dabf0.webp differ
diff --git a/assets/example_image/65433d02fc56dae164719ec29cb9646c0383aa1d0e24f0bb592899f08428d68e.webp b/assets/example_image/65433d02fc56dae164719ec29cb9646c0383aa1d0e24f0bb592899f08428d68e.webp
new file mode 100644
index 0000000..5204c86
Binary files /dev/null and b/assets/example_image/65433d02fc56dae164719ec29cb9646c0383aa1d0e24f0bb592899f08428d68e.webp differ
diff --git a/assets/example_image/6b6d89d46d7f53e6409dbe695a9ef8f97c5257e641da35015a78579e903acdad.webp b/assets/example_image/6b6d89d46d7f53e6409dbe695a9ef8f97c5257e641da35015a78579e903acdad.webp
new file mode 100644
index 0000000..8f787a2
Binary files /dev/null and b/assets/example_image/6b6d89d46d7f53e6409dbe695a9ef8f97c5257e641da35015a78579e903acdad.webp differ
diff --git a/assets/example_image/74fe541e8c8eac8d0b5d8ba144307f6c07ed832cd19bf1d431c74292002028cd.webp b/assets/example_image/74fe541e8c8eac8d0b5d8ba144307f6c07ed832cd19bf1d431c74292002028cd.webp
new file mode 100644
index 0000000..54a78c8
Binary files /dev/null and b/assets/example_image/74fe541e8c8eac8d0b5d8ba144307f6c07ed832cd19bf1d431c74292002028cd.webp differ
diff --git a/assets/example_image/799ab13a23fe319a6876b8bf48007d0374d514f5e7aa31210e9b2cecfbace082.webp b/assets/example_image/799ab13a23fe319a6876b8bf48007d0374d514f5e7aa31210e9b2cecfbace082.webp
new file mode 100644
index 0000000..f25880a
Binary files /dev/null and b/assets/example_image/799ab13a23fe319a6876b8bf48007d0374d514f5e7aa31210e9b2cecfbace082.webp differ
diff --git a/assets/example_image/7b540da337f576ffce2adc36c7459b9bbbfd845ab2160a6abbe986f1f906f6cd.webp b/assets/example_image/7b540da337f576ffce2adc36c7459b9bbbfd845ab2160a6abbe986f1f906f6cd.webp
new file mode 100644
index 0000000..b7a521f
Binary files /dev/null and b/assets/example_image/7b540da337f576ffce2adc36c7459b9bbbfd845ab2160a6abbe986f1f906f6cd.webp differ
diff --git a/assets/example_image/7baa867b4790b8596ee120f9b171b727fd9428c41980577a518505507c99d8a0.webp b/assets/example_image/7baa867b4790b8596ee120f9b171b727fd9428c41980577a518505507c99d8a0.webp
new file mode 100644
index 0000000..baba6ce
Binary files /dev/null and b/assets/example_image/7baa867b4790b8596ee120f9b171b727fd9428c41980577a518505507c99d8a0.webp differ
diff --git a/assets/example_image/7bd0521d20ee4805d1462a0ffb7d9aacc15180c2b741c9ac42a0d82ad3d340cb.webp b/assets/example_image/7bd0521d20ee4805d1462a0ffb7d9aacc15180c2b741c9ac42a0d82ad3d340cb.webp
new file mode 100644
index 0000000..0de4b3c
Binary files /dev/null and b/assets/example_image/7bd0521d20ee4805d1462a0ffb7d9aacc15180c2b741c9ac42a0d82ad3d340cb.webp differ
diff --git a/assets/example_image/7d585a8475db078593486367d98b5efa9368a60a3528c555b96026a1a674aa54.webp b/assets/example_image/7d585a8475db078593486367d98b5efa9368a60a3528c555b96026a1a674aa54.webp
new file mode 100644
index 0000000..883d922
Binary files /dev/null and b/assets/example_image/7d585a8475db078593486367d98b5efa9368a60a3528c555b96026a1a674aa54.webp differ
diff --git a/assets/example_image/7d6f4da4eafcc60243daf6ed210853df394a8bad7e701cadf551e21abcc77869.webp b/assets/example_image/7d6f4da4eafcc60243daf6ed210853df394a8bad7e701cadf551e21abcc77869.webp
new file mode 100644
index 0000000..2843ff2
Binary files /dev/null and b/assets/example_image/7d6f4da4eafcc60243daf6ed210853df394a8bad7e701cadf551e21abcc77869.webp differ
diff --git a/assets/example_image/7d7659d5943e85a73a4ffe33c6dd48f5d79601e9bf11b103516f419ce9fbf713.webp b/assets/example_image/7d7659d5943e85a73a4ffe33c6dd48f5d79601e9bf11b103516f419ce9fbf713.webp
new file mode 100644
index 0000000..7a90b8a
Binary files /dev/null and b/assets/example_image/7d7659d5943e85a73a4ffe33c6dd48f5d79601e9bf11b103516f419ce9fbf713.webp differ
diff --git a/assets/example_image/80ad7988fc2ce62fc655b21a8950865566ec3f5a8b4398f2502db6414a3e6834.webp b/assets/example_image/80ad7988fc2ce62fc655b21a8950865566ec3f5a8b4398f2502db6414a3e6834.webp
new file mode 100644
index 0000000..f268f16
Binary files /dev/null and b/assets/example_image/80ad7988fc2ce62fc655b21a8950865566ec3f5a8b4398f2502db6414a3e6834.webp differ
diff --git a/assets/example_image/8aa698c59aab48d4ce69a558d9159107890e3d64e522af404d9635ad0be21f88.webp b/assets/example_image/8aa698c59aab48d4ce69a558d9159107890e3d64e522af404d9635ad0be21f88.webp
new file mode 100644
index 0000000..0e5c91a
Binary files /dev/null and b/assets/example_image/8aa698c59aab48d4ce69a558d9159107890e3d64e522af404d9635ad0be21f88.webp differ
diff --git a/assets/example_image/8ce83f6a28910e755902de10918672e77dd23476f43f0f1521c48667de6cea84.webp b/assets/example_image/8ce83f6a28910e755902de10918672e77dd23476f43f0f1521c48667de6cea84.webp
new file mode 100644
index 0000000..f0aa5be
Binary files /dev/null and b/assets/example_image/8ce83f6a28910e755902de10918672e77dd23476f43f0f1521c48667de6cea84.webp differ
diff --git a/assets/example_image/8e12cf0977c0476396e7112f04b73d4d73569421173fcb553213d45030bddec3.webp b/assets/example_image/8e12cf0977c0476396e7112f04b73d4d73569421173fcb553213d45030bddec3.webp
new file mode 100644
index 0000000..be60e0f
Binary files /dev/null and b/assets/example_image/8e12cf0977c0476396e7112f04b73d4d73569421173fcb553213d45030bddec3.webp differ
diff --git a/assets/example_image/901d8de4c2011a8502a0decd0adec0fc7418f26165cd52ced64fd44f720353ef.webp b/assets/example_image/901d8de4c2011a8502a0decd0adec0fc7418f26165cd52ced64fd44f720353ef.webp
new file mode 100644
index 0000000..bcee0ee
Binary files /dev/null and b/assets/example_image/901d8de4c2011a8502a0decd0adec0fc7418f26165cd52ced64fd44f720353ef.webp differ
diff --git a/assets/example_image/95db3c13622788ec311ae4dfa24dd88732c66ca5e340a0bf3465d2a528204037.webp b/assets/example_image/95db3c13622788ec311ae4dfa24dd88732c66ca5e340a0bf3465d2a528204037.webp
new file mode 100644
index 0000000..2b782b4
Binary files /dev/null and b/assets/example_image/95db3c13622788ec311ae4dfa24dd88732c66ca5e340a0bf3465d2a528204037.webp differ
diff --git a/assets/example_image/9c306c7bd0e857285f536fb500c0828e5fad4e23c3ceeab92c888c568fa19101.webp b/assets/example_image/9c306c7bd0e857285f536fb500c0828e5fad4e23c3ceeab92c888c568fa19101.webp
new file mode 100644
index 0000000..9b82e77
Binary files /dev/null and b/assets/example_image/9c306c7bd0e857285f536fb500c0828e5fad4e23c3ceeab92c888c568fa19101.webp differ
diff --git a/assets/example_image/T.png b/assets/example_image/T.png
new file mode 100755
index 0000000..187c772
Binary files /dev/null and b/assets/example_image/T.png differ
diff --git a/assets/example_image/a13d176cd7a7d457b42d1b32223bcff1a45dafbbb42c6a272b97d65ac2f2eb52.webp b/assets/example_image/a13d176cd7a7d457b42d1b32223bcff1a45dafbbb42c6a272b97d65ac2f2eb52.webp
new file mode 100644
index 0000000..112d8a2
Binary files /dev/null and b/assets/example_image/a13d176cd7a7d457b42d1b32223bcff1a45dafbbb42c6a272b97d65ac2f2eb52.webp differ
diff --git a/assets/example_image/a306e2ee5cbc3da45e7db48d75a0cade0bb7eee263a74bc6820c617afaba1302.webp b/assets/example_image/a306e2ee5cbc3da45e7db48d75a0cade0bb7eee263a74bc6820c617afaba1302.webp
new file mode 100644
index 0000000..bf6e730
Binary files /dev/null and b/assets/example_image/a306e2ee5cbc3da45e7db48d75a0cade0bb7eee263a74bc6820c617afaba1302.webp differ
diff --git a/assets/example_image/a3d0c28c7d9c6f23adb941c4def2523572c903a94469abcaa7dd1398d28af8f1.webp b/assets/example_image/a3d0c28c7d9c6f23adb941c4def2523572c903a94469abcaa7dd1398d28af8f1.webp
new file mode 100644
index 0000000..677c79b
Binary files /dev/null and b/assets/example_image/a3d0c28c7d9c6f23adb941c4def2523572c903a94469abcaa7dd1398d28af8f1.webp differ
diff --git a/assets/example_image/a63d2595e10229067b19cb167fe2bdc152dabfd8b62ae45fc1655a4cf66509bc.webp b/assets/example_image/a63d2595e10229067b19cb167fe2bdc152dabfd8b62ae45fc1655a4cf66509bc.webp
new file mode 100644
index 0000000..5123114
Binary files /dev/null and b/assets/example_image/a63d2595e10229067b19cb167fe2bdc152dabfd8b62ae45fc1655a4cf66509bc.webp differ
diff --git a/assets/example_image/ab3bb3e183991253ae66c06d44dc6105f3c113a1a1f819ab57a93c6f60b0d32b.webp b/assets/example_image/ab3bb3e183991253ae66c06d44dc6105f3c113a1a1f819ab57a93c6f60b0d32b.webp
new file mode 100644
index 0000000..6e8a099
Binary files /dev/null and b/assets/example_image/ab3bb3e183991253ae66c06d44dc6105f3c113a1a1f819ab57a93c6f60b0d32b.webp differ
diff --git a/assets/example_image/b205f4483c47bd1fec8e229163361e4fdff9f77923c5e968343b8f1dd76b61dc.webp b/assets/example_image/b205f4483c47bd1fec8e229163361e4fdff9f77923c5e968343b8f1dd76b61dc.webp
new file mode 100644
index 0000000..fe4f257
Binary files /dev/null and b/assets/example_image/b205f4483c47bd1fec8e229163361e4fdff9f77923c5e968343b8f1dd76b61dc.webp differ
diff --git a/assets/example_image/b358d0eb96a68ac4ba1f2fb6d44ea2225f95fdfbf9cf4e0da08650c3704f1d23.webp b/assets/example_image/b358d0eb96a68ac4ba1f2fb6d44ea2225f95fdfbf9cf4e0da08650c3704f1d23.webp
new file mode 100644
index 0000000..bbff41b
Binary files /dev/null and b/assets/example_image/b358d0eb96a68ac4ba1f2fb6d44ea2225f95fdfbf9cf4e0da08650c3704f1d23.webp differ
diff --git a/assets/example_image/bb3190891dd8341c9d6d3d4faa6525c6ecdac19945526904928f6bcd2f3f45f1.webp b/assets/example_image/bb3190891dd8341c9d6d3d4faa6525c6ecdac19945526904928f6bcd2f3f45f1.webp
new file mode 100644
index 0000000..dcb1ada
Binary files /dev/null and b/assets/example_image/bb3190891dd8341c9d6d3d4faa6525c6ecdac19945526904928f6bcd2f3f45f1.webp differ
diff --git a/assets/example_image/be7deb26f4fdd2080d4288668af4c39e526564282c579559ff8a4126ca4ed6c1.webp b/assets/example_image/be7deb26f4fdd2080d4288668af4c39e526564282c579559ff8a4126ca4ed6c1.webp
new file mode 100644
index 0000000..3fcff55
Binary files /dev/null and b/assets/example_image/be7deb26f4fdd2080d4288668af4c39e526564282c579559ff8a4126ca4ed6c1.webp differ
diff --git a/assets/example_image/c2125d086c2529638841f38918ae1defbf33e6796d827253885b4c51e601034f.webp b/assets/example_image/c2125d086c2529638841f38918ae1defbf33e6796d827253885b4c51e601034f.webp
new file mode 100644
index 0000000..ada14ed
Binary files /dev/null and b/assets/example_image/c2125d086c2529638841f38918ae1defbf33e6796d827253885b4c51e601034f.webp differ
diff --git a/assets/example_image/c3d714bc125f06ce1187799d5ca10736b4064a24c141e627089aad2bdedf7aa5.webp b/assets/example_image/c3d714bc125f06ce1187799d5ca10736b4064a24c141e627089aad2bdedf7aa5.webp
new file mode 100644
index 0000000..213fe68
Binary files /dev/null and b/assets/example_image/c3d714bc125f06ce1187799d5ca10736b4064a24c141e627089aad2bdedf7aa5.webp differ
diff --git a/assets/example_image/c9340e744541f310bf89838f652602961d3e5950b31cd349bcbfc7e59e15cd2e.webp b/assets/example_image/c9340e744541f310bf89838f652602961d3e5950b31cd349bcbfc7e59e15cd2e.webp
new file mode 100644
index 0000000..6c33a0b
Binary files /dev/null and b/assets/example_image/c9340e744541f310bf89838f652602961d3e5950b31cd349bcbfc7e59e15cd2e.webp differ
diff --git a/assets/example_image/cd3c309f17eee5ad6afe4e001765893ade20b653f611365c93d158286b4cee96.webp b/assets/example_image/cd3c309f17eee5ad6afe4e001765893ade20b653f611365c93d158286b4cee96.webp
new file mode 100644
index 0000000..6532f7c
Binary files /dev/null and b/assets/example_image/cd3c309f17eee5ad6afe4e001765893ade20b653f611365c93d158286b4cee96.webp differ
diff --git a/assets/example_image/cdf996a6cc218918eeb90209891ce306a230e6d9cca2a3d9bbb37c6d7b6bd318.webp b/assets/example_image/cdf996a6cc218918eeb90209891ce306a230e6d9cca2a3d9bbb37c6d7b6bd318.webp
new file mode 100644
index 0000000..a5d7046
Binary files /dev/null and b/assets/example_image/cdf996a6cc218918eeb90209891ce306a230e6d9cca2a3d9bbb37c6d7b6bd318.webp differ
diff --git a/assets/example_image/d39c2bd426456bd686de33f924524d18eb47343a5f080826aa3cb8e77de5147b.webp b/assets/example_image/d39c2bd426456bd686de33f924524d18eb47343a5f080826aa3cb8e77de5147b.webp
new file mode 100644
index 0000000..dc233f0
Binary files /dev/null and b/assets/example_image/d39c2bd426456bd686de33f924524d18eb47343a5f080826aa3cb8e77de5147b.webp differ
diff --git a/assets/example_image/d64c94dffdadf82d46004d11412b5a3b2a17f1b4ddb428477a7ba38652adf973.webp b/assets/example_image/d64c94dffdadf82d46004d11412b5a3b2a17f1b4ddb428477a7ba38652adf973.webp
new file mode 100644
index 0000000..e78c058
Binary files /dev/null and b/assets/example_image/d64c94dffdadf82d46004d11412b5a3b2a17f1b4ddb428477a7ba38652adf973.webp differ
diff --git a/assets/example_image/dd4c51c13a996b9eec9c954a45cd5cd457059bf9f030aadde48d88225a9f3321.webp b/assets/example_image/dd4c51c13a996b9eec9c954a45cd5cd457059bf9f030aadde48d88225a9f3321.webp
new file mode 100644
index 0000000..65b852d
Binary files /dev/null and b/assets/example_image/dd4c51c13a996b9eec9c954a45cd5cd457059bf9f030aadde48d88225a9f3321.webp differ
diff --git a/assets/example_image/e10465728ebea1e055524f97ac5d47cebf82a672f07a05409aa07d826c9d9f37.webp b/assets/example_image/e10465728ebea1e055524f97ac5d47cebf82a672f07a05409aa07d826c9d9f37.webp
new file mode 100644
index 0000000..a773e5c
Binary files /dev/null and b/assets/example_image/e10465728ebea1e055524f97ac5d47cebf82a672f07a05409aa07d826c9d9f37.webp differ
diff --git a/assets/example_image/e134444178eae855cfdefb9e5259d076df5e34f780ee44d4ad604483ff69cc74.webp b/assets/example_image/e134444178eae855cfdefb9e5259d076df5e34f780ee44d4ad604483ff69cc74.webp
new file mode 100644
index 0000000..6430da9
Binary files /dev/null and b/assets/example_image/e134444178eae855cfdefb9e5259d076df5e34f780ee44d4ad604483ff69cc74.webp differ
diff --git a/assets/example_image/e3c57169ce3d5ce10b3c10acef20b81ca774b54a17aabe74e8aca320c7b07b55.webp b/assets/example_image/e3c57169ce3d5ce10b3c10acef20b81ca774b54a17aabe74e8aca320c7b07b55.webp
new file mode 100644
index 0000000..9492191
Binary files /dev/null and b/assets/example_image/e3c57169ce3d5ce10b3c10acef20b81ca774b54a17aabe74e8aca320c7b07b55.webp differ
diff --git a/assets/example_image/e4d6b2f3a18c3e0f5146a5b40cda6c95d7f69372b2e741c023e5ec9661deda2b.webp b/assets/example_image/e4d6b2f3a18c3e0f5146a5b40cda6c95d7f69372b2e741c023e5ec9661deda2b.webp
new file mode 100644
index 0000000..689df60
Binary files /dev/null and b/assets/example_image/e4d6b2f3a18c3e0f5146a5b40cda6c95d7f69372b2e741c023e5ec9661deda2b.webp differ
diff --git a/assets/example_image/e513fcd6c897b249fc4bff54268b4d0bbab6403503ecf3846d92feb892536e5e.webp b/assets/example_image/e513fcd6c897b249fc4bff54268b4d0bbab6403503ecf3846d92feb892536e5e.webp
new file mode 100644
index 0000000..e64acde
Binary files /dev/null and b/assets/example_image/e513fcd6c897b249fc4bff54268b4d0bbab6403503ecf3846d92feb892536e5e.webp differ
diff --git a/assets/example_image/ebd09565cf0b6593aced573dffdfff34915aa359c60ec5dd0b30cd91a7f153c8.webp b/assets/example_image/ebd09565cf0b6593aced573dffdfff34915aa359c60ec5dd0b30cd91a7f153c8.webp
new file mode 100644
index 0000000..c9fb3fb
Binary files /dev/null and b/assets/example_image/ebd09565cf0b6593aced573dffdfff34915aa359c60ec5dd0b30cd91a7f153c8.webp differ
diff --git a/assets/example_image/ee8ecf658fde9c58830c021b2e30d0d5e7e492ef52febe7192a6c74fbf1b0472.webp b/assets/example_image/ee8ecf658fde9c58830c021b2e30d0d5e7e492ef52febe7192a6c74fbf1b0472.webp
new file mode 100644
index 0000000..2e161b2
Binary files /dev/null and b/assets/example_image/ee8ecf658fde9c58830c021b2e30d0d5e7e492ef52febe7192a6c74fbf1b0472.webp differ
diff --git a/assets/example_image/f351569ddc61116da4a7b929bccdab144d011f56b9603e6e72abea05236160f4.webp b/assets/example_image/f351569ddc61116da4a7b929bccdab144d011f56b9603e6e72abea05236160f4.webp
new file mode 100644
index 0000000..bb2ea88
Binary files /dev/null and b/assets/example_image/f351569ddc61116da4a7b929bccdab144d011f56b9603e6e72abea05236160f4.webp differ
diff --git a/assets/example_image/f5332118a0cda9cd13fe13d4be2b00437e702d1f9af51ebb6b75219a572a6ce9.webp b/assets/example_image/f5332118a0cda9cd13fe13d4be2b00437e702d1f9af51ebb6b75219a572a6ce9.webp
new file mode 100644
index 0000000..73e6a55
Binary files /dev/null and b/assets/example_image/f5332118a0cda9cd13fe13d4be2b00437e702d1f9af51ebb6b75219a572a6ce9.webp differ
diff --git a/assets/example_image/f8920788b704531f7a7e875afd7c5c423d62e0a987e9495c63893c2cb4d2b5dc.webp b/assets/example_image/f8920788b704531f7a7e875afd7c5c423d62e0a987e9495c63893c2cb4d2b5dc.webp
new file mode 100644
index 0000000..4f6cbd0
Binary files /dev/null and b/assets/example_image/f8920788b704531f7a7e875afd7c5c423d62e0a987e9495c63893c2cb4d2b5dc.webp differ
diff --git a/assets/example_image/f8a7eafe26a4f3ebd26a9e7d0289e4a40b5a93e9234e94ec3e1071c352acc65a.webp b/assets/example_image/f8a7eafe26a4f3ebd26a9e7d0289e4a40b5a93e9234e94ec3e1071c352acc65a.webp
new file mode 100644
index 0000000..2e144a5
Binary files /dev/null and b/assets/example_image/f8a7eafe26a4f3ebd26a9e7d0289e4a40b5a93e9234e94ec3e1071c352acc65a.webp differ
diff --git a/assets/example_image/f94e2b76494ce2cf1874611273e5fb3d76b395793bb5647492fa85c2ce0a248b.webp b/assets/example_image/f94e2b76494ce2cf1874611273e5fb3d76b395793bb5647492fa85c2ce0a248b.webp
new file mode 100644
index 0000000..9c24d0e
Binary files /dev/null and b/assets/example_image/f94e2b76494ce2cf1874611273e5fb3d76b395793bb5647492fa85c2ce0a248b.webp differ
diff --git a/assets/example_image/fdf979f5227f24b554fca28aa71c351beb7b1be2be236b50bbe07f59e9b8a50c.webp b/assets/example_image/fdf979f5227f24b554fca28aa71c351beb7b1be2be236b50bbe07f59e9b8a50c.webp
new file mode 100644
index 0000000..f90db41
Binary files /dev/null and b/assets/example_image/fdf979f5227f24b554fca28aa71c351beb7b1be2be236b50bbe07f59e9b8a50c.webp differ
diff --git a/assets/hdri/city.exr b/assets/hdri/city.exr
new file mode 100644
index 0000000..d922066
Binary files /dev/null and b/assets/hdri/city.exr differ
diff --git a/assets/hdri/courtyard.exr b/assets/hdri/courtyard.exr
new file mode 100644
index 0000000..b70a0e7
Binary files /dev/null and b/assets/hdri/courtyard.exr differ
diff --git a/assets/hdri/forest.exr b/assets/hdri/forest.exr
new file mode 100644
index 0000000..846b87d
Binary files /dev/null and b/assets/hdri/forest.exr differ
diff --git a/assets/hdri/interior.exr b/assets/hdri/interior.exr
new file mode 100644
index 0000000..92d40ca
Binary files /dev/null and b/assets/hdri/interior.exr differ
diff --git a/assets/hdri/license.txt b/assets/hdri/license.txt
new file mode 100644
index 0000000..eba5cb8
--- /dev/null
+++ b/assets/hdri/license.txt
@@ -0,0 +1,15 @@
+All HDRIs are licensed as CC0.
+
+These were created by Greg Zaal (Poly Haven https://polyhaven.com).
+Originals used for each HDRI:
+- City: https://polyhaven.com/a/portland_landing_pad
+- Courtyard: https://polyhaven.com/a/courtyard
+- Forest: https://polyhaven.com/a/ninomaru_teien
+- Interior: https://polyhaven.com/a/hotel_room
+- Night: Probably https://polyhaven.com/a/moonless_golf
+- Studio: Probably https://polyhaven.com/a/studio_small_01
+- Sunrise: https://polyhaven.com/a/spruit_sunrise
+- Sunset: https://polyhaven.com/a/venice_sunset
+
+1K resolution of each was taken, and compressed with oiiotool:
+oiiotool input.exr --ch R,G,B -d float --compression dwab:300 --clamp:min=0.0:max=32000.0 -o output.exr
diff --git a/assets/hdri/night.exr b/assets/hdri/night.exr
new file mode 100644
index 0000000..a207d99
Binary files /dev/null and b/assets/hdri/night.exr differ
diff --git a/assets/hdri/studio.exr b/assets/hdri/studio.exr
new file mode 100644
index 0000000..baf478d
Binary files /dev/null and b/assets/hdri/studio.exr differ
diff --git a/assets/hdri/sunrise.exr b/assets/hdri/sunrise.exr
new file mode 100644
index 0000000..985a9a5
Binary files /dev/null and b/assets/hdri/sunrise.exr differ
diff --git a/assets/hdri/sunset.exr b/assets/hdri/sunset.exr
new file mode 100644
index 0000000..e86206e
Binary files /dev/null and b/assets/hdri/sunset.exr differ
diff --git a/assets/teaser.webp b/assets/teaser.webp
new file mode 100644
index 0000000..40a0431
Binary files /dev/null and b/assets/teaser.webp differ
diff --git a/example.py b/example.py
new file mode 100644
index 0000000..475a3b1
--- /dev/null
+++ b/example.py
@@ -0,0 +1,48 @@
+import os
+os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
+os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True" # Can save GPU memory
+import cv2
+import imageio
+from PIL import Image
+import torch
+from trellis2.pipelines import Trellis2ImageTo3DPipeline
+from trellis2.utils import render_utils
+from trellis2.renderers import EnvMap
+import o_voxel
+
+# 1. Setup Environment Map
+envmap = EnvMap(torch.tensor(
+ cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB),
+ dtype=torch.float32, device='cuda'
+))
+
+# 2. Load Pipeline
+pipeline = Trellis2ImageTo3DPipeline.from_pretrained("microsoft/TRELLIS.2-4B")
+pipeline.cuda()
+
+# 3. Load Image & Run
+image = Image.open("assets/example_image/T.png")
+mesh = pipeline.run(image)[0]
+mesh.simplify(16777216) # nvdiffrast limit
+
+# 4. Render Video
+video = render_utils.make_pbr_vis_frames(render_utils.render_video(mesh, envmap=envmap))
+imageio.mimsave("sample.mp4", video, fps=15)
+
+# 5. Export to GLB
+glb = o_voxel.postprocess.to_glb(
+ vertices = mesh.vertices,
+ faces = mesh.faces,
+ attr_volume = mesh.attrs,
+ coords = mesh.coords,
+ attr_layout = mesh.layout,
+ voxel_size = mesh.voxel_size,
+ aabb = [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
+ decimation_target = 1000000,
+ texture_size = 4096,
+ remesh = True,
+ remesh_band = 1,
+ remesh_project = 0,
+ verbose = True
+)
+glb.export("sample.glb", extension_webp=True)
\ No newline at end of file
diff --git a/o-voxel/README.md b/o-voxel/README.md
new file mode 100644
index 0000000..0dba0bf
--- /dev/null
+++ b/o-voxel/README.md
@@ -0,0 +1,174 @@
+# O-Voxel: A Native 3D Representation
+
+**O-Voxel** is a sparse, voxel-based native 3D representation designed for high-quality 3D generation and reconstruction. Unlike traditional methods that rely on fields (e.g., Occupancy fields, SDFs), O-Voxel utilizes a **Flexible Dual Grid** formulation to robustly represent surfaces with arbitrary topology (including non-manifold and open surfaces) and **volumetric surface properties** such as Physically-Based Rendering (PBR) material attributes.
+
+This library provides an efficient implementation for the instant bidirectional conversion between Meshes and O-Voxels, along with tools for sparse voxel compression, serialization, and rendering.
+
+
+
+## Key Features
+
+- **🧱 Flexible Dual Grid**: A geometry representation that solves a enhanced QEF (Quadratic Error Function) to accurately capture sharp features and open boundaries without requiring watertight meshes.
+- **🎨 Volumetric PBR Attributes**: Native support for physically-based rendering properties (Base Color, Metallic, Roughness, Opacity) aligned with the sparse voxel grid.
+- **⚡ Instant Bidirectional Conversion**: Rapid `Mesh <-> O-Voxel` conversion without expensive SDF evaluation, flood-filling, or iterative optimization.
+- **💾 Efficient Compression**: Supports custom `.vxz` format for compact storage of sparse voxel structures using Z-order/Hilbert curve encoding.
+- **🛠️ Production Ready**: Tools to export converted assets directly to `.glb` with UV unwrapping and texture baking.
+
+## Installation
+
+```bash
+git clone -b main https://github.com/microsoft/TRELLIS.2.git --recursive
+pip install TRELLIS.2/o_voxel --no-build-isolation
+```
+
+## Quick Start
+
+> See also the [examples](examples) directory for more detailed usage.
+
+### 1. Convert Mesh to O-Voxel [[link]](examples/mesh2ovox.py)
+Convert a standard 3D mesh (with textures) into the O-Voxel representation.
+
+```python
+asset = trimesh.load("path/to/mesh.glb")
+
+# 1. Geometry Voxelization (Flexible Dual Grid)
+# Returns: occupied indices, dual vertices (QEF solution), and edge intersected
+mesh = asset.to_mesh()
+vertices = torch.from_numpy(mesh.vertices).float()
+faces = torch.from_numpy(mesh.faces).long()
+voxel_indices, dual_vertices, intersected = o_voxel.convert.mesh_to_flexible_dual_grid(
+ vertices, faces,
+ grid_size=RES, # Resolution
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]], # Axis-aligned bounding box
+ face_weight=1.0, # Face term weight in QEF
+ boundary_weight=0.2, # Boundary term weight in QEF
+ regularization_weight=1e-2, # Regularization term weight in QEF
+ timing=True
+)
+## sort to ensure align between geometry and material voxelization
+vid = o_voxel.serialize.encode_seq(voxel_indices)
+mapping = torch.argsort(vid)
+voxel_indices = voxel_indices[mapping]
+dual_vertices = dual_vertices[mapping]
+intersected = intersected[mapping]
+
+# 2. Material Voxelization (Volumetric Attributes)
+# Returns: dict containing 'base_color', 'metallic', 'roughness', etc.
+voxel_indices_mat, attributes = o_voxel.convert.textured_mesh_to_volumetric_attr(
+ asset,
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+ timing=True
+)
+## sort to ensure align between geometry and material voxelization
+vid_mat = o_voxel.serialize.encode_seq(voxel_indices_mat)
+mapping_mat = torch.argsort(vid_mat)
+attributes = {k: v[mapping_mat] for k, v in attributes.items()}
+
+# Save to compressed .vxz format
+## packing
+dual_vertices = dual_vertices * RES - voxel_indices
+dual_vertices = (torch.clamp(dual_vertices, 0, 1) * 255).type(torch.uint8)
+intersected = (intersected[:, 0:1] + 2 * intersected[:, 1:2] + 4 * intersected[:, 2:3]).type(torch.uint8)
+attributes['dual_vertices'] = dual_vertices
+attributes['intersected'] = intersected
+o_voxel.io.write("ovoxel_helmet.vxz", voxel_indices, attributes)
+```
+
+### 2. Recover Mesh from O-Voxel [[link]](examples/ovox2mesh.py)
+Reconstruct the surface mesh from the sparse voxel data.
+
+```python
+# Load data
+coords, data = o_voxel.io.read("path/to/ovoxel.vxz")
+dual_vertices = data['dual_vertices']
+intersected = data['intersected']
+base_color = data['base_color']
+## ... other attributes omitted for brevity
+
+# Depack
+dual_vertices = dual_vertices / 255
+intersected = torch.cat([
+ intersected % 2,
+ intersected // 2 % 2,
+ intersected // 4 % 2,
+], dim=-1).bool()
+
+# Extract Mesh
+# O-Voxel connects dual vertices to form quads, optionally splitting them
+# based on geometric features.
+rec_verts, rec_faces = o_voxel.convert.flexible_dual_grid_to_mesh(
+ coords.cuda(),
+ dual_vertices.cuda(),
+ intersected.cuda(),
+ split_weight=None, # Auto-split based on min angle if None
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+)
+```
+
+### 3. Export to GLB [[link]](examples/ovox2glb.py)
+For visualization in standard 3D viewers, you can clean, UV-unwrap, and bake the volumetric attributes into textures.
+
+```python
+# Assuming you have the reconstructed verts/faces and volume attributes
+mesh = o_voxel.postprocess.to_glb(
+ vertices=rec_verts,
+ faces=rec_faces,
+ attr_volume=attr_tensor, # Concatenated attributes
+ coords=coords,
+ attr_layout={'base_color': slice(0,3), 'metallic': slice(3,4), ...},
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+ decimation_target=100000,
+ texture_size=2048,
+ verbose=True,
+)
+mesh.export("rec_helmet.glb")
+```
+
+### 4. Voxel Rendering [[link]](examples/render_ovox.py)
+Render the voxel representation directly.
+
+```python
+# Load data
+coords, data = o_voxel.io.read("ovoxel_helmet.vxz")
+position = (coords / RES - 0.5).cuda()
+base_color = (data['base_color'] / 255).cuda()
+
+# Render
+renderer = o_voxel.rasterize.VoxelRenderer(
+ rendering_options={"resolution": 512, "ssaa": 2}
+)
+output = renderer.render(
+ position=position, # Voxel centers
+ attrs=base_color, # Color/Opacity etc.
+ voxel_size=1.0/RES,
+ extrinsics=extr,
+ intrinsics=intr
+)
+# output.attr contains the rendered image (C, H, W)
+```
+
+## API Overview
+
+### `o_voxel.convert`
+Core algorithms for the conversion between meshes and O-Voxels.
+* `mesh_to_flexible_dual_grid`: Determines the active sparse voxels and solves the QEF to determine dual vertex positions within voxels based on mesh-voxel grid intersections.
+* `flexible_dual_grid_to_mesh`: Reconnects dual vertices to form a surface.
+* `textured_mesh_to_volumetric_attr`: Samples texture maps into voxel space.
+
+### `o_voxel.io`
+Handles sparse voxel file I/O operations.
+* **Formats**: `.npz` (NumPy), `.ply` (Point Cloud), `.vxz` (Custom compressed, recommended).
+* **Functions**: `read()`, `write()`.
+
+### `o_voxel.serialize`
+Utilities for spatial hashing and ordering.
+* `encode_seq` / `decode_seq`: Converts 3D coordinates to/from Morton codes (Z-order) or Hilbert curves for efficient storage and processing.
+
+### `o_voxel.rasterize`
+* `VoxelRenderer`: A lightweight renderer for sparse voxel visualization during training.
+
+### `o_voxel.postprocess`
+* `to_glb`: A comprehensive pipeline for mesh cleaning, remeshing, UV unwrapping, and texture baking.
diff --git a/o-voxel/assets/overview.webp b/o-voxel/assets/overview.webp
new file mode 100644
index 0000000..0e8fb75
Binary files /dev/null and b/o-voxel/assets/overview.webp differ
diff --git a/o-voxel/examples/mesh2ovox.py b/o-voxel/examples/mesh2ovox.py
new file mode 100644
index 0000000..54d0c9b
--- /dev/null
+++ b/o-voxel/examples/mesh2ovox.py
@@ -0,0 +1,57 @@
+import torch
+import o_voxel
+import utils
+
+RES = 512
+
+asset = utils.get_helmet()
+
+# 0. Normalize asset to unit cube
+aabb = asset.bounding_box.bounds
+center = (aabb[0] + aabb[1]) / 2
+scale = 0.99999 / (aabb[1] - aabb[0]).max() # To avoid numerical issues
+asset.apply_translation(-center)
+asset.apply_scale(scale)
+
+# 1. Geometry Voxelization (Flexible Dual Grid)
+# Returns: occupied indices, dual vertices (QEF solution), and edge intersected
+mesh = asset.to_mesh()
+vertices = torch.from_numpy(mesh.vertices).float()
+faces = torch.from_numpy(mesh.faces).long()
+voxel_indices, dual_vertices, intersected = o_voxel.convert.mesh_to_flexible_dual_grid(
+ vertices, faces,
+ grid_size=RES, # Resolution
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]], # Axis-aligned bounding box
+ face_weight=1.0, # Face term weight in QEF
+ boundary_weight=0.2, # Boundary term weight in QEF
+ regularization_weight=1e-2, # Regularization term weight in QEF
+ timing=True
+)
+## sort to ensure align between geometry and material voxelization
+vid = o_voxel.serialize.encode_seq(voxel_indices)
+mapping = torch.argsort(vid)
+voxel_indices = voxel_indices[mapping]
+dual_vertices = dual_vertices[mapping]
+intersected = intersected[mapping]
+
+# 2. Material Voxelization (Volumetric Attributes)
+# Returns: dict containing 'base_color', 'metallic', 'roughness', etc.
+voxel_indices_mat, attributes = o_voxel.convert.textured_mesh_to_volumetric_attr(
+ asset,
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+ timing=True
+)
+## sort to ensure align between geometry and material voxelization
+vid_mat = o_voxel.serialize.encode_seq(voxel_indices_mat)
+mapping_mat = torch.argsort(vid_mat)
+attributes = {k: v[mapping_mat] for k, v in attributes.items()}
+
+# Save to compressed .vxz format
+## packing
+dual_vertices = dual_vertices * RES - voxel_indices
+dual_vertices = (torch.clamp(dual_vertices, 0, 1) * 255).type(torch.uint8)
+intersected = (intersected[:, 0:1] + 2 * intersected[:, 1:2] + 4 * intersected[:, 2:3]).type(torch.uint8)
+attributes['dual_vertices'] = dual_vertices
+attributes['intersected'] = intersected
+o_voxel.io.write("ovoxel_helmet.vxz", voxel_indices, attributes)
\ No newline at end of file
diff --git a/o-voxel/examples/ovox2glb.py b/o-voxel/examples/ovox2glb.py
new file mode 100644
index 0000000..2306310
--- /dev/null
+++ b/o-voxel/examples/ovox2glb.py
@@ -0,0 +1,52 @@
+import torch
+import o_voxel
+
+RES = 512
+
+# Load data
+coords, data = o_voxel.io.read("ovoxel_helmet.vxz")
+dual_vertices = data['dual_vertices']
+intersected = data['intersected']
+base_color = data['base_color']
+metallic = data['metallic']
+roughness = data['roughness']
+alpha = data['alpha']
+
+# Depack
+dual_vertices = dual_vertices / 255
+intersected = torch.cat([
+ intersected % 2,
+ intersected // 2 % 2,
+ intersected // 4 % 2,
+], dim=-1).bool()
+
+# Extract Mesh
+# O-Voxel connects dual vertices to form quads, optionally splitting them
+# based on geometric features.
+rec_verts, rec_faces = o_voxel.convert.flexible_dual_grid_to_mesh(
+ coords.cuda(),
+ dual_vertices.cuda(),
+ intersected.cuda(),
+ split_weight=None, # Auto-split based on min angle if None
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+)
+
+# Post-process
+attr_volume = torch.cat([base_color.cuda(), metallic.cuda(), roughness.cuda(), alpha.cuda()], dim=-1) / 255
+attr_layout = {'base_color': slice(0,3), 'metallic': slice(3,4), 'roughness': slice(4,5), 'alpha': slice(5,6)}
+mesh = o_voxel.postprocess.to_glb(
+ vertices=rec_verts,
+ faces=rec_faces,
+ attr_volume=attr_volume,
+ coords=coords.cuda(),
+ attr_layout=attr_layout,
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+ decimation_target=100000,
+ texture_size=2048,
+ verbose=True,
+)
+
+# Save as glb
+mesh.export("rec_helmet.glb")
diff --git a/o-voxel/examples/ovox2mesh.py b/o-voxel/examples/ovox2mesh.py
new file mode 100644
index 0000000..eb5a6be
--- /dev/null
+++ b/o-voxel/examples/ovox2mesh.py
@@ -0,0 +1,45 @@
+import torch
+import o_voxel
+import trimesh
+import trimesh.visual
+
+RES = 512
+
+# Load data
+coords, data = o_voxel.io.read("ovoxel_helmet.vxz")
+dual_vertices = data['dual_vertices']
+intersected = data['intersected']
+base_color = data['base_color']
+metallic = data['metallic']
+roughness = data['roughness']
+alpha = data['alpha']
+
+# Depack
+dual_vertices = dual_vertices / 255
+intersected = torch.cat([
+ intersected % 2,
+ intersected // 2 % 2,
+ intersected // 4 % 2,
+], dim=-1).bool()
+
+# Extract Mesh
+# O-Voxel connects dual vertices to form quads, optionally splitting them
+# based on geometric features.
+rec_verts, rec_faces = o_voxel.convert.flexible_dual_grid_to_mesh(
+ coords.cuda(),
+ dual_vertices.cuda(),
+ intersected.cuda(),
+ split_weight=None, # Auto-split based on min angle if None
+ grid_size=RES,
+ aabb=[[-0.5,-0.5,-0.5],[0.5,0.5,0.5]],
+)
+
+# Save as ply
+visual = trimesh.visual.ColorVisuals(
+ vertex_colors=base_color,
+)
+mesh = trimesh.Trimesh(
+ vertices=rec_verts.cpu(), faces=rec_faces.cpu(), visual=visual,
+ process=False
+)
+mesh.export("rec_helmet.ply")
diff --git a/o-voxel/examples/render_ovox.py b/o-voxel/examples/render_ovox.py
new file mode 100644
index 0000000..a4a27c0
--- /dev/null
+++ b/o-voxel/examples/render_ovox.py
@@ -0,0 +1,39 @@
+import torch
+import numpy as np
+import imageio
+import o_voxel
+import utils3d
+
+RES = 512
+
+# Load data
+coords, data = o_voxel.io.read("ovoxel_helmet.vxz")
+position = (coords / RES - 0.5).cuda()
+base_color = (data['base_color'] / 255).cuda()
+
+# Setup camera
+extr = utils3d.extrinsics_look_at(
+ eye=torch.tensor([1.2, 0.5, 1.2]),
+ look_at=torch.tensor([0.0, 0.0, 0.0]),
+ up=torch.tensor([0.0, 1.0, 0.0])
+).cuda()
+intr = utils3d.intrinsics_from_fov_xy(
+ fov_x=torch.deg2rad(torch.tensor(45.0)),
+ fov_y=torch.deg2rad(torch.tensor(45.0)),
+).cuda()
+
+# Render
+renderer = o_voxel.rasterize.VoxelRenderer(
+ rendering_options={"resolution": 512, "ssaa": 2}
+)
+output = renderer.render(
+ position=position, # Voxel centers
+ attrs=base_color, # Color/Opacity etc.
+ voxel_size=1.0/RES,
+ extrinsics=extr,
+ intrinsics=intr
+)
+image = np.clip(
+ output.attr.permute(1, 2, 0).cpu().numpy() * 255, 0, 255
+).astype(np.uint8)
+imageio.imwrite("ovoxel_helmet_visualization.png", image)
diff --git a/o-voxel/examples/utils.py b/o-voxel/examples/utils.py
new file mode 100644
index 0000000..f9a8a6a
--- /dev/null
+++ b/o-voxel/examples/utils.py
@@ -0,0 +1,27 @@
+import os
+import requests
+import tarfile
+import trimesh
+
+HELMET_URL = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/refs/heads/main/2.0/DamagedHelmet/glTF-Binary/DamagedHelmet.glb"
+CACHE_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "cache")
+
+
+def download_file(url, path):
+ print(f"Downloading from {url} ...")
+ resp = requests.get(url, stream=True)
+ resp.raise_for_status()
+
+ with open(path, "wb") as f:
+ for chunk in resp.iter_content(chunk_size=8192):
+ f.write(chunk)
+
+ print(f"Saved to {path}")
+
+
+def get_helmet() -> trimesh.Trimesh:
+ HELMET_PATH = os.path.join(CACHE_DIR, "helmet.glb")
+ if not os.path.exists(HELMET_PATH):
+ os.makedirs(CACHE_DIR, exist_ok=True)
+ download_file(HELMET_URL, HELMET_PATH)
+ return trimesh.load(HELMET_PATH)
diff --git a/o-voxel/o_voxel/__init__.py b/o-voxel/o_voxel/__init__.py
new file mode 100644
index 0000000..263b753
--- /dev/null
+++ b/o-voxel/o_voxel/__init__.py
@@ -0,0 +1,7 @@
+from . import (
+ convert,
+ io,
+ postprocess,
+ rasterize,
+ serialize
+)
\ No newline at end of file
diff --git a/o-voxel/o_voxel/convert/__init__.py b/o-voxel/o_voxel/convert/__init__.py
new file mode 100644
index 0000000..f7b6d8c
--- /dev/null
+++ b/o-voxel/o_voxel/convert/__init__.py
@@ -0,0 +1,2 @@
+from .flexible_dual_grid import *
+from .volumetic_attr import *
\ No newline at end of file
diff --git a/o-voxel/o_voxel/convert/flexible_dual_grid.py b/o-voxel/o_voxel/convert/flexible_dual_grid.py
new file mode 100644
index 0000000..7cf1397
--- /dev/null
+++ b/o-voxel/o_voxel/convert/flexible_dual_grid.py
@@ -0,0 +1,283 @@
+from typing import *
+import numpy as np
+import torch
+from .. import _C
+
+__all__ = [
+ "mesh_to_flexible_dual_grid",
+ "flexible_dual_grid_to_mesh",
+]
+
+
+def _init_hashmap(grid_size, capacity, device):
+ VOL = (grid_size[0] * grid_size[1] * grid_size[2]).item()
+
+ # If the number of elements in the tensor is less than 2^32, use uint32 as the hashmap type, otherwise use uint64.
+ if VOL < 2**32:
+ hashmap_keys = torch.full((capacity,), torch.iinfo(torch.uint32).max, dtype=torch.uint32, device=device)
+ elif VOL < 2**64:
+ hashmap_keys = torch.full((capacity,), torch.iinfo(torch.uint64).max, dtype=torch.uint64, device=device)
+ else:
+ raise ValueError(f"The spatial size is too large to fit in a hashmap. Get volumn {VOL} > 2^64.")
+
+ hashmap_vals = torch.empty((capacity,), dtype=torch.uint32, device=device)
+
+ return hashmap_keys, hashmap_vals
+
+
+@torch.no_grad()
+def mesh_to_flexible_dual_grid(
+ vertices: torch.Tensor,
+ faces: torch.Tensor,
+ voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
+ grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
+ aabb: Union[list, tuple, np.ndarray, torch.Tensor] = None,
+ face_weight: float = 1.0,
+ boundary_weight: float = 1.0,
+ regularization_weight: float = 0.1,
+ timing: bool = False,
+) -> Union[torch.Tensor, torch.Tensor, torch.Tensor]:
+ """
+ Voxelize a mesh into a sparse voxel grid.
+
+ Args:
+ vertices (torch.Tensor): The vertices of the mesh.
+ faces (torch.Tensor): The faces of the mesh.
+ voxel_size (float, list, tuple, np.ndarray, torch.Tensor): The size of each voxel.
+ grid_size (int, list, tuple, np.ndarray, torch.Tensor): The size of the grid.
+ NOTE: One of voxel_size and grid_size must be provided.
+ aabb (list, tuple, np.ndarray, torch.Tensor): The axis-aligned bounding box of the mesh.
+ If not provided, it will be computed automatically.
+ face_weight (float): The weight of the face term in the QEF when solving the dual vertices.
+ boundary_weight (float): The weight of the boundary term in the QEF when solving the dual vertices.
+ regularization_weight (float): The weight of the regularization term in the QEF when solving the dual vertices.
+ timing (bool): Whether to time the voxelization process.
+
+ Returns:
+ torch.Tensor: The indices of the voxels that are occupied by the mesh.
+ The shape of the tensor is (N, 3), where N is the number of occupied voxels.
+ torch.Tensor: The dual vertices of the mesh.
+ torch.Tensor: The intersected flag of each voxel.
+ """
+
+ # Load mesh
+ vertices = vertices.float()
+ faces = faces.int()
+
+ # Voxelize settings
+ assert voxel_size is not None or grid_size is not None, "Either voxel_size or grid_size must be provided"
+
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32)
+ assert isinstance(voxel_size, torch.Tensor), f"voxel_size must be a float, list, tuple, np.ndarray, or torch.Tensor, but got {type(voxel_size)}"
+ assert voxel_size.dim() == 1, f"voxel_size must be a 1D tensor, but got {voxel_size.shape}"
+ assert voxel_size.size(0) == 3, f"voxel_size must have 3 elements, but got {voxel_size.size(0)}"
+
+ if grid_size is not None:
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32)
+ assert isinstance(grid_size, torch.Tensor), f"grid_size must be an int, list, tuple, np.ndarray, or torch.Tensor, but got {type(grid_size)}"
+ assert grid_size.dim() == 1, f"grid_size must be a 1D tensor, but got {grid_size.shape}"
+ assert grid_size.size(0) == 3, f"grid_size must have 3 elements, but got {grid_size.size(0)}"
+
+ if aabb is not None:
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32)
+ assert isinstance(aabb, torch.Tensor), f"aabb must be a list, tuple, np.ndarray, or torch.Tensor, but got {type(aabb)}"
+ assert aabb.dim() == 2, f"aabb must be a 2D tensor, but got {aabb.shape}"
+ assert aabb.size(0) == 2, f"aabb must have 2 rows, but got {aabb.size(0)}"
+ assert aabb.size(1) == 3, f"aabb must have 3 columns, but got {aabb.size(1)}"
+
+ # Auto adjust aabb
+ if aabb is None:
+ min_xyz = vertices.min(dim=0).values
+ max_xyz = vertices.max(dim=0).values
+
+ if voxel_size is not None:
+ padding = torch.ceil((max_xyz - min_xyz) / voxel_size) * voxel_size - (max_xyz - min_xyz)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+ if grid_size is not None:
+ padding = (max_xyz - min_xyz) / (grid_size - 1)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+
+ aabb = torch.stack([min_xyz, max_xyz], dim=0).float().cuda()
+
+ # Fill voxel size or grid size
+ if voxel_size is None:
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+ if grid_size is None:
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+
+ # subdivide mesh
+ vertices = vertices - aabb[0].reshape(1, 3)
+ grid_range = torch.stack([torch.zeros_like(grid_size), grid_size], dim=0).int()
+
+ ret = _C.mesh_to_flexible_dual_grid_cpu(
+ vertices,
+ faces,
+ voxel_size,
+ grid_range,
+ face_weight,
+ boundary_weight,
+ regularization_weight,
+ timing,
+ )
+
+ return ret
+
+
+def flexible_dual_grid_to_mesh(
+ coords: torch.Tensor,
+ dual_vertices: torch.Tensor,
+ intersected_flag: torch.Tensor,
+ split_weight: Union[torch.Tensor, None],
+ aabb: Union[list, tuple, np.ndarray, torch.Tensor],
+ voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
+ grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
+ train: bool = False,
+):
+ """
+ Extract mesh from sparse voxel structures using flexible dual grid.
+
+ Args:
+ coords (torch.Tensor): The coordinates of the voxels.
+ dual_vertices (torch.Tensor): The dual vertices.
+ intersected_flag (torch.Tensor): The intersected flag.
+ split_weight (torch.Tensor): The split weight of each dual quad. If None, the algorithm
+ will split based on minimum angle.
+ aabb (list, tuple, np.ndarray, torch.Tensor): The axis-aligned bounding box of the mesh.
+ voxel_size (float, list, tuple, np.ndarray, torch.Tensor): The size of each voxel.
+ grid_size (int, list, tuple, np.ndarray, torch.Tensor): The size of the grid.
+ NOTE: One of voxel_size and grid_size must be provided.
+ train (bool): Whether to use training mode.
+
+ Returns:
+ vertices (torch.Tensor): The vertices of the mesh.
+ faces (torch.Tensor): The faces of the mesh.
+ """
+ # Static variables
+ if not hasattr(flexible_dual_grid_to_mesh, "edge_neighbor_voxel_offset"):
+ flexible_dual_grid_to_mesh.edge_neighbor_voxel_offset = torch.tensor([
+ [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], # x-axis
+ [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], # y-axis
+ [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], # z-axis
+ ], dtype=torch.int, device=coords.device).unsqueeze(0)
+ if not hasattr(flexible_dual_grid_to_mesh, "quad_split_1"):
+ flexible_dual_grid_to_mesh.quad_split_1 = torch.tensor([0, 1, 2, 0, 2, 3], dtype=torch.long, device=coords.device, requires_grad=False)
+ if not hasattr(flexible_dual_grid_to_mesh, "quad_split_2"):
+ flexible_dual_grid_to_mesh.quad_split_2 = torch.tensor([0, 1, 3, 3, 1, 2], dtype=torch.long, device=coords.device, requires_grad=False)
+ if not hasattr(flexible_dual_grid_to_mesh, "quad_split_train"):
+ flexible_dual_grid_to_mesh.quad_split_train = torch.tensor([0, 1, 4, 1, 2, 4, 2, 3, 4, 3, 0, 4], dtype=torch.long, device=coords.device, requires_grad=False)
+
+ # AABB
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32, device=coords.device)
+ assert isinstance(aabb, torch.Tensor), f"aabb must be a list, tuple, np.ndarray, or torch.Tensor, but got {type(aabb)}"
+ assert aabb.dim() == 2, f"aabb must be a 2D tensor, but got {aabb.shape}"
+ assert aabb.size(0) == 2, f"aabb must have 2 rows, but got {aabb.size(0)}"
+ assert aabb.size(1) == 3, f"aabb must have 3 columns, but got {aabb.size(1)}"
+
+ # Voxel size
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32, device=coords.device)
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+ else:
+ assert grid_size is not None, "Either voxel_size or grid_size must be provided"
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32, device=coords.device)
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+ assert isinstance(voxel_size, torch.Tensor), f"voxel_size must be a float, list, tuple, np.ndarray, or torch.Tensor, but got {type(voxel_size)}"
+ assert voxel_size.dim() == 1, f"voxel_size must be a 1D tensor, but got {voxel_size.shape}"
+ assert voxel_size.size(0) == 3, f"voxel_size must have 3 elements, but got {voxel_size.size(0)}"
+ assert isinstance(grid_size, torch.Tensor), f"grid_size must be an int, list, tuple, np.ndarray, or torch.Tensor, but got {type(grid_size)}"
+ assert grid_size.dim() == 1, f"grid_size must be a 1D tensor, but got {grid_size.shape}"
+ assert grid_size.size(0) == 3, f"grid_size must have 3 elements, but got {grid_size.size(0)}"
+
+ # Extract mesh
+ N = dual_vertices.shape[0]
+ mesh_vertices = (coords.float() + dual_vertices) / (2 * N) - 0.5
+
+ # Store active voxels into hashmap
+ hashmap = _init_hashmap(grid_size, 2 * N, device=coords.device)
+ _C.hashmap_insert_3d_idx_as_val_cuda(*hashmap, torch.cat([torch.zeros_like(coords[:, :1]), coords], dim=-1), *grid_size.tolist())
+
+ # Find connected voxels
+ edge_neighbor_voxel = coords.reshape(N, 1, 1, 3) + flexible_dual_grid_to_mesh.edge_neighbor_voxel_offset # (N, 3, 4, 3)
+ connected_voxel = edge_neighbor_voxel[intersected_flag] # (M, 4, 3)
+ M = connected_voxel.shape[0]
+ connected_voxel_hash_key = torch.cat([
+ torch.zeros((M * 4, 1), dtype=torch.int, device=coords.device),
+ connected_voxel.reshape(-1, 3)
+ ], dim=1)
+ connected_voxel_indices = _C.hashmap_lookup_3d_cuda(*hashmap, connected_voxel_hash_key, *grid_size.tolist()).reshape(M, 4).int()
+ connected_voxel_valid = (connected_voxel_indices != 0xffffffff).all(dim=1)
+ quad_indices = connected_voxel_indices[connected_voxel_valid].int() # (L, 4)
+ L = quad_indices.shape[0]
+
+ # Construct triangles
+ if not train:
+ mesh_vertices = (coords.float() + dual_vertices) * voxel_size + aabb[0].reshape(1, 3)
+ if split_weight is None:
+ # if split 1
+ atempt_triangles_0 = quad_indices[:, flexible_dual_grid_to_mesh.quad_split_1]
+ normals0 = torch.cross(mesh_vertices[atempt_triangles_0[:, 1]] - mesh_vertices[atempt_triangles_0[:, 0]], mesh_vertices[atempt_triangles_0[:, 2]] - mesh_vertices[atempt_triangles_0[:, 0]])
+ normals1 = torch.cross(mesh_vertices[atempt_triangles_0[:, 2]] - mesh_vertices[atempt_triangles_0[:, 1]], mesh_vertices[atempt_triangles_0[:, 3]] - mesh_vertices[atempt_triangles_0[:, 1]])
+ align0 = (normals0 * normals1).sum(dim=1, keepdim=True).abs()
+ # if split 2
+ atempt_triangles_1 = quad_indices[:, flexible_dual_grid_to_mesh.quad_split_2]
+ normals0 = torch.cross(mesh_vertices[atempt_triangles_1[:, 1]] - mesh_vertices[atempt_triangles_1[:, 0]], mesh_vertices[atempt_triangles_1[:, 2]] - mesh_vertices[atempt_triangles_1[:, 0]])
+ normals1 = torch.cross(mesh_vertices[atempt_triangles_1[:, 2]] - mesh_vertices[atempt_triangles_1[:, 1]], mesh_vertices[atempt_triangles_1[:, 3]] - mesh_vertices[atempt_triangles_1[:, 1]])
+ align1 = (normals0 * normals1).sum(dim=1, keepdim=True).abs()
+ # select split
+ mesh_triangles = torch.where(align0 > align1, atempt_triangles_0, atempt_triangles_1).reshape(-1, 3)
+ else:
+ split_weight_ws = split_weight[quad_indices]
+ split_weight_ws_02 = split_weight_ws[:, 0] * split_weight_ws[:, 2]
+ split_weight_ws_13 = split_weight_ws[:, 1] * split_weight_ws[:, 3]
+ mesh_triangles = torch.where(
+ split_weight_ws_02 > split_weight_ws_13,
+ quad_indices[:, flexible_dual_grid_to_mesh.quad_split_1],
+ quad_indices[:, flexible_dual_grid_to_mesh.quad_split_2]
+ ).reshape(-1, 3)
+ else:
+ assert split_weight is not None, "split_weight must be provided in training mode"
+ mesh_vertices = (coords.float() + dual_vertices) * voxel_size + aabb[0].reshape(1, 3)
+ quad_vs = mesh_vertices[quad_indices]
+ mean_v02 = (quad_vs[:, 0] + quad_vs[:, 2]) / 2
+ mean_v13 = (quad_vs[:, 1] + quad_vs[:, 3]) / 2
+ split_weight_ws = split_weight[quad_indices]
+ split_weight_ws_02 = split_weight_ws[:, 0] * split_weight_ws[:, 2]
+ split_weight_ws_13 = split_weight_ws[:, 1] * split_weight_ws[:, 3]
+ mid_vertices = (
+ split_weight_ws_02 * mean_v02 +
+ split_weight_ws_13 * mean_v13
+ ) / (split_weight_ws_02 + split_weight_ws_13)
+ mesh_vertices = torch.cat([mesh_vertices, mid_vertices], dim=0)
+ quad_indices = torch.cat([quad_indices, torch.arange(N, N + L, device='cuda').unsqueeze(1)], dim=1)
+ mesh_triangles = quad_indices[:, flexible_dual_grid_to_mesh.quad_split_train].reshape(-1, 3)
+
+ return mesh_vertices, mesh_triangles
diff --git a/o-voxel/o_voxel/convert/volumetic_attr.py b/o-voxel/o_voxel/convert/volumetic_attr.py
new file mode 100644
index 0000000..77c71c1
--- /dev/null
+++ b/o-voxel/o_voxel/convert/volumetic_attr.py
@@ -0,0 +1,583 @@
+from typing import *
+import io
+from PIL import Image
+import torch
+import numpy as np
+from tqdm import tqdm
+import trimesh
+import trimesh.visual
+
+from .. import _C
+
+__all__ = [
+ "textured_mesh_to_volumetric_attr",
+ "blender_dump_to_volumetric_attr"
+]
+
+
+ALPHA_MODE_ENUM = {
+ "OPAQUE": 0,
+ "MASK": 1,
+ "BLEND": 2,
+}
+
+
+def is_power_of_two(n: int) -> bool:
+ return n > 0 and (n & (n - 1)) == 0
+
+
+def nearest_power_of_two(n: int) -> int:
+ if n < 1:
+ raise ValueError("n must be >= 1")
+ if is_power_of_two(n):
+ return n
+ lower = 2 ** (n.bit_length() - 1)
+ upper = 2 ** n.bit_length()
+ if n - lower < upper - n:
+ return lower
+ else:
+ return upper
+
+
+def textured_mesh_to_volumetric_attr(
+ mesh: Union[trimesh.Scene, trimesh.Trimesh, str],
+ voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
+ grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
+ aabb: Union[list, tuple, np.ndarray, torch.Tensor] = None,
+ mip_level_offset: float = 0.0,
+ verbose: bool = False,
+ timing: bool = False,
+) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Voxelize a mesh into a sparse voxel grid with PBR properties.
+
+ Args:
+ mesh (trimesh.Scene, trimesh.Trimesh, str): The input mesh.
+ If a string is provided, it will be loaded as a mesh using trimesh.load().
+ voxel_size (float, list, tuple, np.ndarray, torch.Tensor): The size of each voxel.
+ grid_size (int, list, tuple, np.ndarray, torch.Tensor): The size of the grid.
+ NOTE: One of voxel_size and grid_size must be provided.
+ aabb (list, tuple, np.ndarray, torch.Tensor): The axis-aligned bounding box of the mesh.
+ If not provided, it will be computed automatically.
+ tile_size (int): The size of the tiles used for each individual voxelization.
+ mip_level_offset (float): The mip level offset for texture mip level selection.
+ verbose (bool): Whether to print the settings.
+ timing (bool): Whether to print the timing information.
+
+ Returns:
+ torch.Tensor: The indices of the voxels that are occupied by the mesh.
+ Dict[str, torch.Tensor]: A dictionary containing the following keys:
+ - "base_color": The base color of the occupied voxels.
+ - "metallic": The metallic value of the occupied voxels.
+ - "roughness": The roughness value of the occupied voxels.
+ - "emissive": The emissive value of the occupied voxels.
+ - "alpha": The alpha value of the occupied voxels.
+ - "normal": The normal of the occupied voxels.
+ """
+
+ # Load mesh
+ if isinstance(mesh, str):
+ mesh = trimesh.load(mesh)
+ if isinstance(mesh, trimesh.Scene):
+ groups = mesh.dump()
+ if isinstance(mesh, trimesh.Trimesh):
+ groups = [mesh]
+ scene = trimesh.Scene(groups)
+
+ # Voxelize settings
+ assert voxel_size is not None or grid_size is not None, "Either voxel_size or grid_size must be provided"
+
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32)
+ assert isinstance(voxel_size, torch.Tensor), f"voxel_size must be a float, list, tuple, np.ndarray, or torch.Tensor, but got {type(voxel_size)}"
+ assert voxel_size.dim() == 1, f"voxel_size must be a 1D tensor, but got {voxel_size.shape}"
+ assert voxel_size.size(0) == 3, f"voxel_size must have 3 elements, but got {voxel_size.size(0)}"
+
+ if grid_size is not None:
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32)
+ assert isinstance(grid_size, torch.Tensor), f"grid_size must be an int, list, tuple, np.ndarray, or torch.Tensor, but got {type(grid_size)}"
+ assert grid_size.dim() == 1, f"grid_size must be a 1D tensor, but got {grid_size.shape}"
+ assert grid_size.size(0) == 3, f"grid_size must have 3 elements, but got {grid_size.size(0)}"
+
+ if aabb is not None:
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32)
+ assert isinstance(aabb, torch.Tensor), f"aabb must be a list, tuple, np.ndarray, or torch.Tensor, but got {type(aabb)}"
+ assert aabb.dim() == 2, f"aabb must be a 2D tensor, but got {aabb.shape}"
+ assert aabb.size(0) == 2, f"aabb must have 2 rows, but got {aabb.size(0)}"
+ assert aabb.size(1) == 3, f"aabb must have 3 columns, but got {aabb.size(1)}"
+
+ # Auto adjust aabb
+ if aabb is None:
+ aabb = scene.bounds
+ min_xyz = aabb[0]
+ max_xyz = aabb[1]
+
+ if voxel_size is not None:
+ padding = torch.ceil((max_xyz - min_xyz) / voxel_size) * voxel_size - (max_xyz - min_xyz)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+ if grid_size is not None:
+ padding = (max_xyz - min_xyz) / (grid_size - 1)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+
+ aabb = torch.stack([min_xyz, max_xyz], dim=0).float()
+
+ # Fill voxel size or grid size
+ if voxel_size is None:
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+ if grid_size is None:
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+
+ grid_range = torch.stack([torch.zeros_like(grid_size), grid_size], dim=0).int()
+
+ # Print settings
+ if verbose:
+ print(f"Voxelize settings:")
+ print(f" Voxel size: {voxel_size}")
+ print(f" Grid size: {grid_size}")
+ print(f" AABB: {aabb}")
+
+ # Load Scene
+ scene_buffers = {
+ 'triangles': [],
+ 'normals': [],
+ 'uvs': [],
+ 'material_ids': [],
+ 'base_color_factor': [],
+ 'base_color_texture': [],
+ 'metallic_factor': [],
+ 'metallic_texture': [],
+ 'roughness_factor': [],
+ 'roughness_texture': [],
+ 'emissive_factor': [],
+ 'emissive_texture': [],
+ 'alpha_mode': [],
+ 'alpha_cutoff': [],
+ 'alpha_factor': [],
+ 'alpha_texture': [],
+ 'normal_texture': [],
+ }
+ for sid, (name, g) in tqdm(enumerate(scene.geometry.items()), total=len(scene.geometry), desc="Loading Scene", disable=not verbose):
+ if verbose:
+ print(f"Geometry: {name}")
+ print(f" Visual: {g.visual}")
+ print(f" Triangles: {g.triangles.shape[0]}")
+ print(f" Vertices: {g.vertices.shape[0]}")
+ print(f" Normals: {g.vertex_normals.shape[0]}")
+ if g.visual.material.baseColorFactor is not None:
+ print(f" Base color factor: {g.visual.material.baseColorFactor}")
+ if g.visual.material.baseColorTexture is not None:
+ print(f" Base color texture: {g.visual.material.baseColorTexture.size} {g.visual.material.baseColorTexture.mode}")
+ if g.visual.material.metallicFactor is not None:
+ print(f" Metallic factor: {g.visual.material.metallicFactor}")
+ if g.visual.material.roughnessFactor is not None:
+ print(f" Roughness factor: {g.visual.material.roughnessFactor}")
+ if g.visual.material.metallicRoughnessTexture is not None:
+ print(f" Metallic roughness texture: {g.visual.material.metallicRoughnessTexture.size} {g.visual.material.metallicRoughnessTexture.mode}")
+ if g.visual.material.emissiveFactor is not None:
+ print(f" Emissive factor: {g.visual.material.emissiveFactor}")
+ if g.visual.material.emissiveTexture is not None:
+ print(f" Emissive texture: {g.visual.material.emissiveTexture.size} {g.visual.material.emissiveTexture.mode}")
+ if g.visual.material.alphaMode is not None:
+ print(f" Alpha mode: {g.visual.material.alphaMode}")
+ if g.visual.material.alphaCutoff is not None:
+ print(f" Alpha cutoff: {g.visual.material.alphaCutoff}")
+ if g.visual.material.normalTexture is not None:
+ print(f" Normal texture: {g.visual.material.normalTexture.size} {g.visual.material.normalTexture.mode}")
+
+ assert isinstance(g, trimesh.Trimesh), f"Only trimesh.Trimesh is supported, but got {type(g)}"
+ assert isinstance(g.visual, trimesh.visual.TextureVisuals), f"Only trimesh.visual.TextureVisuals is supported, but got {type(g.visual)}"
+ assert isinstance(g.visual.material, trimesh.visual.material.PBRMaterial), f"Only trimesh.visual.material.PBRMaterial is supported, but got {type(g.visual.material)}"
+ triangles = torch.tensor(g.triangles, dtype=torch.float32) - aabb[0].reshape(1, 1, 3) # [N, 3, 3]
+ normals = torch.tensor(g.vertex_normals[g.faces], dtype=torch.float32) # [N, 3, 3]
+ uvs = torch.tensor(g.visual.uv[g.faces], dtype=torch.float32) if g.visual.uv is not None \
+ else torch.zeros(g.triangles.shape[0], 3, 2, dtype=torch.float32) # [N, 3, 2]
+ baseColorFactor = torch.tensor(g.visual.material.baseColorFactor / 255, dtype=torch.float32) if g.visual.material.baseColorFactor is not None \
+ else torch.ones(3, dtype=torch.float32) # [3]
+ baseColorTexture = torch.tensor(np.array(g.visual.material.baseColorTexture.convert('RGBA'))[..., :3], dtype=torch.uint8) if g.visual.material.baseColorTexture is not None \
+ else torch.tensor([]) # [H, W, 3]
+ metallicFactor = g.visual.material.metallicFactor if g.visual.material.metallicFactor is not None else 1.0
+ metallicTexture = torch.tensor(np.array(g.visual.material.metallicRoughnessTexture.convert('RGB'))[..., 2], dtype=torch.uint8) if g.visual.material.metallicRoughnessTexture is not None \
+ else torch.tensor([]) # [H, W]
+ roughnessFactor = g.visual.material.roughnessFactor if g.visual.material.roughnessFactor is not None else 1.0
+ roughnessTexture = torch.tensor(np.array(g.visual.material.metallicRoughnessTexture.convert('RGB'))[..., 1], dtype=torch.uint8) if g.visual.material.metallicRoughnessTexture is not None \
+ else torch.tensor([]) # [H, W]
+ emissiveFactor = torch.tensor(g.visual.material.emissiveFactor, dtype=torch.float32) if g.visual.material.emissiveFactor is not None \
+ else torch.zeros(3, dtype=torch.float32) # [3]
+ emissiveTexture = torch.tensor(np.array(g.visual.material.emissiveTexture.convert('RGB'))[..., :3], dtype=torch.uint8) if g.visual.material.emissiveTexture is not None \
+ else torch.tensor([]) # [H, W, 3]
+ alphaMode = ALPHA_MODE_ENUM[g.visual.material.alphaMode] if g.visual.material.alphaMode in ALPHA_MODE_ENUM else 0
+ alphaCutoff = g.visual.material.alphaCutoff if g.visual.material.alphaCutoff is not None else 0.5
+ alphaFactor = g.visual.material.baseColorFactor[3] / 255 if g.visual.material.baseColorFactor is not None else 1.0
+ alphaTexture = torch.tensor(np.array(g.visual.material.baseColorTexture.convert('RGBA'))[..., 3], dtype=torch.uint8) if g.visual.material.baseColorTexture is not None and alphaMode != 0 \
+ else torch.tensor([]) # [H, W]
+ normalTexture = torch.tensor(np.array(g.visual.material.normalTexture.convert('RGB'))[..., :3], dtype=torch.uint8) if g.visual.material.normalTexture is not None \
+ else torch.tensor([]) # [H, W, 3]
+
+ scene_buffers['triangles'].append(triangles)
+ scene_buffers['normals'].append(normals)
+ scene_buffers['uvs'].append(uvs)
+ scene_buffers['material_ids'].append(torch.full((triangles.shape[0],), sid, dtype=torch.int32))
+ scene_buffers['base_color_factor'].append(baseColorFactor)
+ scene_buffers['base_color_texture'].append(baseColorTexture)
+ scene_buffers['metallic_factor'].append(metallicFactor)
+ scene_buffers['metallic_texture'].append(metallicTexture)
+ scene_buffers['roughness_factor'].append(roughnessFactor)
+ scene_buffers['roughness_texture'].append(roughnessTexture)
+ scene_buffers['emissive_factor'].append(emissiveFactor)
+ scene_buffers['emissive_texture'].append(emissiveTexture)
+ scene_buffers['alpha_mode'].append(alphaMode)
+ scene_buffers['alpha_cutoff'].append(alphaCutoff)
+ scene_buffers['alpha_factor'].append(alphaFactor)
+ scene_buffers['alpha_texture'].append(alphaTexture)
+ scene_buffers['normal_texture'].append(normalTexture)
+
+ scene_buffers['triangles'] = torch.cat(scene_buffers['triangles'], dim=0) # [N, 3, 3]
+ scene_buffers['normals'] = torch.cat(scene_buffers['normals'], dim=0) # [N, 3, 3]
+ scene_buffers['uvs'] = torch.cat(scene_buffers['uvs'], dim=0) # [N, 3, 2]
+ scene_buffers['material_ids'] = torch.cat(scene_buffers['material_ids'], dim=0) # [N]
+
+ # Voxelize
+ out_tuple = _C.textured_mesh_to_volumetric_attr_cpu(
+ voxel_size,
+ grid_range,
+ scene_buffers["triangles"],
+ scene_buffers["normals"],
+ scene_buffers["uvs"],
+ scene_buffers["material_ids"],
+ scene_buffers["base_color_factor"],
+ scene_buffers["base_color_texture"],
+ [1] * len(scene_buffers["base_color_texture"]),
+ [0] * len(scene_buffers["base_color_texture"]),
+ scene_buffers["metallic_factor"],
+ scene_buffers["metallic_texture"],
+ [1] * len(scene_buffers["metallic_texture"]),
+ [0] * len(scene_buffers["metallic_texture"]),
+ scene_buffers["roughness_factor"],
+ scene_buffers["roughness_texture"],
+ [1] * len(scene_buffers["roughness_texture"]),
+ [0] * len(scene_buffers["roughness_texture"]),
+ scene_buffers["emissive_factor"],
+ scene_buffers["emissive_texture"],
+ [1] * len(scene_buffers["emissive_texture"]),
+ [0] * len(scene_buffers["emissive_texture"]),
+ scene_buffers["alpha_mode"],
+ scene_buffers["alpha_cutoff"],
+ scene_buffers["alpha_factor"],
+ scene_buffers["alpha_texture"],
+ [1] * len(scene_buffers["alpha_texture"]),
+ [0] * len(scene_buffers["alpha_texture"]),
+ scene_buffers["normal_texture"],
+ [1] * len(scene_buffers["normal_texture"]),
+ [0] * len(scene_buffers["normal_texture"]),
+ mip_level_offset,
+ timing,
+ )
+
+ # Post process
+ coord = out_tuple[0]
+ attr = {
+ "base_color": torch.clamp(out_tuple[1] * 255, 0, 255).byte().reshape(-1, 3),
+ "metallic": torch.clamp(out_tuple[2] * 255, 0, 255).byte().reshape(-1, 1),
+ "roughness": torch.clamp(out_tuple[3] * 255, 0, 255).byte().reshape(-1, 1),
+ "emissive": torch.clamp(out_tuple[4] * 255, 0, 255).byte().reshape(-1, 3),
+ "alpha": torch.clamp(out_tuple[5] * 255, 0, 255).byte().reshape(-1, 1),
+ "normal": torch.clamp((out_tuple[6] * 0.5 + 0.5) * 255, 0, 255).byte().reshape(-1, 3),
+ }
+
+ return coord, attr
+
+
+def blender_dump_to_volumetric_attr(
+ dump: Dict[str, Any],
+ voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
+ grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
+ aabb: Union[list, tuple, np.ndarray, torch.Tensor] = None,
+ mip_level_offset: float = 0.0,
+ verbose: bool = False,
+ timing: bool = False,
+) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Voxelize a mesh into a sparse voxel grid with PBR properties.
+
+ Args:
+ dump (Dict[str, Any]): Dumped data from a blender scene.
+ voxel_size (float, list, tuple, np.ndarray, torch.Tensor): The size of each voxel.
+ grid_size (int, list, tuple, np.ndarray, torch.Tensor): The size of the grid.
+ NOTE: One of voxel_size and grid_size must be provided.
+ aabb (list, tuple, np.ndarray, torch.Tensor): The axis-aligned bounding box of the mesh.
+ If not provided, it will be computed automatically.
+ mip_level_offset (float): The mip level offset for texture mip level selection.
+ verbose (bool): Whether to print the settings.
+ timing (bool): Whether to print the timing information.
+
+ Returns:
+ torch.Tensor: The indices of the voxels that are occupied by the mesh.
+ Dict[str, torch.Tensor]: A dictionary containing the following keys:
+ - "base_color": The base color of the occupied voxels.
+ - "metallic": The metallic value of the occupied voxels.
+ - "roughness": The roughness value of the occupied voxels.
+ - "emissive": The emissive value of the occupied voxels.
+ - "alpha": The alpha value of the occupied voxels.
+ - "normal": The normal of the occupied voxels.
+ """
+ # Voxelize settings
+ assert voxel_size is not None or grid_size is not None, "Either voxel_size or grid_size must be provided"
+
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32)
+ assert isinstance(voxel_size, torch.Tensor), f"voxel_size must be a float, list, tuple, np.ndarray, or torch.Tensor, but got {type(voxel_size)}"
+ assert voxel_size.dim() == 1, f"voxel_size must be a 1D tensor, but got {voxel_size.shape}"
+ assert voxel_size.size(0) == 3, f"voxel_size must have 3 elements, but got {voxel_size.size(0)}"
+
+ if grid_size is not None:
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32)
+ assert isinstance(grid_size, torch.Tensor), f"grid_size must be an int, list, tuple, np.ndarray, or torch.Tensor, but got {type(grid_size)}"
+ assert grid_size.dim() == 1, f"grid_size must be a 1D tensor, but got {grid_size.shape}"
+ assert grid_size.size(0) == 3, f"grid_size must have 3 elements, but got {grid_size.size(0)}"
+
+ if aabb is not None:
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32)
+ assert isinstance(aabb, torch.Tensor), f"aabb must be a list, tuple, np.ndarray, or torch.Tensor, but got {type(aabb)}"
+ assert aabb.dim() == 2, f"aabb must be a 2D tensor, but got {aabb.shape}"
+ assert aabb.size(0) == 2, f"aabb must have 2 rows, but got {aabb.size(0)}"
+ assert aabb.size(1) == 3, f"aabb must have 3 columns, but got {aabb.size(1)}"
+
+ # Auto adjust aabb
+ if aabb is None:
+ min_xyz = np.min([
+ object['vertices'].min(axis=0)
+ for object in dump['objects']
+ ], axis=0)
+ max_xyz = np.max([
+ object['vertices'].max(axis=0)
+ for object in dump['objects']
+ ], axis=0)
+
+ if voxel_size is not None:
+ padding = torch.ceil((max_xyz - min_xyz) / voxel_size) * voxel_size - (max_xyz - min_xyz)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+ if grid_size is not None:
+ padding = (max_xyz - min_xyz) / (grid_size - 1)
+ min_xyz -= padding * 0.5
+ max_xyz += padding * 0.5
+
+ aabb = torch.stack([min_xyz, max_xyz], dim=0).float()
+
+ # Fill voxel size or grid size
+ if voxel_size is None:
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+ if grid_size is None:
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+
+ grid_range = torch.stack([torch.zeros_like(grid_size), grid_size], dim=0).int()
+
+ # Print settings
+ if verbose:
+ print(f"Voxelize settings:")
+ print(f" Voxel size: {voxel_size}")
+ print(f" Grid size: {grid_size}")
+ print(f" AABB: {aabb}")
+
+ # Load Scene
+ scene_buffers = {
+ 'triangles': [],
+ 'normals': [],
+ 'uvs': [],
+ 'material_ids': [],
+ 'base_color_factor': [],
+ 'base_color_texture': [],
+ 'base_color_texture_filter': [],
+ 'base_color_texture_wrap': [],
+ 'metallic_factor': [],
+ 'metallic_texture': [],
+ 'metallic_texture_filter': [],
+ 'metallic_texture_wrap': [],
+ 'roughness_factor': [],
+ 'roughness_texture': [],
+ 'roughness_texture_filter': [],
+ 'roughness_texture_wrap': [],
+ 'alpha_mode': [],
+ 'alpha_cutoff': [],
+ 'alpha_factor': [],
+ 'alpha_texture': [],
+ 'alpha_texture_filter': [],
+ 'alpha_texture_wrap': [],
+ }
+
+ def load_texture(pack):
+ png_bytes = pack['image']
+ image = Image.open(io.BytesIO(png_bytes))
+ if image.width != image.height or not is_power_of_two(image.width):
+ size = nearest_power_of_two(max(image.width, image.height))
+ image = image.resize((size, size), Image.LANCZOS)
+ texture = torch.tensor(np.array(image), dtype=torch.uint8)
+ filter_mode = {
+ 'Linear': 1,
+ 'Closest': 0,
+ 'Cubic': 1,
+ 'Smart': 1,
+ }[pack['interpolation']]
+ wrap_mode = {
+ 'REPEAT': 0,
+ 'EXTEND': 1,
+ 'CLIP': 1,
+ 'MIRROR': 2,
+ }[pack['extension']]
+ return texture, filter_mode, wrap_mode
+
+ for material in dump['materials']:
+ baseColorFactor = torch.tensor(material['baseColorFactor'][:3], dtype=torch.float32)
+ if material['baseColorTexture'] is not None:
+ baseColorTexture, baseColorTextureFilter, baseColorTextureWrap = \
+ load_texture(material['baseColorTexture'])
+ assert baseColorTexture.shape[2] == 3, f"Base color texture must have 3 channels, but got {baseColorTexture.shape[2]}"
+ else:
+ baseColorTexture = torch.tensor([])
+ baseColorTextureFilter = 0
+ baseColorTextureWrap = 0
+ scene_buffers['base_color_factor'].append(baseColorFactor)
+ scene_buffers['base_color_texture'].append(baseColorTexture)
+ scene_buffers['base_color_texture_filter'].append(baseColorTextureFilter)
+ scene_buffers['base_color_texture_wrap'].append(baseColorTextureWrap)
+
+ metallicFactor = material['metallicFactor']
+ if material['metallicTexture'] is not None:
+ metallicTexture, metallicTextureFilter, metallicTextureWrap = \
+ load_texture(material['metallicTexture'])
+ assert metallicTexture.dim() == 2, f"Metallic roughness texture must have 2 dimensions, but got {metallicTexture.dim()}"
+ else:
+ metallicTexture = torch.tensor([])
+ metallicTextureFilter = 0
+ metallicTextureWrap = 0
+ scene_buffers['metallic_factor'].append(metallicFactor)
+ scene_buffers['metallic_texture'].append(metallicTexture)
+ scene_buffers['metallic_texture_filter'].append(metallicTextureFilter)
+ scene_buffers['metallic_texture_wrap'].append(metallicTextureWrap)
+
+ roughnessFactor = material['roughnessFactor']
+ if material['roughnessTexture'] is not None:
+ roughnessTexture, roughnessTextureFilter, roughnessTextureWrap = \
+ load_texture(material['roughnessTexture'])
+ assert roughnessTexture.dim() == 2, f"Metallic roughness texture must have 2 dimensions, but got {roughnessTexture.dim()}"
+ else:
+ roughnessTexture = torch.tensor([])
+ roughnessTextureFilter = 0
+ roughnessTextureWrap = 0
+ scene_buffers['roughness_factor'].append(roughnessFactor)
+ scene_buffers['roughness_texture'].append(roughnessTexture)
+ scene_buffers['roughness_texture_filter'].append(roughnessTextureFilter)
+ scene_buffers['roughness_texture_wrap'].append(roughnessTextureWrap)
+
+ alphaMode = ALPHA_MODE_ENUM[material['alphaMode']]
+ alphaCutoff = material['alphaCutoff']
+ alphaFactor = material['alphaFactor']
+ if material['alphaTexture'] is not None:
+ alphaTexture, alphaTextureFilter, alphaTextureWrap = \
+ load_texture(material['alphaTexture'])
+ assert alphaTexture.dim() == 2, f"Alpha texture must have 2 dimensions, but got {alphaTexture.dim()}"
+ else:
+ alphaTexture = torch.tensor([])
+ alphaTextureFilter = 0
+ alphaTextureWrap = 0
+ scene_buffers['alpha_mode'].append(alphaMode)
+ scene_buffers['alpha_cutoff'].append(alphaCutoff)
+ scene_buffers['alpha_factor'].append(alphaFactor)
+ scene_buffers['alpha_texture'].append(alphaTexture)
+ scene_buffers['alpha_texture_filter'].append(alphaTextureFilter)
+ scene_buffers['alpha_texture_wrap'].append(alphaTextureWrap)
+
+ for object in dump['objects']:
+ triangles = torch.tensor(object['vertices'][object['faces']], dtype=torch.float32).reshape(-1, 3, 3) - aabb[0].reshape(1, 1, 3)
+ normails = torch.tensor(object['normals'], dtype=torch.float32)
+ uvs = torch.tensor(object['uvs'], dtype=torch.float32) if object['uvs'] is not None else torch.zeros(triangles.shape[0], 3, 2, dtype=torch.float32)
+ material_id = torch.tensor(object['mat_ids'], dtype=torch.int32)
+ scene_buffers['triangles'].append(triangles)
+ scene_buffers['normals'].append(normails)
+ scene_buffers['uvs'].append(uvs)
+ scene_buffers['material_ids'].append(material_id)
+
+ scene_buffers['triangles'] = torch.cat(scene_buffers['triangles'], dim=0) # [N, 3, 3]
+ scene_buffers['normals'] = torch.cat(scene_buffers['normals'], dim=0) # [N, 3, 3]
+ scene_buffers['uvs'] = torch.cat(scene_buffers['uvs'], dim=0) # [N, 3, 2]
+ scene_buffers['material_ids'] = torch.cat(scene_buffers['material_ids'], dim=0) # [N]
+
+ scene_buffers['uvs'][:, :, 1] = 1 - scene_buffers['uvs'][:, :, 1] # Flip v coordinate
+
+ # Voxelize
+ out_tuple = _C.textured_mesh_to_volumetric_attr_cpu(
+ voxel_size,
+ grid_range,
+ scene_buffers["triangles"],
+ scene_buffers["normals"],
+ scene_buffers["uvs"],
+ scene_buffers["material_ids"],
+ scene_buffers["base_color_factor"],
+ scene_buffers["base_color_texture"],
+ scene_buffers["base_color_texture_filter"],
+ scene_buffers["base_color_texture_wrap"],
+ scene_buffers["metallic_factor"],
+ scene_buffers["metallic_texture"],
+ scene_buffers["metallic_texture_filter"],
+ scene_buffers["metallic_texture_wrap"],
+ scene_buffers["roughness_factor"],
+ scene_buffers["roughness_texture"],
+ scene_buffers["roughness_texture_filter"],
+ scene_buffers["roughness_texture_wrap"],
+ [torch.zeros(3, dtype=torch.float32) for _ in range(len(scene_buffers["base_color_texture"]))],
+ [torch.tensor([]) for _ in range(len(scene_buffers["base_color_texture"]))],
+ [0] * len(scene_buffers["base_color_texture"]),
+ [0] * len(scene_buffers["base_color_texture"]),
+ scene_buffers["alpha_mode"],
+ scene_buffers["alpha_cutoff"],
+ scene_buffers["alpha_factor"],
+ scene_buffers["alpha_texture"],
+ scene_buffers["alpha_texture_filter"],
+ scene_buffers["alpha_texture_wrap"],
+ [torch.tensor([]) for _ in range(len(scene_buffers["base_color_texture"]))],
+ [0] * len(scene_buffers["base_color_texture"]),
+ [0] * len(scene_buffers["base_color_texture"]),
+ mip_level_offset,
+ timing,
+ )
+
+ # Post process
+ coord = out_tuple[0]
+ attr = {
+ "base_color": torch.clamp(out_tuple[1] * 255, 0, 255).byte().reshape(-1, 3),
+ "metallic": torch.clamp(out_tuple[2] * 255, 0, 255).byte().reshape(-1, 1),
+ "roughness": torch.clamp(out_tuple[3] * 255, 0, 255).byte().reshape(-1, 1),
+ "emissive": torch.clamp(out_tuple[4] * 255, 0, 255).byte().reshape(-1, 3),
+ "alpha": torch.clamp(out_tuple[5] * 255, 0, 255).byte().reshape(-1, 1),
+ "normal": torch.clamp((out_tuple[6] * 0.5 + 0.5) * 255, 0, 255).byte().reshape(-1, 3),
+ }
+
+ return coord, attr
\ No newline at end of file
diff --git a/o-voxel/o_voxel/io/__init__.py b/o-voxel/o_voxel/io/__init__.py
new file mode 100644
index 0000000..f29541b
--- /dev/null
+++ b/o-voxel/o_voxel/io/__init__.py
@@ -0,0 +1,45 @@
+from typing import Dict, Union
+import torch
+from .ply import *
+from .npz import *
+from .vxz import *
+
+
+def read(file_path: str) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Read a file containing voxels.
+
+ Args:
+ file_path: Path to the file.
+
+ Returns:
+ torch.Tensor: the coordinates of the voxels.
+ Dict[str, torch.Tensor]: the attributes of the voxels.
+ """
+ if file_path.endswith('.npz'):
+ return read_npz(file_path)
+ elif file_path.endswith('.ply'):
+ return read_ply(file_path)
+ elif file_path.endswith('.vxz'):
+ return read_vxz(file_path)
+ else:
+ raise ValueError(f"Unsupported file type {file_path}")
+
+
+def write(file_path: str, coord: torch.Tensor, attr: Dict[str, torch.Tensor], **kwargs):
+ """
+ Write a file containing voxels.
+
+ Args:
+ file_path: Path to the file.
+ coord: the coordinates of the voxels.
+ attr: the attributes of the voxels.
+ """
+ if file_path.endswith('.npz'):
+ write_npz(file_path, coord, attr, **kwargs)
+ elif file_path.endswith('.ply'):
+ write_ply(file_path, coord, attr, **kwargs)
+ elif file_path.endswith('.vxz'):
+ write_vxz(file_path, coord, attr, **kwargs)
+ else:
+ raise ValueError(f"Unsupported file type {file_path}")
diff --git a/o-voxel/o_voxel/io/npz.py b/o-voxel/o_voxel/io/npz.py
new file mode 100644
index 0000000..9009045
--- /dev/null
+++ b/o-voxel/o_voxel/io/npz.py
@@ -0,0 +1,43 @@
+from typing import *
+import torch
+import numpy as np
+
+
+__all__ = [
+ "read_npz",
+ "write_npz",
+]
+
+
+def read_npz(file) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Read a NPZ file containing voxels.
+
+ Args:
+ file_path: Path or file object from which to read the NPZ file.
+
+ Returns:
+ torch.Tensor: the coordinates of the voxels.
+ Dict[str, torch.Tensor]: the attributes of the voxels.
+ """
+ data = np.load(file)
+ coord = torch.from_numpy(data['coord']).int()
+ attr = {k: torch.from_numpy(v) for k, v in data.items() if k!= 'coord'}
+ return coord, attr
+
+
+def write_npz(file, coord: torch.Tensor, attr: Dict[str, torch.Tensor], compress=True):
+ """
+ Write a NPZ file containing voxels.
+
+ Args:
+ file_path: Path or file object to which to write the NPZ file.
+ coord: the coordinates of the voxels.
+ attr: the attributes of the voxels.
+ """
+ data = {'coord': coord.cpu().numpy().astype(np.uint16)}
+ data.update({k: v.cpu().numpy() for k, v in attr.items()})
+ if compress:
+ np.savez_compressed(file, **data)
+ else:
+ np.savez(file, **data)
diff --git a/o-voxel/o_voxel/io/ply.py b/o-voxel/o_voxel/io/ply.py
new file mode 100644
index 0000000..c8e7fb8
--- /dev/null
+++ b/o-voxel/o_voxel/io/ply.py
@@ -0,0 +1,72 @@
+from typing import *
+import io
+import torch
+import numpy as np
+import plyfile
+
+
+__all__ = [
+ "read_ply",
+ "write_ply",
+]
+
+
+DTYPE_MAP = {
+ torch.uint8: 'u1',
+ torch.uint16: 'u2',
+ torch.uint32: 'u4',
+ torch.int8: 'i1',
+ torch.int16: 'i2',
+ torch.int32: 'i4',
+ torch.float32: 'f4',
+ torch.float64: 'f8'
+}
+
+
+def read_ply(file) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Read a PLY file containing voxels.
+
+ Args:
+ file: Path or file-like object of the PLY file.
+
+ Returns:
+ torch.Tensor: the coordinates of the voxels.
+ Dict[str, torch.Tensor]: the attributes of the voxels.
+ """
+ plydata = plyfile.PlyData.read(file)
+ xyz = np.stack([plydata.elements[0][k] for k in ['x', 'y', 'z']], axis=1)
+ coord = np.round(xyz).astype(int)
+ coord = torch.from_numpy(coord)
+
+ attr_keys = [k for k in plydata.elements[0].data.dtype.names if k not in ['x', 'y', 'z']]
+ attr_names = ['_'.join(k.split('_')[:-1]) for k in attr_keys]
+ attr_chs = [sum([1 for k in attr_keys if k.startswith(f'{name}_')]) for name in attr_names]
+
+ attr = {}
+ for i, name in enumerate(attr_names):
+ attr[name] = np.stack([plydata.elements[0][f'{name}_{j}'] for j in range(attr_chs[i])], axis=1)
+ attr = {k: torch.from_numpy(v) for k, v in attr.items()}
+
+ return coord, attr
+
+
+def write_ply(file, coord: torch.Tensor, attr: Dict[str, torch.Tensor]):
+ """
+ Write a PLY file containing voxels.
+
+ Args:
+ file: Path or file-like object of the PLY file.
+ coord: the coordinates of the voxels.
+ attr: the attributes of the voxels.
+ """
+ dtypes = [('x', 'f4'), ('y', 'f4'), ('z', 'f4')]
+ for k, v in attr.items():
+ for j in range(v.shape[-1]):
+ assert v.dtype in DTYPE_MAP, f"Unsupported data type {v.dtype} for attribute {k}"
+ dtypes.append((f'{k}_{j}', DTYPE_MAP[v.dtype]))
+ data = np.empty(len(coord), dtype=dtypes)
+ all_chs = np.concatenate([coord.cpu().numpy().astype(np.float32)] + [v.cpu().numpy() for v in attr.values()], axis=1)
+ data[:] = list(map(tuple, all_chs))
+ plyfile.PlyData([plyfile.PlyElement.describe(data, 'vertex')]).write(file)
+
\ No newline at end of file
diff --git a/o-voxel/o_voxel/io/vxz.py b/o-voxel/o_voxel/io/vxz.py
new file mode 100644
index 0000000..bc34cda
--- /dev/null
+++ b/o-voxel/o_voxel/io/vxz.py
@@ -0,0 +1,365 @@
+from typing import *
+import os
+import json
+import struct
+import torch
+import numpy as np
+import zlib
+import lzma
+import zstandard
+from concurrent.futures import ThreadPoolExecutor
+from ..serialize import encode_seq, decode_seq
+from .. import _C
+
+
+__all__ = [
+ "read_vxz",
+ "read_vxz_info",
+ "write_vxz",
+]
+
+
+"""
+VXZ format
+
+Header:
+- file type (3 bytes) - 'VXZ'
+- version (1 byte) - 0
+- binary start offset (4 bytes)
+- structure (json) -
+{
+ "num_voxel": int,
+ "chunk_size": int,
+ "filter": str,
+ "compression": str,
+ "compression_level": int,
+ "raw_size": int,
+ "compressed_size": int,
+ "compress_ratio": float,
+ "attr_interleave": str,
+ "attr": [
+ {"name": str, "chs": int},
+ ...
+ ]
+ "chunks": [
+ {
+ "ptr": [offset, length], # offset from global binary start
+ "svo": [offset, length], # offset from this chunk start
+ "attr": [offset, length], # offset from this chunk start
+ },
+ ...
+ ]
+}
+- binary data
+"""
+
+DEFAULT_COMPRESION_LEVEL = {
+ 'none': 0,
+ 'deflate': 9,
+ 'lzma': 9,
+ 'zstd': 22,
+}
+
+
+def _compress(data: bytes, algo: Literal['none', 'deflate', 'lzma', 'zstd'], level: int) -> bytes:
+ if algo == 'none':
+ return data
+ if level is None:
+ level = DEFAULT_COMPRESION_LEVEL[algo]
+ if algo == 'deflate':
+ compresser = zlib.compressobj(level, wbits=-15)
+ return compresser.compress(data) + compresser.flush()
+ if algo == 'lzma':
+ compresser = lzma.LZMACompressor(format=lzma.FORMAT_RAW, filters=[{'id': lzma.FILTER_LZMA2, 'preset': level}])
+ return compresser.compress(data) + compresser.flush()
+ if algo == 'zstd':
+ compresser = zstandard.ZstdCompressor(level=level, write_checksum=False, write_content_size=True, threads=-1)
+ return compresser.compress(data)
+ raise ValueError(f"Invalid compression algorithm: {algo}")
+
+
+def _decompress(data: bytes, algo: Literal['none', 'deflate', 'lzma', 'zstd'], level: int) -> bytes:
+ if algo == 'none':
+ return data
+ if level is None:
+ level = DEFAULT_COMPRESION_LEVEL[algo]
+ if algo == 'deflate':
+ decompresser = zlib.decompressobj(wbits=-15)
+ return decompresser.decompress(data) + decompresser.flush()
+ if algo == 'lzma':
+ decompresser = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=[{'id': lzma.FILTER_LZMA2, 'preset': level}])
+ return decompresser.decompress(data)
+ if algo == 'zstd':
+ decompresser = zstandard.ZstdDecompressor(format=zstandard.FORMAT_ZSTD1)
+ return decompresser.decompress(data)
+ raise ValueError(f"Invalid compression algorithm: {algo}")
+
+
+def read_vxz_info(file) -> Dict:
+ """
+ Read the header of a VXZ file without decompressing the binary data.
+
+ Args:
+ file_path: Path or file-like object to the VXZ file.
+
+ Returns:
+ Dict: the header of the VXZ file.
+ """
+ if isinstance(file, str):
+ with open(file, 'rb') as f:
+ file_data = f.read()
+ else:
+ file_data = file.read()
+
+ assert file_data[:3] == b'VXZ', "Invalid file type"
+ version = file_data[3]
+ assert version == 0, "Invalid file version"
+
+ bin_start = struct.unpack('>I', file_data[4:8])[0]
+ structure_data = json.loads(file_data[8:bin_start].decode())
+ return structure_data
+
+
+def read_vxz(file, num_threads: int = -1) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
+ """
+ Read a VXZ file containing voxels.
+
+ Args:
+ file_path: Path or file-like object to the VXZ file.
+ num_threads: the number of threads to use for reading the file.
+
+ Returns:
+ torch.Tensor: the coordinates of the voxels.
+ Dict[str, torch.Tensor]: the attributes of the voxels.
+ """
+ if isinstance(file, str):
+ with open(file, 'rb') as f:
+ file_data = f.read()
+ else:
+ file_data = file.read()
+
+ num_threads = num_threads if num_threads > 0 else os.cpu_count()
+
+ # Parse header
+ assert file_data[:3] == b'VXZ', "Invalid file type"
+ version = file_data[3]
+ assert version == 0, "Invalid file version"
+
+ bin_start = struct.unpack('>I', file_data[4:8])[0]
+ structure_data = json.loads(file_data[8:bin_start].decode())
+ bin_data = file_data[bin_start:]
+
+ # Decode chunks
+ chunk_size = structure_data['chunk_size']
+ chunk_depth = np.log2(chunk_size)
+ assert chunk_depth.is_integer(), f"Chunk size must be a power of 2, got {chunk_size}"
+ chunk_depth = int(chunk_depth)
+
+ def worker(chunk_info):
+ decompressed = {}
+ chunk_data = bin_data[chunk_info['ptr'][0]:chunk_info['ptr'][0]+chunk_info['ptr'][1]]
+ for k, v in chunk_info.items():
+ if k in ['ptr', 'idx']:
+ continue
+ decompressed[k] = np.frombuffer(_decompress(chunk_data[v[0]:v[0]+v[1]], structure_data['compression'], structure_data['compression_level']), dtype=np.uint8)
+ svo = torch.tensor(np.frombuffer(decompressed['svo'], dtype=np.uint8))
+ morton_code = _C.decode_sparse_voxel_octree_cpu(svo, chunk_depth)
+ coord = decode_seq(morton_code.int()).cpu()
+
+ # deinterleave attributes
+ if structure_data['attr_interleave'] == 'none':
+ all_attr = []
+ for k, chs in structure_data['attr']:
+ for i in range(chs):
+ all_attr.append(torch.tensor(decompressed[f'{k}_{i}']))
+ all_attr = torch.stack(all_attr, dim=1)
+ elif structure_data['attr_interleave'] == 'as_is':
+ all_attr = []
+ for k, chs in structure_data['attr']:
+ all_attr.append(torch.tensor(decompressed[k].reshape(-1, chs)))
+ all_attr = torch.cat(all_attr, dim=1)
+ elif structure_data['attr_interleave'] == 'all':
+ all_chs = sum(chs for k, chs in structure_data['attr'])
+ all_attr = decompressed['attr'].reshape(-1, all_chs)
+
+ # unfilter
+ if structure_data['filter'] == 'none':
+ pass
+ elif structure_data['filter'] == 'parent':
+ all_attr = _C.decode_sparse_voxel_octree_attr_parent_cpu(svo, chunk_depth, all_attr)
+ elif structure_data['filter'] == 'neighbor':
+ all_attr = _C.decode_sparse_voxel_octree_attr_neighbor_cpu(coord, chunk_size, all_attr)
+
+ # final
+ attr = {}
+ ch = 0
+ for k, chs in structure_data['attr']:
+ attr[k] = all_attr[:, ch:ch+chs]
+ ch += chs
+ return {
+ 'coord': coord,
+ 'attr': attr,
+ }
+
+ if num_threads == 1:
+ chunks = [worker(info) for info in structure_data['chunks']]
+ else:
+ with ThreadPoolExecutor(max_workers=num_threads) as executor:
+ chunks = list(executor.map(worker, structure_data['chunks']))
+
+ # Combine chunks
+ coord = []
+ attr = {k: [] for k, _ in structure_data['attr']}
+ for info, chunk in zip(structure_data['chunks'], chunks):
+ coord.append(chunk['coord'] + torch.tensor([[info['idx'][0] * chunk_size, info['idx'][1] * chunk_size, info['idx'][2] * chunk_size]]).int())
+ for k, v in chunk['attr'].items():
+ attr[k].append(v)
+ coord = torch.cat(coord, dim=0)
+ for k, v in attr.items():
+ attr[k] = torch.cat(v, dim=0)
+ return coord, attr
+
+
+def write_vxz(
+ file,
+ coord: torch.Tensor,
+ attr: Dict[str, torch.Tensor],
+ chunk_size: int = 256,
+ filter: Literal['none', 'parent', 'neighbor'] = 'none',
+ compression: Literal['none', 'deflate', 'lzma', 'zstd'] = 'lzma',
+ compression_level: Optional[int] = None,
+ attr_interleave: Literal['none', 'as_is', 'all'] = 'as_is',
+ num_threads: int = -1,
+):
+ """
+ Write a VXZ file containing voxels.
+
+ Args:
+ file: Path or file-like object to the VXZ file.
+ coord: the coordinates of the voxels.
+ attr: the attributes of the voxels.
+ chunk_size: the size of each chunk.
+ filter: the filter to apply to the voxels.
+ compression: the compression algorithm to use.
+ compression_level: the level of compression.
+ attr_interleave: how to interleave the attributes.
+ num_threads: the number of threads to use for compression.
+ """
+ # Check
+ for k, v in attr.items():
+ assert coord.shape[0] == v.shape[0], f"Number of coordinates and attributes do not match for key {k}"
+ assert v.dtype == torch.uint8, f"Attributes must be uint8, got {v.dtype} for key {k}"
+ assert attr_interleave in ['none', 'as_is', 'all'], f"Invalid attr_interleave value: {attr_interleave}"
+
+ compression_level = compression_level or DEFAULT_COMPRESION_LEVEL[compression]
+ num_threads = num_threads if num_threads > 0 else os.cpu_count()
+
+ file_info = {
+ 'num_voxel': coord.shape[0],
+ 'chunk_size': chunk_size,
+ 'filter': filter,
+ 'compression': compression,
+ 'compression_level': compression_level,
+ 'raw_size': sum([coord.numel() * 4] + [v.numel() for v in attr.values()]),
+ 'compressed_size': 0,
+ 'compress_ratio': 0.0,
+ 'attr_interleave': attr_interleave,
+ 'attr': [[k, v.shape[1]] for k, v in attr.items()],
+ 'chunks': [],
+ }
+ bin_data = b''
+
+ # Split into chunks
+ chunk_depth = np.log2(chunk_size)
+ assert chunk_depth.is_integer(), f"Chunk size must be a power of 2, got {chunk_size}"
+ chunk_depth = int(chunk_depth)
+
+ chunk_coord = coord // chunk_size
+ coord = coord % chunk_size
+ unique_chunk_coord, inverse = torch.unique(chunk_coord, dim=0, return_inverse=True)
+
+ chunks = []
+ for idx, chunk_xyz in enumerate(unique_chunk_coord.tolist()):
+ chunk_mask = (inverse == idx)
+ chunks.append({
+ 'idx': chunk_xyz,
+ 'coord': coord[chunk_mask],
+ 'attr': {k: v[chunk_mask] for k, v in attr.items()},
+ })
+
+ # Compress each chunk
+ with ThreadPoolExecutor(max_workers=num_threads) as executor:
+ def worker(chunk):
+ ## compress to binary
+ coord = chunk['coord']
+ morton_code = encode_seq(coord)
+ sorted_idx = morton_code.argsort().cpu()
+ coord = coord.cpu()[sorted_idx]
+ morton_code = morton_code.cpu()[sorted_idx]
+ attr = torch.cat([v.cpu()[sorted_idx] for v in chunk['attr'].values()], dim=1)
+ svo = _C.encode_sparse_voxel_octree_cpu(morton_code, chunk_depth)
+ svo_bytes = _compress(svo.numpy().tobytes(), compression, compression_level)
+
+ # filter
+ if filter == 'none':
+ attr = attr.numpy()
+ elif filter == 'parent':
+ attr = _C.encode_sparse_voxel_octree_attr_parent_cpu(svo, chunk_depth, attr).numpy()
+ elif filter == 'neighbor':
+ attr = _C.encode_sparse_voxel_octree_attr_neighbor_cpu(coord, chunk_size, attr).numpy()
+
+ # interleave attributes
+ attr_bytes = {}
+ if attr_interleave == 'none':
+ ch = 0
+ for k, chs in file_info['attr']:
+ for i in range(chs):
+ attr_bytes[f'{k}_{i}'] = _compress(attr[:, ch].tobytes(), compression, compression_level)
+ ch += 1
+ elif attr_interleave == 'as_is':
+ ch = 0
+ for k, chs in file_info['attr']:
+ attr_bytes[k] = _compress(attr[:, ch:ch+chs].tobytes(), compression, compression_level)
+ ch += chs
+ elif attr_interleave == 'all':
+ attr_bytes['attr'] = _compress(attr.tobytes(), compression, compression_level)
+
+ ## buffer for each chunk
+ chunk_info = {'idx': chunk['idx']}
+ bin_data = b''
+
+ ### svo
+ chunk_info['svo'] = [len(bin_data), len(svo_bytes)]
+ bin_data += svo_bytes
+
+ ### attr
+ for k, v in attr_bytes.items():
+ chunk_info[k] = [len(bin_data), len(v)]
+ bin_data += v
+
+ return chunk_info, bin_data
+
+ chunks = list(executor.map(worker, chunks))
+
+ for chunk_info, chunk_data in chunks:
+ chunk_info['ptr'] = [len(bin_data), len(chunk_data)]
+ bin_data += chunk_data
+ file_info['chunks'].append(chunk_info)
+
+ file_info['compressed_size'] = len(bin_data)
+ file_info['compress_ratio'] = file_info['raw_size'] / file_info['compressed_size']
+
+ # File parts
+ structure_data = json.dumps(file_info).encode()
+ header = b'VXZ\x00' + struct.pack('>I', len(structure_data) + 8)
+
+ # Write to file
+ if isinstance(file, str):
+ with open(file, 'wb') as f:
+ f.write(header)
+ f.write(structure_data)
+ f.write(bin_data)
+ else:
+ file.write(header)
+ file.write(structure_data)
+ file.write(bin_data)
diff --git a/o-voxel/o_voxel/postprocess.py b/o-voxel/o_voxel/postprocess.py
new file mode 100644
index 0000000..1ce8227
--- /dev/null
+++ b/o-voxel/o_voxel/postprocess.py
@@ -0,0 +1,331 @@
+from typing import *
+from tqdm import tqdm
+import numpy as np
+import torch
+import cv2
+from PIL import Image
+import trimesh
+import trimesh.visual
+from flex_gemm.ops.grid_sample import grid_sample_3d
+import nvdiffrast.torch as dr
+import cumesh
+
+
+def to_glb(
+ vertices: torch.Tensor,
+ faces: torch.Tensor,
+ attr_volume: torch.Tensor,
+ coords: torch.Tensor,
+ attr_layout: Dict[str, slice],
+ aabb: Union[list, tuple, np.ndarray, torch.Tensor],
+ voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
+ grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
+ decimation_target: int = 1000000,
+ texture_size: int = 2048,
+ remesh: bool = False,
+ remesh_band: float = 1,
+ remesh_project: float = 0.9,
+ mesh_cluster_threshold_cone_half_angle_rad=np.radians(90.0),
+ mesh_cluster_refine_iterations=0,
+ mesh_cluster_global_iterations=1,
+ mesh_cluster_smooth_strength=1,
+ verbose: bool = False,
+ use_tqdm: bool = False,
+):
+ """
+ Convert an extracted mesh to a GLB file.
+ Performs cleaning, optional remeshing, UV unwrapping, and texture baking from a volume.
+
+ Args:
+ vertices: (N, 3) tensor of vertex positions
+ faces: (M, 3) tensor of vertex indices
+ attr_volume: (L, C) features of a sprase tensor for attribute interpolation
+ coords: (L, 3) tensor of coordinates for each voxel
+ attr_layout: dictionary of slice objects for each attribute
+ aabb: (2, 3) tensor of minimum and maximum coordinates of the volume
+ voxel_size: (3,) tensor of size of each voxel
+ grid_size: (3,) tensor of number of voxels in each dimension
+ decimation_target: target number of vertices for mesh simplification
+ texture_size: size of the texture for baking
+ remesh: whether to perform remeshing
+ remesh_band: size of the remeshing band
+ remesh_project: projection factor for remeshing
+ mesh_cluster_threshold_cone_half_angle_rad: threshold for cone-based clustering in uv unwrapping
+ mesh_cluster_refine_iterations: number of iterations for refining clusters in uv unwrapping
+ mesh_cluster_global_iterations: number of global iterations for clustering in uv unwrapping
+ mesh_cluster_smooth_strength: strength of smoothing for clustering in uv unwrapping
+ verbose: whether to print verbose messages
+ use_tqdm: whether to use tqdm to display progress bar
+ """
+ # --- Input Normalization (AABB, Voxel Size, Grid Size) ---
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32, device=coords.device)
+ assert isinstance(aabb, torch.Tensor), f"aabb must be a list, tuple, np.ndarray, or torch.Tensor, but got {type(aabb)}"
+ assert aabb.dim() == 2, f"aabb must be a 2D tensor, but got {aabb.shape}"
+ assert aabb.size(0) == 2, f"aabb must have 2 rows, but got {aabb.size(0)}"
+ assert aabb.size(1) == 3, f"aabb must have 3 columns, but got {aabb.size(1)}"
+
+ # Calculate grid dimensions based on AABB and voxel size
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32, device=coords.device)
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+ else:
+ assert grid_size is not None, "Either voxel_size or grid_size must be provided"
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32, device=coords.device)
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+
+ # Assertions for dimensions
+ assert isinstance(voxel_size, torch.Tensor)
+ assert voxel_size.dim() == 1 and voxel_size.size(0) == 3
+ assert isinstance(grid_size, torch.Tensor)
+ assert grid_size.dim() == 1 and grid_size.size(0) == 3
+
+ if use_tqdm:
+ pbar = tqdm(total=6, desc="Extracting GLB")
+ if verbose:
+ print(f"Original mesh: {vertices.shape[0]} vertices, {faces.shape[0]} faces")
+
+ # Move data to GPU
+ vertices = vertices.cuda()
+ faces = faces.cuda()
+
+ # Initialize CUDA mesh handler
+ mesh = cumesh.CuMesh()
+ mesh.init(vertices, faces)
+
+ # --- Initial Mesh Cleaning ---
+ # Fills holes as much as we can before processing
+ mesh.fill_holes(max_hole_perimeter=3e-2)
+ if verbose:
+ print(f"After filling holes: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+ vertices, faces = mesh.read()
+ if use_tqdm:
+ pbar.update(1)
+
+ # Build BVH for the current mesh to guide remeshing
+ if use_tqdm:
+ pbar.set_description("Building BVH")
+ if verbose:
+ print(f"Building BVH for current mesh...", end='', flush=True)
+ bvh = cumesh.cuBVH(vertices, faces)
+ if use_tqdm:
+ pbar.update(1)
+ if verbose:
+ print("Done")
+
+ if use_tqdm:
+ pbar.set_description("Cleaning mesh")
+ if verbose:
+ print("Cleaning mesh...")
+
+ # --- Branch 1: Standard Pipeline (Simplification & Cleaning) ---
+ if not remesh:
+ # Step 1: Aggressive simplification (3x target)
+ mesh.simplify(decimation_target * 3, verbose=verbose)
+ if verbose:
+ print(f"After inital simplification: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ # Step 2: Clean up topology (duplicates, non-manifolds, isolated parts)
+ mesh.remove_duplicate_faces()
+ mesh.repair_non_manifold_edges()
+ mesh.remove_small_connected_components(1e-5)
+ mesh.fill_holes(max_hole_perimeter=3e-2)
+ if verbose:
+ print(f"After initial cleanup: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ # Step 3: Final simplification to target count
+ mesh.simplify(decimation_target, verbose=verbose)
+ if verbose:
+ print(f"After final simplification: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ # Step 4: Final Cleanup loop
+ mesh.remove_duplicate_faces()
+ mesh.repair_non_manifold_edges()
+ mesh.remove_small_connected_components(1e-5)
+ mesh.fill_holes(max_hole_perimeter=3e-2)
+ if verbose:
+ print(f"After final cleanup: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ # Step 5: Unify face orientations
+ mesh.unify_face_orientations()
+
+ # --- Branch 2: Remeshing Pipeline ---
+ else:
+ center = aabb.mean(dim=0)
+ scale = (aabb[1] - aabb[0]).max().item()
+ resolution = grid_size.max().item()
+
+ # Perform Dual Contouring remeshing (rebuilds topology)
+ mesh.init(*cumesh.remeshing.remesh_narrow_band_dc(
+ vertices, faces,
+ center = center,
+ scale = (resolution + 3 * remesh_band) / resolution * scale,
+ resolution = resolution,
+ band = remesh_band,
+ project_back = remesh_project, # Snaps vertices back to original surface
+ verbose = verbose,
+ bvh = bvh,
+ ))
+ if verbose:
+ print(f"After remeshing: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ # Simplify and clean the remeshed result (similar logic to above)
+ mesh.simplify(decimation_target, verbose=verbose)
+ if verbose:
+ print(f"After simplifying: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
+
+ if use_tqdm:
+ pbar.update(1)
+ if verbose:
+ print("Done")
+
+
+ # --- UV Parameterization ---
+ if use_tqdm:
+ pbar.set_description("Parameterizing new mesh")
+ if verbose:
+ print("Parameterizing new mesh...")
+
+ out_vertices, out_faces, out_uvs, out_vmaps = mesh.uv_unwrap(
+ compute_charts_kwargs={
+ "threshold_cone_half_angle_rad": mesh_cluster_threshold_cone_half_angle_rad,
+ "refine_iterations": mesh_cluster_refine_iterations,
+ "global_iterations": mesh_cluster_global_iterations,
+ "smooth_strength": mesh_cluster_smooth_strength,
+ },
+ return_vmaps=True,
+ verbose=verbose,
+ )
+ out_vertices = out_vertices.cuda()
+ out_faces = out_faces.cuda()
+ out_uvs = out_uvs.cuda()
+ out_vmaps = out_vmaps.cuda()
+ mesh.compute_vertex_normals()
+ out_normals = mesh.read_vertex_normals()[out_vmaps]
+
+ if use_tqdm:
+ pbar.update(1)
+ if verbose:
+ print("Done")
+
+ # --- Texture Baking (Attribute Sampling) ---
+ if use_tqdm:
+ pbar.set_description("Sampling attributes")
+ if verbose:
+ print("Sampling attributes...", end='', flush=True)
+
+ # Setup differentiable rasterizer context
+ ctx = dr.RasterizeCudaContext()
+ # Prepare UV coordinates for rasterization (rendering in UV space)
+ uvs_rast = torch.cat([out_uvs * 2 - 1, torch.zeros_like(out_uvs[:, :1]), torch.ones_like(out_uvs[:, :1])], dim=-1).unsqueeze(0)
+ rast = torch.zeros((1, texture_size, texture_size, 4), device='cuda', dtype=torch.float32)
+
+ # Rasterize in chunks to save memory
+ for i in range(0, out_faces.shape[0], 100000):
+ rast_chunk, _ = dr.rasterize(
+ ctx, uvs_rast, out_faces[i:i+100000],
+ resolution=[texture_size, texture_size],
+ )
+ mask_chunk = rast_chunk[..., 3:4] > 0
+ rast_chunk[..., 3:4] += i # Store face ID in alpha channel
+ rast = torch.where(mask_chunk, rast_chunk, rast)
+
+ # Mask of valid pixels in texture
+ mask = rast[0, ..., 3] > 0
+
+ # Interpolate 3D positions in UV space (finding 3D coord for every texel)
+ pos = dr.interpolate(out_vertices.unsqueeze(0), rast, out_faces)[0][0]
+ valid_pos = pos[mask]
+
+ # Map these positions back to the *original* high-res mesh to get accurate attributes
+ # This corrects geometric errors introduced by simplification/remeshing
+ _, face_id, uvw = bvh.unsigned_distance(valid_pos, return_uvw=True)
+ orig_tri_verts = vertices[faces[face_id.long()]] # (N_new, 3, 3)
+ valid_pos = (orig_tri_verts * uvw.unsqueeze(-1)).sum(dim=1)
+
+ # Trilinear sampling from the attribute volume (Color, Material props)
+ attrs = torch.zeros(texture_size, texture_size, attr_volume.shape[1], device='cuda')
+ attrs[mask] = grid_sample_3d(
+ attr_volume,
+ torch.cat([torch.zeros_like(coords[:, :1]), coords], dim=-1),
+ shape=torch.Size([1, attr_volume.shape[1], *grid_size.tolist()]),
+ grid=((valid_pos - aabb[0]) / voxel_size).reshape(1, -1, 3),
+ mode='trilinear',
+ )
+ if use_tqdm:
+ pbar.update(1)
+ if verbose:
+ print("Done")
+
+ # --- Texture Post-Processing & Material Construction ---
+ if use_tqdm:
+ pbar.set_description("Finalizing mesh")
+ if verbose:
+ print("Finalizing mesh...", end='', flush=True)
+
+ mask = mask.cpu().numpy()
+
+ # Extract channels based on layout (BaseColor, Metallic, Roughness, Alpha)
+ base_color = np.clip(attrs[..., attr_layout['base_color']].cpu().numpy() * 255, 0, 255).astype(np.uint8)
+ metallic = np.clip(attrs[..., attr_layout['metallic']].cpu().numpy() * 255, 0, 255).astype(np.uint8)
+ roughness = np.clip(attrs[..., attr_layout['roughness']].cpu().numpy() * 255, 0, 255).astype(np.uint8)
+ alpha = np.clip(attrs[..., attr_layout['alpha']].cpu().numpy() * 255, 0, 255).astype(np.uint8)
+ alpha_mode = 'OPAQUE'
+
+ # Inpainting: fill gaps (dilation) to prevent black seams at UV boundaries
+ mask_inv = (~mask).astype(np.uint8)
+ base_color = cv2.inpaint(base_color, mask_inv, 3, cv2.INPAINT_TELEA)
+ metallic = cv2.inpaint(metallic, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
+ roughness = cv2.inpaint(roughness, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
+ alpha = cv2.inpaint(alpha, mask_inv, 1, cv2.INPAINT_TELEA)[..., None]
+
+ # Create PBR material
+ # Standard PBR packs Metallic and Roughness into Blue and Green channels
+ material = trimesh.visual.material.PBRMaterial(
+ baseColorTexture=Image.fromarray(np.concatenate([base_color, alpha], axis=-1)),
+ baseColorFactor=np.array([255, 255, 255, 255], dtype=np.uint8),
+ metallicRoughnessTexture=Image.fromarray(np.concatenate([np.zeros_like(metallic), roughness, metallic], axis=-1)),
+ metallicFactor=1.0,
+ roughnessFactor=1.0,
+ alphaMode=alpha_mode,
+ doubleSided=True if not remesh else False,
+ )
+
+ # --- Coordinate System Conversion & Final Object ---
+ vertices_np = out_vertices.cpu().numpy()
+ faces_np = out_faces.cpu().numpy()
+ uvs_np = out_uvs.cpu().numpy()
+ normals_np = out_normals.cpu().numpy()
+
+ # Swap Y and Z axes, invert Y (common conversion for GLB compatibility)
+ vertices_np[:, 1], vertices_np[:, 2] = vertices_np[:, 2], -vertices_np[:, 1]
+ normals_np[:, 1], normals_np[:, 2] = normals_np[:, 2], -normals_np[:, 1]
+ uvs_np[:, 1] = 1 - uvs_np[:, 1] # Flip UV V-coordinate
+
+ textured_mesh = trimesh.Trimesh(
+ vertices=vertices_np,
+ faces=faces_np,
+ vertex_normals=normals_np,
+ process=False,
+ visual=trimesh.visual.TextureVisuals(uv=uvs_np, material=material)
+ )
+
+ if use_tqdm:
+ pbar.update(1)
+ pbar.close()
+ if verbose:
+ print("Done")
+
+ return textured_mesh
\ No newline at end of file
diff --git a/o-voxel/o_voxel/rasterize.py b/o-voxel/o_voxel/rasterize.py
new file mode 100644
index 0000000..c27134a
--- /dev/null
+++ b/o-voxel/o_voxel/rasterize.py
@@ -0,0 +1,111 @@
+import torch
+import torch.nn.functional as F
+from easydict import EasyDict as edict
+from . import _C
+
+
+def intrinsics_to_projection(
+ intrinsics: torch.Tensor,
+ near: float,
+ far: float,
+ ) -> torch.Tensor:
+ """
+ OpenCV intrinsics to OpenGL perspective matrix
+
+ Args:
+ intrinsics (torch.Tensor): [3, 3] OpenCV intrinsics matrix
+ near (float): near plane to clip
+ far (float): far plane to clip
+ Returns:
+ (torch.Tensor): [4, 4] OpenGL perspective matrix
+ """
+ fx, fy = intrinsics[0, 0], intrinsics[1, 1]
+ cx, cy = intrinsics[0, 2], intrinsics[1, 2]
+ ret = torch.zeros((4, 4), dtype=intrinsics.dtype, device=intrinsics.device)
+ ret[0, 0] = 2 * fx
+ ret[1, 1] = 2 * fy
+ ret[0, 2] = 2 * cx - 1
+ ret[1, 2] = - 2 * cy + 1
+ ret[2, 2] = far / (far - near)
+ ret[2, 3] = near * far / (near - far)
+ ret[3, 2] = 1.
+ return ret
+
+
+class VoxelRenderer:
+ """
+ Renderer for the Voxel representation.
+
+ Args:
+ rendering_options (dict): Rendering options.
+ """
+
+ def __init__(self, rendering_options={}) -> None:
+ self.rendering_options = edict({
+ "resolution": None,
+ "near": 0.1,
+ "far": 10.0,
+ "ssaa": 1,
+ })
+ self.rendering_options.update(rendering_options)
+
+ def render(
+ self,
+ position: torch.Tensor,
+ attrs: torch.Tensor,
+ voxel_size: float,
+ extrinsics: torch.Tensor,
+ intrinsics: torch.Tensor,
+ ) -> edict:
+ """
+ Render the octree.
+
+ Args:
+ position (torch.Tensor): (N, 3) xyz positions
+ attrs (torch.Tensor): (N, C) attributes
+ voxel_size (float): voxel size
+ extrinsics (torch.Tensor): (4, 4) camera extrinsics
+ intrinsics (torch.Tensor): (3, 3) camera intrinsics
+
+ Returns:
+ edict containing:
+ attr (torch.Tensor): (C, H, W) rendered color
+ depth (torch.Tensor): (H, W) rendered depth
+ alpha (torch.Tensor): (H, W) rendered alpha
+ """
+ resolution = self.rendering_options["resolution"]
+ near = self.rendering_options["near"]
+ far = self.rendering_options["far"]
+ ssaa = self.rendering_options["ssaa"]
+
+ view = extrinsics
+ perspective = intrinsics_to_projection(intrinsics, near, far)
+ camera = torch.inverse(view)[:3, 3]
+ focalx = intrinsics[0, 0]
+ focaly = intrinsics[1, 1]
+ args = (
+ position,
+ attrs,
+ voxel_size,
+ view.T.contiguous(),
+ (perspective @ view).T.contiguous(),
+ camera,
+ 0.5 / focalx,
+ 0.5 / focaly,
+ resolution * ssaa,
+ resolution * ssaa,
+ )
+ color, depth, alpha = _C.rasterize_voxels_cuda(*args)
+
+ if ssaa > 1:
+ color = F.interpolate(color[None], size=(resolution, resolution), mode='bilinear', align_corners=False, antialias=True).squeeze()
+ depth = F.interpolate(depth[None, None], size=(resolution, resolution), mode='bilinear', align_corners=False, antialias=True).squeeze()
+ alpha = F.interpolate(alpha[None, None], size=(resolution, resolution), mode='bilinear', align_corners=False, antialias=True).squeeze()
+
+ ret = edict({
+ 'attr': color,
+ 'depth': depth,
+ 'alpha': alpha,
+ })
+ return ret
+
\ No newline at end of file
diff --git a/o-voxel/o_voxel/serialize.py b/o-voxel/o_voxel/serialize.py
new file mode 100644
index 0000000..452b5dc
--- /dev/null
+++ b/o-voxel/o_voxel/serialize.py
@@ -0,0 +1,68 @@
+from typing import *
+import torch
+from . import _C
+
+
+@torch.no_grad()
+def encode_seq(coords: torch.Tensor, permute: List[int] = [0, 1, 2], mode: Literal['z_order', 'hilbert'] = 'z_order') -> torch.Tensor:
+ """
+ Encodes 3D coordinates into a 30-bit code.
+
+ Args:
+ coords: a tensor of shape [N, 3] containing the 3D coordinates.
+ permute: the permutation of the coordinates.
+ mode: the encoding mode to use.
+ """
+ assert coords.shape[-1] == 3 and coords.ndim == 2, "Input coordinates must be of shape [N, 3]"
+ x = coords[:, permute[0]].int()
+ y = coords[:, permute[1]].int()
+ z = coords[:, permute[2]].int()
+ if mode == 'z_order':
+ if coords.device.type == 'cpu':
+ return _C.z_order_encode_cpu(x, y, z)
+ elif coords.device.type == 'cuda':
+ return _C.z_order_encode_cuda(x, y, z)
+ else:
+ raise ValueError(f"Unsupported device type: {coords.device.type}")
+ elif mode == 'hilbert':
+ if coords.device.type == 'cpu':
+ return _C.hilbert_encode_cpu(x, y, z)
+ elif coords.device.type == 'cuda':
+ return _C.hilbert_encode_cuda(x, y, z)
+ else:
+ raise ValueError(f"Unsupported device type: {coords.device.type}")
+ else:
+ raise ValueError(f"Unknown encoding mode: {mode}")
+
+
+@torch.no_grad()
+def decode_seq(code: torch.Tensor, permute: List[int] = [0, 1, 2], mode: Literal['z_order', 'hilbert'] = 'z_order') -> torch.Tensor:
+ """
+ Decodes a 30-bit code into 3D coordinates.
+
+ Args:
+ code: a tensor of shape [N] containing the 30-bit code.
+ permute: the permutation of the coordinates.
+ mode: the decoding mode to use.
+ """
+ assert code.ndim == 1, "Input code must be of shape [N]"
+ if mode == 'z_order':
+ if code.device.type == 'cpu':
+ coords = _C.z_order_decode_cpu(code)
+ elif code.device.type == 'cuda':
+ coords = _C.z_order_decode_cuda(code)
+ else:
+ raise ValueError(f"Unsupported device type: {code.device.type}")
+ elif mode == 'hilbert':
+ if code.device.type == 'cpu':
+ coords = _C.hilbert_decode_cpu(code)
+ elif code.device.type == 'cuda':
+ coords = _C.hilbert_decode_cuda(code)
+ else:
+ raise ValueError(f"Unsupported device type: {code.device.type}")
+ else:
+ raise ValueError(f"Unknown decoding mode: {mode}")
+ x = coords[permute.index(0)]
+ y = coords[permute.index(1)]
+ z = coords[permute.index(2)]
+ return torch.stack([x, y, z], dim=-1)
diff --git a/o-voxel/pyproject.toml b/o-voxel/pyproject.toml
new file mode 100644
index 0000000..6b13d43
--- /dev/null
+++ b/o-voxel/pyproject.toml
@@ -0,0 +1,34 @@
+[build-system]
+requires = [
+ "setuptools>=64",
+ "wheel",
+ "torch",
+ "numpy",
+ "plyfile",
+ "trimesh",
+ "tqdm",
+ "zstandard",
+ "easydict"
+]
+build-backend = "setuptools.build_meta"
+
+
+[project]
+name = "o_voxel"
+version = "0.0.1"
+description = "All about voxel."
+requires-python = ">=3.8"
+authors = [
+ { name = "Jianfeng Xiang", email = "belljig@outlook.com" }
+]
+dependencies = [
+ "torch",
+ "numpy",
+ "plyfile",
+ "trimesh",
+ "tqdm",
+ "zstandard",
+ "easydict",
+ "cumesh @ git+https://github.com/JeffreyXiang/CuMesh.git",
+ "flex_gemm @ git+https://github.com/JeffreyXiang/FlexGEMM.git",
+]
diff --git a/o-voxel/setup.py b/o-voxel/setup.py
new file mode 100644
index 0000000..91cb5ce
--- /dev/null
+++ b/o-voxel/setup.py
@@ -0,0 +1,67 @@
+from setuptools import setup
+from torch.utils.cpp_extension import CUDAExtension, BuildExtension, IS_HIP_EXTENSION
+import os
+
+ROOT = os.path.dirname(os.path.abspath(__file__))
+BUILD_TARGET = os.environ.get("BUILD_TARGET", "auto")
+
+if BUILD_TARGET == "auto":
+ if IS_HIP_EXTENSION:
+ IS_HIP = True
+ else:
+ IS_HIP = False
+else:
+ if BUILD_TARGET == "cuda":
+ IS_HIP = False
+ elif BUILD_TARGET == "rocm":
+ IS_HIP = True
+
+if not IS_HIP:
+ cc_flag = []
+else:
+ archs = os.getenv("GPU_ARCHS", "native").split(";")
+ cc_flag = [f"--offload-arch={arch}" for arch in archs]
+
+setup(
+ name="o_voxel",
+ packages=[
+ 'o_voxel',
+ 'o_voxel.convert',
+ 'o_voxel.io',
+ ],
+ ext_modules=[
+ CUDAExtension(
+ name="o_voxel._C",
+ sources=[
+ # Hashmap functions
+ "src/hash/hash.cu",
+ # Convert functions
+ "src/convert/flexible_dual_grid.cpp",
+ "src/convert/volumetic_attr.cpp",
+ ## Serialization functions
+ "src/serialize/api.cu",
+ "src/serialize/hilbert.cu",
+ "src/serialize/z_order.cu",
+ # IO functions
+ "src/io/svo.cpp",
+ "src/io/filter_parent.cpp",
+ "src/io/filter_neighbor.cpp",
+ # Rasterization functions
+ "src/rasterize/rasterize.cu",
+
+ # main
+ "src/ext.cpp",
+ ],
+ include_dirs=[
+ os.path.join(ROOT, "third_party/eigen"),
+ ],
+ extra_compile_args={
+ "cxx": ["-O3", "-std=c++17"],
+ "nvcc": ["-O3","-std=c++17"] + cc_flag,
+ }
+ )
+ ],
+ cmdclass={
+ 'build_ext': BuildExtension
+ }
+)
diff --git a/o-voxel/src/convert/api.h b/o-voxel/src/convert/api.h
new file mode 100644
index 0000000..b70551c
--- /dev/null
+++ b/o-voxel/src/convert/api.h
@@ -0,0 +1,122 @@
+/*
+ * O-Voxel Convertion API
+ *
+ * Copyright (C) 2025, Jianfeng XIANG
+ * All rights reserved.
+ *
+ * Licensed under The MIT License [see LICENSE for details]
+ *
+ * Written by Jianfeng XIANG
+ */
+
+#pragma once
+#include
+
+
+/**
+ * Extract flexible dual grid from a triangle mesh.
+ *
+ * @param vertices: Tensor of shape (N, 3) containing vertex positions.
+ * @param faces: Tensor of shape (M, 3) containing triangle vertex indices.
+ * @param voxel_size: Tensor of shape (3,) containing the voxel size in each dimension.
+ * @param grid_range: Tensor of shape (2, 3) containing the minimum and maximum coordinates of the grid range.
+ * @param face_weight: Weight for the face edges in the QEM computation.
+ * @param boundary_weight: Weight for the boundary edges in the QEM computation.
+ * @param regularization_weight: Regularization factor to apply to the QEM matrices.
+ * @param timing: Boolean flag to indicate whether to print timing information.
+ *
+ * @return a tuple ((x, y, z), vertices, intersected, faces) containing the remeshed vertices and the corresponding voxel grid.
+ */
+std::tuple mesh_to_flexible_dual_grid_cpu(
+ const torch::Tensor& vertices,
+ const torch::Tensor& faces,
+ const torch::Tensor& voxel_size,
+ const torch::Tensor& grid_range,
+ float face_weight,
+ float boundary_weight,
+ float regularization_weight,
+ bool timing
+);
+
+
+/**
+ * Voxelizes a triangle mesh with PBR materials
+ *
+ * @param voxel_size [3] tensor containing the size of a voxel
+ * @param grid_range [6] tensor containing the size of the grid
+ * @param vertices [N_tri, 3, 3] array containing the triangle vertices
+ * @param normals [N_tri, 3, 3] array containing the triangle vertex normals
+ * @param uvs [N_tri, 3, 2] tensor containing the texture coordinates
+ * @param materialIds [N_tri] tensor containing the material ids
+ * @param baseColorFactor list of [3] tensor containing the base color factor
+ * @param baseColorTexture list of [H, W, 3] tensor containing the base color texture
+ * @param baseColorTextureFilter list of int indicating the base color texture filter (0: NEAREST, 1: LINEAR)
+ * @param baseColorTextureWrap list of int indicating the base color texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param metallicFactor list of float containing the metallic factor
+ * @param metallicTexture list of [H, W] tensor containing the metallic texture
+ * @param metallicTextureFilter list of int indicating the metallic texture filter (0: NEAREST, 1: LINEAR)
+ * @param metallicTextureWrap list of int indicating the metallic texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param roughnessFactor list of float containing the roughness factor
+ * @param roughnessTexture list of [H, W] tensor containing the roughness texture
+ * @param roughnessTextureFilter list of int indicating the roughness texture filter (0: NEAREST, 1: LINEAR)
+ * @param roughnessTextureWrap list of int indicating the roughness texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param emissiveFactor list of [3] tensor containing the emissive factor
+ * @param emissiveTexture list of [H, W, 3] tensor containing the emissive texture
+ * @param emissiveTextureFilter list of int indicating the emissive texture filter (0: NEAREST, 1: LINEAR)
+ * @param emissiveTextureWrap list of int indicating the emissive texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param alphaMode list of int indicating the alpha mode (0: OPAQUE, 1: MASK, 2: BLEND)
+ * @param alphaCutoff list of float containing the alpha cutoff
+ * @param alphaFactor list of float containing the alpha factor
+ * @param alphaTexture list of [H, W] tensor containing the alpha texture
+ * @param alphaTextureFilter list of int indicating the alpha texture filter (0: NEAREST, 1: LINEAR)
+ * @param alphaTextureWrap list of int indicating the alpha texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param normalTexture list of [H, W, 3] tensor containing the normal texture
+ * @param normalTextureFilter list of int indicating the normal texture filter (0: NEAREST, 1: LINEAR)
+ * @param normalTextureWrap list of int indicating the normal texture wrap (0: REPEAT, 1: CLAMP_TO_EDGE, 2: MIRRORED_REPEAT)
+ * @param mipLevelOffset float indicating the mip level offset for texture mipmap
+ *
+ * @return tuple containing:
+ * - coords: tensor of shape [N, 3] containing the voxel coordinates
+ * - out_baseColor: tensor of shape [N, 3] containing the base color of each voxel
+ * - out_metallic: tensor of shape [N, 1] containing the metallic of each voxel
+ * - out_roughness: tensor of shape [N, 1] containing the roughness of each voxel
+ * - out_emissive: tensor of shape [N, 3] containing the emissive of each voxel
+ * - out_alpha: tensor of shape [N, 1] containing the alpha of each voxel
+ * - out_normal: tensor of shape [N, 3] containing the normal of each voxel
+ */
+std::tuple
+textured_mesh_to_volumetric_attr_cpu(
+ const torch::Tensor& voxel_size,
+ const torch::Tensor& grid_range,
+ const torch::Tensor& vertices,
+ const torch::Tensor& normals,
+ const torch::Tensor& uvs,
+ const torch::Tensor& materialIds,
+ const std::vector& baseColorFactor,
+ const std::vector& baseColorTexture,
+ const std::vector& baseColorTextureFilter,
+ const std::vector& baseColorTextureWrap,
+ const std::vector& metallicFactor,
+ const std::vector& metallicTexture,
+ const std::vector& metallicTextureFilter,
+ const std::vector& metallicTextureWrap,
+ const std::vector& roughnessFactor,
+ const std::vector& roughnessTexture,
+ const std::vector& roughnessTextureFilter,
+ const std::vector& roughnessTextureWrap,
+ const std::vector& emissiveFactor,
+ const std::vector& emissiveTexture,
+ const std::vector& emissiveTextureFilter,
+ const std::vector& emissiveTextureWrap,
+ const std::vector& alphaMode,
+ const std::vector& alphaCutoff,
+ const std::vector& alphaFactor,
+ const std::vector& alphaTexture,
+ const std::vector& alphaTextureFilter,
+ const std::vector& alphaTextureWrap,
+ const std::vector& normalTexture,
+ const std::vector& normalTextureFilter,
+ const std::vector& normalTextureWrap,
+ const float mipLevelOffset,
+ const bool timing
+);
diff --git a/o-voxel/src/convert/flexible_dual_grid.cpp b/o-voxel/src/convert/flexible_dual_grid.cpp
new file mode 100644
index 0000000..ad89edc
--- /dev/null
+++ b/o-voxel/src/convert/flexible_dual_grid.cpp
@@ -0,0 +1,775 @@
+#include
+#include
+#include
+#include
+#include
+
+#include "api.h"
+
+
+constexpr size_t kInvalidIndex = std::numeric_limits::max();
+
+
+struct float3 {float x, y, z; float& operator[](int i) {return (&x)[i];}};
+struct int3 {int x, y, z; int& operator[](int i) {return (&x)[i];}};
+struct int4 {int x, y, z, w; int& operator[](int i) {return (&x)[i];}};
+struct bool3 {bool x, y, z; bool& operator[](int i) {return (&x)[i];}};
+
+
+template
+static inline U lerp(const T& a, const T& b, const T& t, const U& val_a, const U& val_b) {
+ if (a == b) return val_a; // Avoid divide by zero
+ T alpha = (t - a) / (b - a);
+ return (1 - alpha) * val_a + alpha * val_b;
+}
+
+
+template
+static auto get_or_default(const Map& map, const Key& key, const Default& default_val) -> typename Map::mapped_type {
+ auto it = map.find(key);
+ return (it != map.end()) ? it->second : default_val;
+}
+
+
+// 3D voxel coordinate
+struct VoxelCoord {
+ int x, y, z;
+
+ int& operator[](int i) {
+ return (&x)[i];
+ }
+
+ bool operator==(const VoxelCoord& other) const {
+ return x == other.x && y == other.y && z == other.z;
+ }
+};
+
+// Hash function for VoxelCoord to use in unordered_map
+namespace std {
+template <>
+struct hash {
+ size_t operator()(const VoxelCoord& v) const {
+ const std::size_t p1 = 73856093;
+ const std::size_t p2 = 19349663;
+ const std::size_t p3 = 83492791;
+ return (std::size_t)(v.x) * p1 ^ (std::size_t)(v.y) * p2 ^ (std::size_t)(v.z) * p3;
+ }
+};
+}
+
+
+void intersect_qef(
+ const Eigen::Vector3f& voxel_size,
+ const Eigen::Vector3i& grid_min,
+ const Eigen::Vector3i& grid_max,
+ const std::vector& triangles, // 3 vertices per triangle
+ std::unordered_map& hash_table, // Hash table for voxel lookup
+ std::vector& voxels, // Output: Voxel coordinates
+ std::vector& means, // Output: Mean vertex positions for each voxel
+ std::vector& cnt, // Output: Number of intersections for each voxel
+ std::vector& intersected, // Output: Whether edge of voxel intersects with triangle
+ std::vector& qefs // Output: QEF matrices for each voxel
+) {
+ const size_t N_tri = triangles.size() / 3;
+
+ for (size_t i = 0; i < N_tri; ++i) {
+ const Eigen::Vector3f& v0 = triangles[i * 3 + 0];
+ const Eigen::Vector3f& v1 = triangles[i * 3 + 1];
+ const Eigen::Vector3f& v2 = triangles[i * 3 + 2];
+
+ // Compute edge vectors and face normal
+ Eigen::Vector3f e0 = v1 - v0;
+ Eigen::Vector3f e1 = v2 - v1;
+ Eigen::Vector3f n = e0.cross(e1).normalized();
+ Eigen::Vector4f plane;
+ plane << n.x(), n.y(), n.z(), -n.dot(v0);
+ auto Q = plane * plane.transpose();
+
+ // Scan-line algorithm to find intersections with the voxel grid from three directions
+ /*
+ t0
+ | \
+ | t1
+ | /
+ t2
+ */
+ auto scan_line_fill = [&] (const int ax2) {
+ int ax0 = (ax2 + 1) % 3;
+ int ax1 = (ax2 + 2) % 3;
+
+ // Canonical question
+ std::array t = {
+ Eigen::Vector3d(v0[ax0], v0[ax1], v0[ax2]),
+ Eigen::Vector3d(v1[ax0], v1[ax1], v1[ax2]),
+ Eigen::Vector3d(v2[ax0], v2[ax1], v2[ax2])
+ };
+ std::sort(t.begin(), t.end(), [](const Eigen::Vector3d& a, const Eigen::Vector3d& b) { return a.y() < b.y(); });
+
+ // Scan-line algorithm
+ int start = std::clamp(int(t[0].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+ int mid = std::clamp(int(t[1].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+ int end = std::clamp(int(t[2].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+
+ auto scan_line_half = [&] (const int row_start, const int row_end, const Eigen::Vector3d t0, const Eigen::Vector3d t1, const Eigen::Vector3d t2) {
+ /*
+ t0
+ | \
+ t3-t4
+ | \
+ t1---t2
+ */
+ for (int y_idx = row_start; y_idx < row_end; ++y_idx) {
+ double y = (y_idx + 1) * voxel_size[ax1];
+ Eigen::Vector2d t3 = lerp(t0.y(), t1.y(), y, Eigen::Vector2d(t0.x(), t0.z()), Eigen::Vector2d(t1.x(), t1.z()));
+ Eigen::Vector2d t4 = lerp(t0.y(), t2.y(), y, Eigen::Vector2d(t0.x(), t0.z()), Eigen::Vector2d(t2.x(), t2.z()));
+ if (t3.x() > t4.x()) std::swap(t3, t4);
+ int line_start = std::clamp(int(t3.x() / voxel_size[ax0]), grid_min[ax0], grid_max[ax0] - 1);
+ int line_end = std::clamp(int(t4.x() / voxel_size[ax0]), grid_min[ax0], grid_max[ax0] - 1);
+ for (int x_idx = line_start; x_idx < line_end; ++x_idx) {
+ double x = (x_idx + 1) * voxel_size[ax0];
+ double z = lerp(t3.x(), t4.x(), x, t3.y(), t4.y());
+ int z_idx = int(z / voxel_size[ax2]);
+ if (z_idx >= grid_min[ax2] && z_idx < grid_max[ax2]) {
+ // For 4-connected voxels
+ for (int dx = 0; dx < 2; ++dx) {
+ for (int dy = 0; dy < 2; ++dy) {
+ VoxelCoord coord;
+ coord[ax0] = x_idx + dx; coord[ax1] = y_idx + dy; coord[ax2] = z_idx;
+ Eigen::Vector3d intersect;
+ intersect[ax0] = x; intersect[ax1] = y; intersect[ax2] = z;
+ auto kv = hash_table.find(coord);
+ if (kv == hash_table.end()) {
+ hash_table[coord] = voxels.size();
+ voxels.push_back({coord.x, coord.y, coord.z});
+ means.push_back(intersect.cast());
+ cnt.push_back(1);
+ intersected.push_back({false, false, false});
+ qefs.push_back(Q);
+ if (dx == 0 && dy == 0)
+ intersected.back()[ax2] = true;
+ }
+ else {
+ auto i = kv->second;
+ means[i] += intersect.cast();
+ cnt[i] += 1;
+ if (dx == 0 && dy == 0)
+ intersected[i][ax2] = true;
+ qefs[i] += Q;
+ }
+ }
+ }
+ }
+ }
+ }
+ };
+ scan_line_half(start, mid, t[0], t[1], t[2]);
+ scan_line_half(mid, end, t[2], t[1], t[0]);
+ };
+ scan_line_fill(0);
+ scan_line_fill(1);
+ scan_line_fill(2);
+ }
+}
+
+
+void face_qef(
+ const Eigen::Vector3f& voxel_size,
+ const Eigen::Vector3i& grid_min,
+ const Eigen::Vector3i& grid_max,
+ const std::vector& triangles, // 3 vertices per triangle
+ std::unordered_map& hash_table, // Hash table for voxel lookup
+ std::vector& qefs // Output: QEF matrices for each voxel
+) {
+ const size_t N_tri = triangles.size() / 3;
+
+ for (size_t i = 0; i < N_tri; ++i) {
+ const Eigen::Vector3f& v0 = triangles[i * 3 + 0];
+ const Eigen::Vector3f& v1 = triangles[i * 3 + 1];
+ const Eigen::Vector3f& v2 = triangles[i * 3 + 2];
+
+ // Compute edge vectors and face normal
+ Eigen::Vector3f e0 = v1 - v0;
+ Eigen::Vector3f e1 = v2 - v1;
+ Eigen::Vector3f e2 = v0 - v2;
+ Eigen::Vector3f n = e0.cross(e1).normalized();
+ Eigen::Vector4f plane;
+ plane << n.x(), n.y(), n.z(), -n.dot(v0);
+ auto Q = plane * plane.transpose();
+
+ // Compute triangle bounding box in voxel coordinates
+ Eigen::Vector3f bb_min_f = v0.cwiseMin(v1).cwiseMin(v2).cwiseQuotient(voxel_size);
+ Eigen::Vector3f bb_max_f = v0.cwiseMax(v1).cwiseMax(v2).cwiseQuotient(voxel_size);
+
+ Eigen::Vector3i bb_min(std::max(static_cast(bb_min_f.x()), grid_min.x()),
+ std::max(static_cast(bb_min_f.y()), grid_min.y()),
+ std::max(static_cast(bb_min_f.z()), grid_min.z()));
+ Eigen::Vector3i bb_max(std::min(static_cast(bb_max_f.x() + 1), grid_max.x()),
+ std::min(static_cast(bb_max_f.y() + 1), grid_max.y()),
+ std::min(static_cast(bb_max_f.z() + 1), grid_max.z()));
+
+ // Plane test setup
+ Eigen::Vector3f c(
+ n.x() > 0.0f ? voxel_size.x() : 0.0f,
+ n.y() > 0.0f ? voxel_size.y() : 0.0f,
+ n.z() > 0.0f ? voxel_size.z() : 0.0f
+ );
+ float d1 = n.dot(c - v0);
+ float d2 = n.dot(voxel_size - c - v0);
+
+ // XY plane projection test setup
+ int mul_xy = (n.z() < 0.0f) ? -1 : 1;
+ Eigen::Vector2f n_xy_e0(-mul_xy * e0.y(), mul_xy * e0.x());
+ Eigen::Vector2f n_xy_e1(-mul_xy * e1.y(), mul_xy * e1.x());
+ Eigen::Vector2f n_xy_e2(-mul_xy * e2.y(), mul_xy * e2.x());
+
+ float d_xy_e0 = -n_xy_e0.dot(v0.head<2>()) + n_xy_e0.cwiseMax(0.0f).dot(voxel_size.head<2>());
+ float d_xy_e1 = -n_xy_e1.dot(v1.head<2>()) + n_xy_e1.cwiseMax(0.0f).dot(voxel_size.head<2>());
+ float d_xy_e2 = -n_xy_e2.dot(v2.head<2>()) + n_xy_e2.cwiseMax(0.0f).dot(voxel_size.head<2>());
+
+ // YZ plane projection test setup
+ int mul_yz = (n.x() < 0.0f) ? -1 : 1;
+ Eigen::Vector2f n_yz_e0(-mul_yz * e0.z(), mul_yz * e0.y());
+ Eigen::Vector2f n_yz_e1(-mul_yz * e1.z(), mul_yz * e1.y());
+ Eigen::Vector2f n_yz_e2(-mul_yz * e2.z(), mul_yz * e2.y());
+
+ float d_yz_e0 = -n_yz_e0.dot(Eigen::Vector2f(v0.y(), v0.z())) + n_yz_e0.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.y(), voxel_size.z()));
+ float d_yz_e1 = -n_yz_e1.dot(Eigen::Vector2f(v1.y(), v1.z())) + n_yz_e1.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.y(), voxel_size.z()));
+ float d_yz_e2 = -n_yz_e2.dot(Eigen::Vector2f(v2.y(), v2.z())) + n_yz_e2.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.y(), voxel_size.z()));
+
+ // ZX plane projection test setup
+ int mul_zx = (n.y() < 0.0f) ? -1 : 1;
+ Eigen::Vector2f n_zx_e0(-mul_zx * e0.x(), mul_zx * e0.z());
+ Eigen::Vector2f n_zx_e1(-mul_zx * e1.x(), mul_zx * e1.z());
+ Eigen::Vector2f n_zx_e2(-mul_zx * e2.x(), mul_zx * e2.z());
+
+ float d_zx_e0 = -n_zx_e0.dot(Eigen::Vector2f(v0.z(), v0.x())) + n_zx_e0.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.z(), voxel_size.x()));
+ float d_zx_e1 = -n_zx_e1.dot(Eigen::Vector2f(v1.z(), v1.x())) + n_zx_e1.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.z(), voxel_size.x()));
+ float d_zx_e2 = -n_zx_e2.dot(Eigen::Vector2f(v2.z(), v2.x())) + n_zx_e2.cwiseMax(0.0f).dot(Eigen::Vector2f(voxel_size.z(), voxel_size.x()));
+
+ // Loop over candidate voxels inside bounding box
+ for (int z = bb_min.z(); z < bb_max.z(); ++z) {
+ for (int y = bb_min.y(); y < bb_max.y(); ++y) {
+ for (int x = bb_min.x(); x < bb_max.x(); ++x) {
+ // Voxel center
+ Eigen::Vector3f p = voxel_size.cwiseProduct(Eigen::Vector3f(x, y, z));
+
+ // Plane through box test
+ float nDOTp = n.dot(p);
+ if (((nDOTp + d1) * (nDOTp + d2)) > 0.0f) continue;
+
+ // XY projection test
+ Eigen::Vector2f p_xy(p.x(), p.y());
+ if (n_xy_e0.dot(p_xy) + d_xy_e0 < 0) continue;
+ if (n_xy_e1.dot(p_xy) + d_xy_e1 < 0) continue;
+ if (n_xy_e2.dot(p_xy) + d_xy_e2 < 0) continue;
+
+ // YZ projection test
+ Eigen::Vector2f p_yz(p.y(), p.z());
+ if (n_yz_e0.dot(p_yz) + d_yz_e0 < 0) continue;
+ if (n_yz_e1.dot(p_yz) + d_yz_e1 < 0) continue;
+ if (n_yz_e2.dot(p_yz) + d_yz_e2 < 0) continue;
+
+ // ZX projection test
+ Eigen::Vector2f p_zx(p.z(), p.x());
+ if (n_zx_e0.dot(p_zx) + d_zx_e0 < 0) continue;
+ if (n_zx_e1.dot(p_zx) + d_zx_e1 < 0) continue;
+ if (n_zx_e2.dot(p_zx) + d_zx_e2 < 0) continue;
+
+ // Passed all tests — mark voxel
+ auto coord = VoxelCoord{x, y, z};
+ auto kv = hash_table.find(coord);
+ if (kv != hash_table.end()) {
+ qefs[kv->second] += Q;
+ }
+ }
+ }
+ }
+ }
+}
+
+
+void boundry_qef(
+ const Eigen::Vector3f& voxel_size,
+ const Eigen::Vector3i& grid_min,
+ const Eigen::Vector3i& grid_max,
+ const std::vector& boundries, // 2 vertices per segment
+ const float boundary_weight, // Weight for boundary edges
+ std::unordered_map& hash_table, // Hash table for voxel lookup
+ std::vector& qefs // Output: QEF matrices for each voxel
+) {
+ for (size_t i = 0; i < boundries.size() / 2; ++i) {
+ const Eigen::Vector3f& v0 = boundries[i * 2 + 0];
+ const Eigen::Vector3f& v1 = boundries[i * 2 + 1];
+
+ // Calculate the QEF for the edge (boundary) defined by v0 and v1
+ Eigen::Vector3d dir(v1.x() - v0.x(), v1.y() - v0.y(), v1.z() - v0.z());
+ double segment_length = dir.norm();
+ if (segment_length < 1e-6d) continue; // Skip degenerate edges (zero-length)
+ dir.normalize(); // unit direction vector
+
+ // Projection matrix orthogonal to the direction: I - d d^T
+ Eigen::Matrix3f A = Eigen::Matrix3f::Identity() - (dir * dir.transpose()).cast();
+
+ // b = -A * v0
+ Eigen::Vector3f b = -A * v0;
+
+ // c = v0^T * A * v0
+ float c = v0.transpose() * A * v0;
+
+ // Now pack this into a 4x4 QEF matrix
+ Eigen::Matrix4f Q = Eigen::Matrix4f::Zero();
+ Q.block<3, 3>(0, 0) = A;
+ Q.block<3, 1>(0, 3) = b;
+ Q.block<1, 3>(3, 0) = b.transpose();
+ Q(3, 3) = c;
+
+ // DDA Traversal logic directly inside the function
+
+ // Starting and ending voxel coordinates
+ Eigen::Vector3i v0_voxel = (v0.cwiseQuotient(voxel_size)).array().floor().cast();
+ Eigen::Vector3i v1_voxel = (v1.cwiseQuotient(voxel_size)).array().floor().cast();
+
+ // Determine step direction for each axis based on the line direction
+ Eigen::Vector3i step = (dir.array() > 0).select(Eigen::Vector3i(1, 1, 1), Eigen::Vector3i(-1, -1, -1));
+
+ Eigen::Vector3d tMax, tDelta;
+ for (int axis = 0; axis < 3; ++axis) {
+ if (dir[axis] == 0.0d) {
+ tMax[axis] = std::numeric_limits::infinity();
+ tDelta[axis] = std::numeric_limits::infinity();
+ } else {
+ float voxel_border = voxel_size[axis] * (v0_voxel[axis] + (step[axis] > 0 ? 1 : 0));
+ tMax[axis] = (voxel_border - v0[axis]) / dir[axis];
+ tDelta[axis] = voxel_size[axis] / std::abs(dir[axis]);
+ }
+ }
+
+ // Current voxel position
+ Eigen::Vector3i current = v0_voxel;
+
+ // Store the voxel we start at
+ std::vector voxels;
+ voxels.push_back({current.x(), current.y(), current.z()});
+
+ // Traverse the voxels
+ while (true) {
+ int axis;
+ if (tMax.x() < tMax.y()) {
+ axis = (tMax.x() < tMax.z()) ? 0 : 2;
+ } else {
+ axis = (tMax.y() < tMax.z()) ? 1 : 2;
+ }
+
+ if (tMax[axis] > segment_length) break;
+
+ current[axis] += step[axis];
+ tMax[axis] += tDelta[axis];
+
+ voxels.push_back({current.x(), current.y(), current.z()});
+ }
+
+ // Accumulate QEF for each voxel passed through
+ for (const auto& coord : voxels) {
+ // Make sure the voxel is within bounds
+ if ((coord.x < grid_min.x() || coord.x >= grid_max.x()) ||
+ (coord.y < grid_min.y() || coord.y >= grid_max.y()) ||
+ (coord.z < grid_min.z() || coord.z >= grid_max.z())) continue;
+ if (!hash_table.count(coord)) continue; // Skip if voxel not in hash table
+
+ // Accumulate the QEF for this voxel
+ qefs[hash_table[coord]] += boundary_weight * Q; // Scale by boundary weight
+ }
+ }
+}
+
+
+std::array quad_to_2tri(
+ const std::vector& vertices,
+ const int4& quad_indices
+) {
+ int ia = quad_indices.x;
+ int ib = quad_indices.y;
+ int ic = quad_indices.z;
+ int id = quad_indices.w;
+
+ Eigen::Vector3f a(vertices[ia].x, vertices[ia].y, vertices[ia].z);
+ Eigen::Vector3f b(vertices[ib].x, vertices[ib].y, vertices[ib].z);
+ Eigen::Vector3f c(vertices[ic].x, vertices[ic].y, vertices[ic].z);
+ Eigen::Vector3f d(vertices[id].x, vertices[id].y, vertices[id].z);
+
+ // diagonal AC
+ Eigen::Vector3f n_abc = (b - a).cross(c - a).normalized();
+ Eigen::Vector3f n_acd = (c - a).cross(d - a).normalized();
+ float angle_ac = std::acos(std::clamp(n_abc.dot(n_acd), -1.0f, 1.0f));
+
+ // diagonal BD
+ Eigen::Vector3f n_abd = (b - a).cross(d - a).normalized();
+ Eigen::Vector3f n_bcd = (c - b).cross(d - b).normalized();
+ float angle_bd = std::acos(std::clamp(n_abd.dot(n_bcd), -1.0f, 1.0f));
+
+ if (angle_ac <= angle_bd) {
+ return {int3{ia, ib, ic}, int3{ia, ic, id}};
+ } else {
+ return {int3{ia, ib, id}, int3{ib, ic, id}};
+ }
+}
+
+
+void face_from_dual_vertices(
+ const std::unordered_map& hash_table,
+ const std::vector& voxels,
+ const std::vector& dual_vertices,
+ const std::vector& intersected,
+ std::vector& face_indices
+) {
+ for (int i = 0; i < dual_vertices.size(); ++i) {
+ int3 coord = voxels[i];
+ bool3 is_intersected = intersected[i];
+
+ // Check existence of neighboring 6 voxels
+ size_t neigh_indices[6] = {
+ get_or_default(hash_table, VoxelCoord{coord.x + 1, coord.y, coord.z}, kInvalidIndex),
+ get_or_default(hash_table, VoxelCoord{coord.x, coord.y + 1, coord.z}, kInvalidIndex),
+ get_or_default(hash_table, VoxelCoord{coord.x + 1, coord.y + 1, coord.z}, kInvalidIndex),
+ get_or_default(hash_table, VoxelCoord{coord.x, coord.y, coord.z + 1}, kInvalidIndex),
+ get_or_default(hash_table, VoxelCoord{coord.x + 1, coord.y, coord.z + 1}, kInvalidIndex),
+ get_or_default(hash_table, VoxelCoord{coord.x, coord.y + 1, coord.z + 1}, kInvalidIndex)
+ };
+
+ // xy-plane
+ if (is_intersected[2] && neigh_indices[0] != kInvalidIndex && neigh_indices[1] != kInvalidIndex && neigh_indices[2] != kInvalidIndex) {
+ int4 quad_indices{i, neigh_indices[0], neigh_indices[2], neigh_indices[1]};
+ auto tri_indices = quad_to_2tri(dual_vertices, quad_indices);
+ face_indices.insert(face_indices.end(), tri_indices.begin(), tri_indices.end());
+ }
+ // yz-plane
+ if (is_intersected[0] && neigh_indices[1] != kInvalidIndex && neigh_indices[3] != kInvalidIndex && neigh_indices[5] != kInvalidIndex) {
+ int4 quad_indices{i, neigh_indices[1], neigh_indices[5], neigh_indices[3]};
+ auto tri_indices = quad_to_2tri(dual_vertices, quad_indices);
+ face_indices.insert(face_indices.end(), tri_indices.begin(), tri_indices.end());
+ }
+ // xz-plane
+ if (is_intersected[1] && neigh_indices[0] != kInvalidIndex && neigh_indices[3] != kInvalidIndex && neigh_indices[4] != kInvalidIndex) {
+ int4 quad_indices{i, neigh_indices[0], neigh_indices[4], neigh_indices[3]};
+ auto tri_indices = quad_to_2tri(dual_vertices, quad_indices);
+ face_indices.insert(face_indices.end(), tri_indices.begin(), tri_indices.end());
+ }
+ }
+}
+
+/**
+ * Extract flexible dual grid from a triangle mesh.
+ *
+ * @param vertices: Tensor of shape (N, 3) containing vertex positions.
+ * @param faces: Tensor of shape (M, 3) containing triangle vertex indices.
+ * @param voxel_size: Tensor of shape (3,) containing the voxel size in each dimension.
+ * @param grid_range: Tensor of shape (2, 3) containing the minimum and maximum coordinates of the grid range.
+ * @param face_weight: Weight for the face edges in the QEF computation.
+ * @param boundary_weight: Weight for the boundary edges in the QEF computation.
+ * @param regularization_weight: Regularization factor to apply to the QEF matrices.
+ * @param timing: Boolean flag to indicate whether to print timing information.
+ *
+ * @return a tuple ((x, y, z), vertices, intersected, faces) containing the remeshed vertices and the corresponding voxel grid.
+ */
+std::tuple mesh_to_flexible_dual_grid_cpu(
+ const torch::Tensor& vertices,
+ const torch::Tensor& faces,
+ const torch::Tensor& voxel_size,
+ const torch::Tensor& grid_range,
+ float face_weight,
+ float boundary_weight,
+ float regularization_weight,
+ bool timing
+) {
+ const int F = faces.size(0);
+ const float* v_ptr = vertices.data_ptr();
+ const int* f_ptr = faces.data_ptr();
+ const float* voxel_size_ptr = voxel_size.data_ptr();
+ const int* grid_range_ptr = grid_range.data_ptr();
+ clock_t start, end;
+ std::unordered_map hash_table;
+ std::vector voxels; // Voxel coordinates
+ std::vector means; // Mean vertex positions for each voxel
+ std::vector cnt; // Number of intersections for each voxel
+ std::vector intersected; // Indicate whether edges of voxels intersect with surface
+ std::vector qefs; // QEF matrices for each voxel
+
+ // Convert tensors to Eigen types
+ Eigen::Vector3f e_voxel_size(voxel_size_ptr[0], voxel_size_ptr[1], voxel_size_ptr[2]);
+ Eigen::Vector3i e_grid_min(grid_range_ptr[0], grid_range_ptr[1], grid_range_ptr[2]);
+ Eigen::Vector3i e_grid_max(grid_range_ptr[3], grid_range_ptr[4], grid_range_ptr[5]);
+
+ // Intersect QEF computation
+ start = clock();
+ std::vector triangles;
+ triangles.reserve(F * 3);
+ for (int f = 0; f < F; ++f) {
+ for (int v = 0; v < 3; ++v) {
+ triangles.push_back(Eigen::Vector3f(
+ v_ptr[f_ptr[f * 3 + v] * 3 + 0],
+ v_ptr[f_ptr[f * 3 + v] * 3 + 1],
+ v_ptr[f_ptr[f * 3 + v] * 3 + 2]
+ ));
+ }
+ }
+ intersect_qef(e_voxel_size, e_grid_min, e_grid_max, triangles, hash_table, voxels, means, cnt, intersected, qefs);
+ end = clock();
+ if (timing) std::cout << "Intersect QEF computation took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+
+ // Face QEF computation
+ if (face_weight > 0.0f) {
+ start = clock();
+ face_qef(e_voxel_size, e_grid_min, e_grid_max, triangles, hash_table, qefs);
+ end = clock();
+ if (timing) std::cout << "Face QEF computation took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+ }
+
+ // Boundary QEF computation
+ if (boundary_weight > 0.0f) {
+ start = clock();
+ std::map, int> edge_count;
+ for (int f = 0; f < F; ++f) {
+ for (int v0 = 0; v0 < 3; ++v0) {
+ int e0 = f_ptr[f * 3 + v0];
+ int e1 = f_ptr[f * 3 + (v0 + 1) % 3];
+ if (e0 > e1) std::swap(e0, e1);
+ edge_count[std::make_pair(e0, e1)]++;
+ }
+ }
+ std::vector boundries;
+ for (const auto& e : edge_count) {
+ if (e.second == 1) {
+ int v0 = e.first.first;
+ int v1 = e.first.second;
+ boundries.push_back(Eigen::Vector3f(
+ v_ptr[v0 * 3 + 0],
+ v_ptr[v0 * 3 + 1],
+ v_ptr[v0 * 3 + 2]
+ ));
+ boundries.push_back(Eigen::Vector3f(
+ v_ptr[v1 * 3 + 0],
+ v_ptr[v1 * 3 + 1],
+ v_ptr[v1 * 3 + 2]
+ ));
+ }
+ }
+ boundry_qef(e_voxel_size, e_grid_min, e_grid_max, boundries, boundary_weight, hash_table, qefs);
+ end = clock();
+ if (timing) std::cout << "Boundary QEF computation took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+ }
+
+ // Solve the QEF system to obtain final dual vertices
+ start = clock();
+ std::vector dual_vertices(voxels.size());
+ for (int i = 0; i < voxels.size(); ++i) {
+ int3 coord = voxels[i];
+ Eigen::Matrix4f Q = qefs[i];
+ float min_corner[3] = {
+ coord.x * e_voxel_size.x(),
+ coord.y * e_voxel_size.y(),
+ coord.z * e_voxel_size.z()
+ };
+ float max_corner[3] = {
+ (coord.x + 1) * e_voxel_size.x(),
+ (coord.y + 1) * e_voxel_size.y(),
+ (coord.z + 1) * e_voxel_size.z()
+ };
+
+ // Add regularization term
+ if (regularization_weight > 0.0f) {
+ Eigen::Vector3f p = means[i] / cnt[i];
+
+ // Construct the QEF matrix for this vertex
+ Eigen::Matrix4f Qreg = Eigen::Matrix4f::Zero();
+ Qreg.topLeftCorner<3,3>() = Eigen::Matrix3f::Identity();
+ Qreg.block<3,1>(0,3) = -p;
+ Qreg.block<1,3>(3,0) = -p.transpose();
+ Qreg(3,3) = p.dot(p);
+
+ Q += regularization_weight * cnt[i] * Qreg; // Scale by regularization weight
+ }
+
+ // Solve unconstrained
+ Eigen::Matrix3f A = Q.topLeftCorner<3, 3>();
+ Eigen::Vector3f b = -Q.block<3, 1>(0, 3);
+ Eigen::Vector3f v_new = A.colPivHouseholderQr().solve(b);
+
+ if (!(
+ v_new.x() >= min_corner[0] && v_new.x() <= max_corner[0] &&
+ v_new.y() >= min_corner[1] && v_new.y() <= max_corner[1] &&
+ v_new.z() >= min_corner[2] && v_new.z() <= max_corner[2]
+ )) {
+ // Starting enumeration of constraints
+ float best = std::numeric_limits::infinity();
+
+ // Solve single-constraint
+ auto solve_single_constraint = [&](int fixed_axis) {
+ int ax1 = (fixed_axis + 1) % 3;
+ int ax2 = (fixed_axis + 2) % 3;
+
+ Eigen::Matrix2f A;
+ Eigen::Matrix2f B;
+ Eigen::Vector2f q, b, x;
+
+ A << Q(ax1, ax1), Q(ax1, ax2),
+ Q(ax2, ax1), Q(ax2, ax2);
+ B << Q(ax1, fixed_axis), Q(ax1, 3),
+ Q(ax2, fixed_axis), Q(ax2, 3);
+ auto Asol = A.colPivHouseholderQr();
+
+ // if lower bound
+ q << min_corner[fixed_axis], 1;
+ b = -B * q;
+ x = Asol.solve(b);
+ if (
+ x.x() >= min_corner[ax1] && x.x() <= max_corner[ax1] &&
+ x.y() >= min_corner[ax2] && x.y() <= max_corner[ax2]
+ ) {
+ Eigen::Vector4f p;
+ p[fixed_axis] = min_corner[fixed_axis];
+ p[ax1] = x.x();
+ p[ax2] = x.y();
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+
+ // if upper bound
+ q << max_corner[fixed_axis], 1;
+ b = -B * q;
+ x = Asol.solve(b);
+ if (
+ x.x() >= min_corner[ax1] && x.x() <= max_corner[ax1] &&
+ x.y() >= min_corner[ax2] && x.y() <= max_corner[ax2]
+ ) {
+ Eigen::Vector4f p;
+ p[fixed_axis] = max_corner[fixed_axis];
+ p[ax1] = x.x();
+ p[ax2] = x.y();
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+ };
+ solve_single_constraint(0); // fix x
+ solve_single_constraint(1); // fix y
+ solve_single_constraint(2); // fix z
+
+ // Solve two-constraint
+ auto solve_two_constraint = [&](int free_axis) {
+ int ax1 = (free_axis + 1) % 3;
+ int ax2 = (free_axis + 2) % 3;
+
+ float a, x;
+ Eigen::Vector3f b, q;
+
+ a = Q(free_axis, free_axis);
+ b << Q(free_axis, ax1), Q(free_axis, ax2), Q(free_axis, 3);
+
+ // if lower-lower bound
+ q << min_corner[ax1], min_corner[ax2], 1;
+ x = -(b.dot(q)) / a;
+ if (x >= min_corner[free_axis] && x <= max_corner[free_axis]) {
+ Eigen::Vector4f p;
+ p[free_axis] = x;
+ p[ax1] = min_corner[ax1];
+ p[ax2] = min_corner[ax2];
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+
+ // if lower-upper bound
+ q << min_corner[ax1], max_corner[ax2], 1;
+ x = -(b.dot(q)) / a;
+ if (x >= min_corner[free_axis] && x <= max_corner[free_axis]) {
+ Eigen::Vector4f p;
+ p[free_axis] = x;
+ p[ax1] = min_corner[ax1];
+ p[ax2] = max_corner[ax2];
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+
+ // if upper-lower bound
+ q << max_corner[ax1], min_corner[ax2], 1;
+ x = -(b.dot(q)) / a;
+ if (x >= min_corner[free_axis] && x <= max_corner[free_axis]) {
+ Eigen::Vector4f p;
+ p[free_axis] = x;
+ p[ax1] = max_corner[ax1];
+ p[ax2] = min_corner[ax2];
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+
+ // if upper-upper bound
+ q << max_corner[ax1], max_corner[ax2], 1;
+ x = -(b.dot(q)) / a;
+ if (x >= min_corner[free_axis] && x <= max_corner[free_axis]) {
+ Eigen::Vector4f p;
+ p[free_axis] = x;
+ p[ax1] = max_corner[ax1];
+ p[ax2] = max_corner[ax2];
+ p[3] = 1.0f;
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+ };
+ solve_two_constraint(0); // free x
+ solve_two_constraint(1); // free y
+ solve_two_constraint(2); // free z
+
+ // Solve three-constraint
+ for (int x_constraint = 0; x_constraint < 2; ++x_constraint) {
+ for (int y_constraint = 0; y_constraint < 2; ++y_constraint) {
+ for (int z_constraint = 0; z_constraint < 2; ++z_constraint) {
+ Eigen::Vector4f p;
+ p[0] = x_constraint ? min_corner[0] : max_corner[0];
+ p[1] = y_constraint ? min_corner[1] : max_corner[1];
+ p[2] = z_constraint ? min_corner[2] : max_corner[2];
+ p[3] = 1.0f;
+
+ float err = p.transpose() * Q * p;
+ if (err < best) {
+ best = err;
+ v_new << p[0], p[1], p[2];
+ }
+ }
+ }
+ }
+ }
+
+ // Store the dual vertex and voxel grid coordinates
+ dual_vertices[i] = float3{v_new.x(), v_new.y(), v_new.z()};
+ }
+ end = clock();
+ if (timing) std::cout << "Dual vertices computation took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+
+ return std::make_tuple(
+ torch::from_blob(voxels.data(), {int(voxels .size()), 3}, torch::kInt32).clone(),
+ torch::from_blob(dual_vertices.data(), {int(dual_vertices.size()), 3}, torch::kFloat32).clone(),
+ torch::from_blob(intersected.data(), {int(intersected.size()), 3}, torch::kBool).clone()
+ );
+}
+
diff --git a/o-voxel/src/convert/volumetic_attr.cpp b/o-voxel/src/convert/volumetic_attr.cpp
new file mode 100644
index 0000000..64c3d09
--- /dev/null
+++ b/o-voxel/src/convert/volumetic_attr.cpp
@@ -0,0 +1,872 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "api.h"
+
+
+constexpr size_t kInvalidIndex = std::numeric_limits::max();
+
+
+static bool is_power_of_two(int n) {
+ return n > 0 && (n & (n - 1)) == 0;
+}
+
+
+template
+static inline U lerp(const T& a, const T& b, const T& t, const U& val_a, const U& val_b) {
+ if (a == b) return val_a; // Avoid divide by zero
+ T alpha = (t - a) / (b - a);
+ return (1 - alpha) * val_a + alpha * val_b;
+}
+
+
+template
+static auto get_or_default(const Map& map, const Key& key, const Default& default_val) -> typename Map::mapped_type {
+ auto it = map.find(key);
+ return (it != map.end()) ? it->second : default_val;
+}
+
+
+// 3D voxel coordinate
+struct VoxelCoord {
+ int x, y, z;
+
+ int& operator[](int i) {
+ return (&x)[i];
+ }
+
+ bool operator==(const VoxelCoord& other) const {
+ return x == other.x && y == other.y && z == other.z;
+ }
+};
+
+// Hash function for VoxelCoord to use in unordered_map
+namespace std {
+template <>
+struct hash {
+ size_t operator()(const VoxelCoord& v) const {
+ const std::size_t p1 = 73856093;
+ const std::size_t p2 = 19349663;
+ const std::size_t p3 = 83492791;
+ return (std::size_t)(v.x) * p1 ^ (std::size_t)(v.y) * p2 ^ (std::size_t)(v.z) * p3;
+ }
+};
+}
+
+
+/**
+ * Compute the Normal Tangent and Bitangent vectors for a triangle.
+ *
+ * @param v0 The first vertex of the triangle.
+ * @param v1 The second vertex of the triangle.
+ * @param v2 The third vertex of the triangle.
+ * @param uv0 The texture coordinates of the first vertex.
+ * @param uv1 The texture coordinates of the second vertex.
+ * @param uv2 The texture coordinates of the third vertex.
+ *
+ * @return A tuple containing:
+ * - t The tangent vector.
+ * - b The bitangent vector.
+ * - n The normal vector.
+ * - mip_length The norms of the partial derivatives of the 3D coordinates with respect to the 2D texture coordinates.
+ */
+static std::tuple compute_TBN(
+ const Eigen::Vector3f& v0,
+ const Eigen::Vector3f& v1,
+ const Eigen::Vector3f& v2,
+ const Eigen::Vector2f& uv0,
+ const Eigen::Vector2f& uv1,
+ const Eigen::Vector2f& uv2
+) {
+ Eigen::Vector3f e1 = v1 - v0;
+ Eigen::Vector3f e2 = v2 - v0;
+ Eigen::Vector2f duv1 = uv1 - uv0;
+ Eigen::Vector2f duv2 = uv2 - uv0;
+ Eigen::Vector3f n = e1.cross(e2).normalized();
+
+ float det = duv1.x() * duv2.y() - duv1.y() * duv2.x();
+ if (fabs(det) < 1e-6) {
+ // Use default
+ Eigen::Vector3f t(1.0f, 0.0f, 0.0f);
+ Eigen::Vector3f b(0.0f, 1.0f, 0.0f);
+ Eigen::Vector2f mip_length(1e6, 1e6);
+ return std::make_tuple(t, b, n, mip_length);
+ }
+
+ float invDet = 1.0f / det;
+ Eigen::Vector3f t = (duv2.y() * e1 - duv1.y() * e2);
+ Eigen::Vector3f b = (duv1.x() * e2 - duv2.x() * e1);
+ float t_norm = t.norm();
+ float b_norm = b.norm();
+ t = t / t_norm;
+ b = b / b_norm;
+ Eigen::Vector2f mip_length(invDet * t_norm, invDet * b_norm);
+
+ return std::make_tuple(t, b, n, mip_length);
+}
+
+
+/**
+ * Project a point onto a triangle defined by three vertices.
+ *
+ * @param p The point to project.
+ * @param a The first vertex of the triangle.
+ * @param b The second vertex of the triangle.
+ * @param c The third vertex of the triangle.
+ * @param n The normal of the triangle.
+ *
+ * @return The projected point represented as barycentric coordinates (u, v, w) and distance from the plane.
+ */
+static Eigen::Vector4f project_onto_triangle(
+ const Eigen::Vector3f& p,
+ const Eigen::Vector3f& a,
+ const Eigen::Vector3f& b,
+ const Eigen::Vector3f& c,
+ const Eigen::Vector3f& n
+) {
+ float d = (p - a).dot(n);
+
+ Eigen::Vector3f p_proj = p - d * n;
+ Eigen::Vector3f ab = b - a;
+ Eigen::Vector3f ac = c - a;
+ Eigen::Vector3f ap = p_proj - a;
+
+ float d00 = ab.dot(ab);
+ float d01 = ab.dot(ac);
+ float d11 = ac.dot(ac);
+ float d20 = ap.dot(ab);
+ float d21 = ap.dot(ac);
+
+ float denom = d00 * d11 - d01 * d01;
+ float v = (d11 * d20 - d01 * d21) / denom;
+ float w = (d00 * d21 - d01 * d20) / denom;
+ float u = 1.0f - v - w;
+
+ return Eigen::Vector4f(u, v, w, d);
+}
+
+
+static inline int wrap_texcoord(const int& x, const int& W, const int& filter) {
+ if (filter == 0) { // REPEAT
+ return (x % W + W) % W;
+ } else if (filter == 1) { // CLAMP_TO_EDGE
+ return std::max(0, std::min(x, W - 1));
+ } else if (filter == 2) { // MIRROR_REPEAT
+ int period = 2 * W;
+ int x_mod = (x % period + period) % period;
+ return (x_mod < W) ? x_mod : (period - x_mod - 1);
+ } else {
+ // Default to repeat
+ return (x % W + W) % W;
+ }
+}
+
+
+static std::vector> build_mipmaps(
+ const uint8_t* texture,
+ const int& H, const int& W, const int& C
+) {
+ if (H != W || !is_power_of_two(H)) {
+ throw std::invalid_argument("Texture width and height must be equal and a power of two.");
+ }
+ std::vector> mipmaps;
+ const uint8_t* cur_map = texture;
+ int cur_H = H;
+ int cur_W = W;
+ int next_H = cur_H >> 1;
+ int next_W = cur_W >> 1;
+ while (next_H > 0 && next_W > 0) {
+ std::vector next_map(next_H * next_W * C);
+ for (int y = 0; y < next_H; y++) {
+ for (int x = 0; x < next_W; x++) {
+ for (int c = 0; c < C; c++) {
+ size_t sum = 0;
+ size_t xx = static_cast(x) << 1;
+ size_t yy = static_cast(y) << 1;
+ sum += cur_map[yy * static_cast(cur_W) * C + xx * C + c];
+ sum += cur_map[(yy + 1) * static_cast(cur_W) * C + xx * C + c];
+ sum += cur_map[yy * static_cast(cur_W) * C + (xx + 1) * C + c];
+ sum += cur_map[(yy + 1) * static_cast(cur_W) * C + (xx + 1) * C + c];
+ next_map[y * next_W * C + x * C + c] = static_cast(sum / 4);
+ }
+ }
+ }
+ mipmaps.push_back(std::move(next_map));
+ cur_map = mipmaps.back().data();
+ cur_H = next_H;
+ cur_W = next_W;
+ next_H = cur_H >> 1;
+ next_W = cur_W >> 1;
+ }
+ return mipmaps;
+}
+
+
+static void sample_texture(
+ const uint8_t* texture,
+ const int& H, const int& W, const int& C,
+ const float& u, const float& v,
+ const int& filter, const int& wrap,
+ float* color
+) {
+ float x = u * W;
+ float y = (1 - v) * H;
+ if (filter == 0) { // NEAREST
+ int x_int = floorf(x);
+ int y_int = floorf(y);
+ x_int = wrap_texcoord(x_int, W, wrap);
+ y_int = wrap_texcoord(y_int, H, wrap);
+ for (int c = 0; c < C; c++) {
+ color[c] = texture[y_int * W * C + x_int * C + c] / 255.0f;
+ }
+ }
+ else { // LINEAR
+ int x_low = floorf(x - 0.5);
+ int x_high = x_low + 1;
+ int y_low = floorf(y - 0.5);
+ int y_high = y_low + 1;
+ float w_x = x - x_low - 0.5;
+ float w_y = y - y_low - 0.5;
+ x_low = wrap_texcoord(x_low, W, wrap);
+ x_high = wrap_texcoord(x_high, W, wrap);
+ y_low = wrap_texcoord(y_low, H, wrap);
+ y_high = wrap_texcoord(y_high, H, wrap);
+ for (int c = 0; c < C; c++) {
+ color[c] = (1 - w_x) * (1 - w_y) * texture[y_low * W * C + x_low * C + c] +
+ w_x * (1 - w_y) * texture[y_low * W * C + x_high * C + c] +
+ (1 - w_x) * w_y * texture[y_high * W * C + x_low * C + c] +
+ w_x * w_y * texture[y_high * W * C + x_high * C + c];
+ color[c] /= 255.0f;
+ }
+ }
+}
+
+
+static void sample_texture_mipmap(
+ const uint8_t* texture,
+ const int& H, const int& W, const int& C,
+ const std::vector>& mipmaps,
+ const float& u, const float& v, const float& mip_length, const float& mipLevelOffset,
+ const int& filter, const int& wrap,
+ float* color
+) {
+ if (filter == 0) { // NEAREST
+ sample_texture(texture, H, W, C, u, v, filter, wrap, color);
+ }
+ else { // LINEAR
+ float mip_level = std::log2(mip_length * H) + mipLevelOffset;
+ if (!std::isfinite(mip_level) || mip_level <= 0 || mipmaps.empty()) {
+ sample_texture(texture, H, W, C, u, v, filter, wrap, color);
+ }
+ else if (mip_level >= mipmaps.size()) {
+ sample_texture(mipmaps[mipmaps.size() - 1].data(), H >> mipmaps.size(), W >> mipmaps.size(), C, u, v, filter, wrap, color);
+ }
+ else {
+ int lower_mip_level = std::floor(mip_level);
+ int upper_mip_level = lower_mip_level + 1;
+ float mip_frac = mip_level - lower_mip_level;
+ const uint8_t* lower_mip_ptr = lower_mip_level == 0 ? texture : mipmaps[lower_mip_level - 1].data();
+ const uint8_t* upper_mip_ptr = mipmaps[upper_mip_level - 1].data();
+ int lower_mip_H = H >> lower_mip_level;
+ int lower_mip_W = W >> lower_mip_level;
+ int upper_mip_H = H >> upper_mip_level;
+ int upper_mip_W = W >> upper_mip_level;
+ std::vector lower_mip_sample(C);
+ std::vector upper_mip_sample(C);
+ sample_texture(lower_mip_ptr, lower_mip_H, lower_mip_W, C, u, v, filter, wrap, lower_mip_sample.data());
+ sample_texture(upper_mip_ptr, upper_mip_H, upper_mip_W, C, u, v, filter, wrap, upper_mip_sample.data());
+ for (int c = 0; c < C; c++) {
+ color[c] = (1 - mip_frac) * lower_mip_sample[c] + mip_frac * upper_mip_sample[c];
+ }
+ }
+ }
+}
+
+
+static std::tuple, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector>
+voxelize_trimesh_pbr_impl(
+ const float* voxel_size,
+ const int* grid_range,
+ const int N_tri,
+ const float* vertices,
+ const float* normals,
+ const float* uvs,
+ const int* materialIds,
+ const std::vector baseColorFactor,
+ const std::vector baseColorTexture,
+ const std::vector H_bcTex, const std::vector W_bcTex,
+ const std::vector baseColorTextureFilter,
+ const std::vector baseColorTextureWrap,
+ const std::vector metallicFactor,
+ const std::vector metallicTexture,
+ const std::vector H_mtlTex, const std::vector W_mtlTex,
+ const std::vector metallicTextureFilter,
+ const std::vector metallicTextureWrap,
+ const std::vector roughnessFactor,
+ const std::vector roughnessTexture,
+ const std::vector H_rghTex, const std::vector W_rghTex,
+ const std::vector roughnessTextureFilter,
+ const std::vector roughnessTextureWrap,
+ const std::vector emissiveFactor,
+ const std::vector emissiveTexture,
+ const std::vector H_emTex, const std::vector W_emTex,
+ const std::vector emissiveTextureFilter,
+ const std::vector emissiveTextureWrap,
+ const std::vector alphaMode,
+ const std::vector alphaCutoff,
+ const std::vector alphaFactor,
+ const std::vector alphaTexture,
+ const std::vector H_aTex, const std::vector W_aTex,
+ const std::vector alphaTextureFilter,
+ const std::vector alphaTextureWrap,
+ const std::vector normalTexture,
+ const std::vector H_nTex, const std::vector W_nTex,
+ const std::vector normalTextureFilter,
+ const std::vector normalTextureWrap,
+ const float mipLevelOffset,
+ const bool timing
+) {
+ clock_t start, end;
+
+ // Common variables used in the voxelization process
+ Eigen::Vector3f delta_p(voxel_size[0], voxel_size[1], voxel_size[2]);
+ Eigen::Vector3i grid_min(grid_range[0], grid_range[1], grid_range[2]);
+ Eigen::Vector3i grid_max(grid_range[3], grid_range[4], grid_range[5]);
+
+ // Construct Mipmaps
+ start = clock();
+ std::vector>> baseColorMipmaps(baseColorTexture.size());
+ std::vector>> metallicMipmaps(metallicTexture.size());
+ std::vector>> roughnessMipmaps(roughnessTexture.size());
+ std::vector>> emissiveMipmaps(emissiveTexture.size());
+ std::vector>> alphaMipmaps(alphaTexture.size());
+ std::vector>> normalMipmaps(normalTexture.size());
+ for (size_t i = 0; i < baseColorTexture.size(); i++) {
+ if (baseColorTexture[i] != nullptr && baseColorTextureFilter[i] != 0) {
+ baseColorMipmaps[i] = build_mipmaps(baseColorTexture[i], H_bcTex[i], W_bcTex[i], 3);
+ }
+ }
+ for (size_t i = 0; i < metallicTexture.size(); i++) {
+ if (metallicTexture[i] != nullptr && metallicTextureFilter[i] != 0) {
+ metallicMipmaps[i] = build_mipmaps(metallicTexture[i], H_mtlTex[i], W_mtlTex[i], 1);
+ }
+ }
+ for (size_t i = 0; i < roughnessTexture.size(); i++) {
+ if (roughnessTexture[i] != nullptr && roughnessTextureFilter[i] != 0) {
+ roughnessMipmaps[i] = build_mipmaps(roughnessTexture[i], H_rghTex[i], W_rghTex[i], 1);
+ }
+ }
+ for (size_t i = 0; i < emissiveTexture.size(); i++) {
+ if (emissiveTexture[i] != nullptr && emissiveTextureFilter[i] != 0) {
+ emissiveMipmaps[i] = build_mipmaps(emissiveTexture[i], H_emTex[i], W_emTex[i], 3);
+ }
+ }
+ for (size_t i = 0; i < alphaTexture.size(); i++) {
+ if (alphaTexture[i] != nullptr && alphaTextureFilter[i] != 0) {
+ alphaMipmaps[i] = build_mipmaps(alphaTexture[i], H_aTex[i], W_aTex[i], 1);
+ }
+ }
+ for (size_t i = 0; i < normalTexture.size(); i++) {
+ if (normalTexture[i] != nullptr && normalTextureFilter[i] != 0) {
+ normalMipmaps[i] = build_mipmaps(normalTexture[i], H_nTex[i], W_nTex[i], 3);
+ }
+ }
+ end = clock();
+ if (timing) std::cout << "Mipmaps construction took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+
+ // Buffers
+ std::unordered_map hash_table;
+ std::vector coords;
+ std::vector buf_weights;
+ std::vector buf_baseColors;
+ std::vector buf_metallics;
+ std::vector buf_roughnesses;
+ std::vector buf_emissives;
+ std::vector buf_alphas;
+ std::vector buf_normals;
+
+ // Enumerate all triangles
+ start = clock();
+ for (size_t tid = 0; tid < N_tri; tid++) {
+ // COMPUTE COMMON TRIANGLE PROPERTIES
+ // Move vertices to origin using bbox
+ size_t ptr = tid * 9;
+ Eigen::Vector3f v0(vertices[ptr], vertices[ptr + 1], vertices[ptr + 2]);
+ Eigen::Vector3f v1(vertices[ptr + 3], vertices[ptr + 4], vertices[ptr + 5]);
+ Eigen::Vector3f v2(vertices[ptr + 6], vertices[ptr + 7], vertices[ptr + 8]);
+ // Normals
+ Eigen::Vector3f n0(normals[ptr], normals[ptr + 1], normals[ptr + 2]);
+ Eigen::Vector3f n1(normals[ptr + 3], normals[ptr + 4], normals[ptr + 5]);
+ Eigen::Vector3f n2(normals[ptr + 6], normals[ptr + 7], normals[ptr + 8]);
+ // UV vectors
+ ptr = tid * 6;
+ Eigen::Vector2f uv0(uvs[ptr], uvs[ptr + 1]);
+ Eigen::Vector2f uv1(uvs[ptr + 2], uvs[ptr + 3]);
+ Eigen::Vector2f uv2(uvs[ptr + 4], uvs[ptr + 5]);
+ // TBN
+ auto tbn = compute_TBN(v0, v1, v2, uv0, uv1, uv2);
+ Eigen::Vector3f t = std::get<0>(tbn);
+ Eigen::Vector3f b = std::get<1>(tbn);
+ Eigen::Vector3f n = std::get<2>(tbn);
+ Eigen::Vector2f v_mip_length = std::get<3>(tbn);
+ float mip_length = delta_p.maxCoeff() / std::sqrt(v_mip_length.x() * v_mip_length.y());
+ // Material ID
+ int mid = materialIds[tid];
+
+ // Find intersected voxel for each triangle
+ std::unordered_set intersected_voxels;
+ // Scan-line algorithm to find intersections with the voxel grid from three directions
+ /*
+ t0
+ | \
+ | t1
+ | /
+ t2
+ */
+ auto scan_line_fill = [&] (const int ax2) {
+ int ax0 = (ax2 + 1) % 3;
+ int ax1 = (ax2 + 2) % 3;
+
+ // Canonical question
+ std::array t = {
+ Eigen::Vector3d(v0[ax0], v0[ax1], v0[ax2]),
+ Eigen::Vector3d(v1[ax0], v1[ax1], v1[ax2]),
+ Eigen::Vector3d(v2[ax0], v2[ax1], v2[ax2])
+ };
+ std::sort(t.begin(), t.end(), [](const Eigen::Vector3d& a, const Eigen::Vector3d& b) { return a.y() < b.y(); });
+
+ // Scan-line algorithm
+ int start = std::clamp(int(t[0].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+ int mid = std::clamp(int(t[1].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+ int end = std::clamp(int(t[2].y() / voxel_size[ax1]), grid_min[ax1], grid_max[ax1] - 1);
+
+ auto scan_line_half = [&] (const int row_start, const int row_end, const Eigen::Vector3d t0, const Eigen::Vector3d t1, const Eigen::Vector3d t2) {
+ /*
+ t0
+ | \
+ t3-t4
+ | \
+ t1---t2
+ */
+ for (int y_idx = row_start; y_idx < row_end; ++y_idx) {
+ double y = (y_idx + 1) * voxel_size[ax1];
+ Eigen::Vector2d t3 = lerp(t0.y(), t1.y(), y, Eigen::Vector2d(t0.x(), t0.z()), Eigen::Vector2d(t1.x(), t1.z()));
+ Eigen::Vector2d t4 = lerp(t0.y(), t2.y(), y, Eigen::Vector2d(t0.x(), t0.z()), Eigen::Vector2d(t2.x(), t2.z()));
+ if (t3.x() > t4.x()) std::swap(t3, t4);
+ int line_start = std::clamp(int(t3.x() / voxel_size[ax0]), grid_min[ax0], grid_max[ax0] - 1);
+ int line_end = std::clamp(int(t4.x() / voxel_size[ax0]), grid_min[ax0], grid_max[ax0] - 1);
+ for (int x_idx = line_start; x_idx < line_end; ++x_idx) {
+ double x = (x_idx + 1) * voxel_size[ax0];
+ double z = lerp(t3.x(), t4.x(), x, t3.y(), t4.y());
+ int z_idx = int(z / voxel_size[ax2]);
+ if (z_idx >= grid_min[ax2] && z_idx < grid_max[ax2]) {
+ // For 4-connected voxels
+ for (int dx = 0; dx < 2; ++dx) {
+ for (int dy = 0; dy < 2; ++dy) {
+ VoxelCoord coord;
+ coord[ax0] = x_idx + dx; coord[ax1] = y_idx + dy; coord[ax2] = z_idx;
+ intersected_voxels.insert(coord);
+ }
+ }
+ }
+ }
+ }
+ };
+ scan_line_half(start, mid, t[0], t[1], t[2]);
+ scan_line_half(mid, end, t[2], t[1], t[0]);
+ };
+ scan_line_fill(0);
+ scan_line_fill(1);
+ scan_line_fill(2);
+
+ // For all intersected voxels, ample texture and write to voxel grid
+ for (auto voxel : intersected_voxels) {
+ int x = voxel.x;
+ int y = voxel.y;
+ int z = voxel.z;
+
+ // Compute barycentric coordinates and weight
+ Eigen::Vector4f barycentric = project_onto_triangle(
+ Eigen::Vector3f((x + 0.5f) * delta_p.x(), (y + 0.5f) * delta_p.y(), (z + 0.5f) * delta_p.z()),
+ v0, v1, v2, n
+ );
+ Eigen::Vector2f uv = {
+ barycentric.x() * uv0.x() + barycentric.y() * uv1.x() + barycentric.z() * uv2.x(),
+ barycentric.x() * uv0.y() + barycentric.y() * uv1.y() + barycentric.z() * uv2.y()
+ };
+ Eigen::Vector3f int_n = {
+ barycentric.x() * n0.x() + barycentric.y() * n1.x() + barycentric.z() * n2.x(),
+ barycentric.x() * n0.y() + barycentric.y() * n1.y() + barycentric.z() * n2.y(),
+ barycentric.x() * n0.z() + barycentric.y() * n1.z() + barycentric.z() * n2.z()
+ };
+ float weight = 1 - barycentric.w();
+
+ /// base color
+ float baseColor[3] = {1, 1, 1};
+ if (baseColorTexture[mid]) {
+ sample_texture_mipmap(
+ baseColorTexture[mid],
+ H_bcTex[mid], W_bcTex[mid], 3,
+ baseColorMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ baseColorTextureFilter[mid], baseColorTextureWrap[mid],
+ baseColor
+ );
+ }
+ baseColor[0] *= baseColorFactor[mid][0];
+ baseColor[1] *= baseColorFactor[mid][1];
+ baseColor[2] *= baseColorFactor[mid][2];
+
+ /// metallic
+ float metallic = 1.0f;
+ if (metallicTexture[mid]) {
+ sample_texture_mipmap(
+ metallicTexture[mid],
+ H_mtlTex[mid], W_mtlTex[mid], 1,
+ metallicMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ metallicTextureFilter[mid], metallicTextureWrap[mid],
+ &metallic
+ );
+ }
+ metallic *= metallicFactor[mid];
+
+ /// roughness
+ float roughness = 1.0f;
+ if (roughnessTexture[mid]) {
+ sample_texture_mipmap(
+ roughnessTexture[mid],
+ H_rghTex[mid], W_rghTex[mid], 1,
+ roughnessMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ roughnessTextureFilter[mid], roughnessTextureWrap[mid],
+ &roughness
+ );
+ }
+ roughness *= roughnessFactor[mid];
+
+ /// emissive
+ float emissive[3] = {1, 1, 1};
+ if (emissiveTexture[mid]) {
+ sample_texture_mipmap(
+ emissiveTexture[mid],
+ H_emTex[mid], W_emTex[mid], 3,
+ roughnessMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ emissiveTextureFilter[mid], emissiveTextureWrap[mid],
+ emissive
+ );
+ }
+ emissive[0] *= emissiveFactor[mid][0];
+ emissive[1] *= emissiveFactor[mid][1];
+ emissive[2] *= emissiveFactor[mid][2];
+
+ /// alpha
+ float alpha = 1.0f;
+ if (alphaMode[mid] != 0) {
+ if (alphaTexture[mid]) {
+ sample_texture_mipmap(
+ alphaTexture[mid],
+ H_aTex[mid], W_aTex[mid], 1,
+ alphaMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ alphaTextureFilter[mid], alphaTextureWrap[mid],
+ &alpha
+ );
+ }
+ alpha *= alphaFactor[mid];
+ if (alphaMode[mid] == 1) { // MASK
+ alpha = alpha < alphaCutoff[mid] ? 0.0f : 1.0f;
+ }
+ }
+
+ /// normal
+ float normal[3] = {int_n.x(), int_n.y(), int_n.z()};
+ if (normalTexture[mid]) {
+ sample_texture_mipmap(
+ normalTexture[mid],
+ H_nTex[mid], W_nTex[mid], 3,
+ normalMipmaps[mid],
+ uv.x(), uv.y(), mip_length, mipLevelOffset,
+ normalTextureFilter[mid], normalTextureWrap[mid],
+ normal
+ );
+ normal[0] = normal[0] * 2 - 1;
+ normal[1] = normal[1] * 2 - 1;
+ normal[2] = normal[2] * 2 - 1;
+ Eigen::Vector3f _n = (normal[0] * t + normal[1] * b + normal[2] * int_n).normalized();
+ normal[0] = _n.x();
+ normal[1] = _n.y();
+ normal[2] = _n.z();
+ }
+
+ // Write to voxel grid
+ auto coord = VoxelCoord{x-grid_min.x(), y-grid_min.y(), z-grid_min.z()};
+ auto kv = hash_table.find(coord);
+ if (kv == hash_table.end()) {
+ hash_table[coord] = coords.size();
+ coords.push_back({coord.x, coord.y, coord.z});
+ buf_weights.push_back(weight);
+ buf_baseColors.push_back(Eigen::Vector3f(baseColor[0], baseColor[1], baseColor[2]) * weight);
+ buf_metallics.push_back(metallic * weight);
+ buf_roughnesses.push_back(roughness * weight);
+ buf_emissives.push_back(Eigen::Vector3f(emissive[0], emissive[1], emissive[2]) * weight);
+ buf_alphas.push_back(alpha * weight);
+ buf_normals.push_back(Eigen::Vector3f(normal[0], normal[1], normal[2]) * weight);
+ }
+ else {
+ auto i = kv->second;
+ buf_weights[i] += weight;
+ buf_baseColors[i] += Eigen::Vector3f(baseColor[0], baseColor[1], baseColor[2]) * weight;
+ buf_metallics[i] += metallic * weight;
+ buf_roughnesses[i] += roughness * weight;
+ buf_emissives[i] += Eigen::Vector3f(emissive[0], emissive[1], emissive[2]) * weight;
+ buf_alphas[i] += alpha * weight;
+ buf_normals[i] += Eigen::Vector3f(normal[0], normal[1], normal[2]) * weight;
+ }
+ }
+ }
+ end = clock();
+ if (timing) std::cout << "Voxelization took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+
+ // Normalize buffers
+ start = clock();
+ std::vector out_coord(coords.size() * 3);
+ std::vector out_baseColor(coords.size() * 3);
+ std::vector out_metallic(coords.size());
+ std::vector out_roughness(coords.size());
+ std::vector out_emissive(coords.size() * 3);
+ std::vector out_alpha(coords.size());
+ std::vector out_normal(coords.size() * 3);
+ for (int i = 0; i < coords.size(); i++) {
+ out_coord[i * 3 + 0] = coords[i].x;
+ out_coord[i * 3 + 1] = coords[i].y;
+ out_coord[i * 3 + 2] = coords[i].z;
+ out_baseColor[i * 3 + 0] = buf_baseColors[i].x() / buf_weights[i];
+ out_baseColor[i * 3 + 1] = buf_baseColors[i].y() / buf_weights[i];
+ out_baseColor[i * 3 + 2] = buf_baseColors[i].z() / buf_weights[i];
+ out_metallic[i] = buf_metallics[i] / buf_weights[i];
+ out_roughness[i] = buf_roughnesses[i] / buf_weights[i];
+ out_emissive[i * 3 + 0] = buf_emissives[i].x() / buf_weights[i];
+ out_emissive[i * 3 + 1] = buf_emissives[i].y() / buf_weights[i];
+ out_emissive[i * 3 + 2] = buf_emissives[i].z() / buf_weights[i];
+ out_alpha[i] = buf_alphas[i] / buf_weights[i];
+ out_normal[i * 3 + 0] = buf_normals[i].x() / buf_weights[i];
+ out_normal[i * 3 + 1] = buf_normals[i].y() / buf_weights[i];
+ out_normal[i * 3 + 2] = buf_normals[i].z() / buf_weights[i];
+ }
+ end = clock();
+ if (timing) std::cout << "Normalization took " << double(end - start) / CLOCKS_PER_SEC << " seconds." << std::endl;
+
+ return std::make_tuple(
+ std::move(out_coord),
+ std::move(out_baseColor),
+ std::move(out_metallic),
+ std::move(out_roughness),
+ std::move(out_emissive),
+ std::move(out_alpha),
+ std::move(out_normal)
+ );
+}
+
+
+std::tuple
+textured_mesh_to_volumetric_attr_cpu(
+ const torch::Tensor& voxel_size,
+ const torch::Tensor& grid_range,
+ const torch::Tensor& vertices,
+ const torch::Tensor& normals,
+ const torch::Tensor& uvs,
+ const torch::Tensor& materialIds,
+ const std::vector& baseColorFactor,
+ const std::vector& baseColorTexture,
+ const std::vector& baseColorTextureFilter,
+ const std::vector& baseColorTextureWrap,
+ const std::vector& metallicFactor,
+ const std::vector& metallicTexture,
+ const std::vector& metallicTextureFilter,
+ const std::vector& metallicTextureWrap,
+ const std::vector& roughnessFactor,
+ const std::vector& roughnessTexture,
+ const std::vector& roughnessTextureFilter,
+ const std::vector& roughnessTextureWrap,
+ const std::vector& emissiveFactor,
+ const std::vector& emissiveTexture,
+ const std::vector& emissiveTextureFilter,
+ const std::vector& emissiveTextureWrap,
+ const std::vector& alphaMode,
+ const std::vector& alphaCutoff,
+ const std::vector& alphaFactor,
+ const std::vector& alphaTexture,
+ const std::vector& alphaTextureFilter,
+ const std::vector& alphaTextureWrap,
+ const std::vector& normalTexture,
+ const std::vector& normalTextureFilter,
+ const std::vector& normalTextureWrap,
+ const float mipLevelOffset,
+ const bool timing
+) {
+ auto N_mat = baseColorFactor.size();
+ int N_tri = vertices.size(0);
+
+ // Get the size of the input tensors
+ std::vector baseColorFactor_ptrs(N_mat);
+ std::vector baseColorTexture_ptrs(N_mat);
+ std::vector H_bcTex(N_mat), W_bcTex(N_mat);
+ std::vector metallicFactor_vec(N_mat);
+ std::vector metallicTexture_ptrs(N_mat);
+ std::vector H_mtlTex(N_mat), W_mtlTex(N_mat);
+ std::vector roughnessFactor_vec(N_mat);
+ std::vector roughnessTexture_ptrs(N_mat);
+ std::vector H_rghTex(N_mat), W_rghTex(N_mat);
+ std::vector emissiveFactor_ptrs(N_mat);
+ std::vector emissiveTexture_ptrs(N_mat);
+ std::vector H_emTex(N_mat), W_emTex(N_mat);
+ std::vector alphaMode_vec(N_mat);
+ std::vector alphaCutoff_vec(N_mat);
+ std::vector alphaFactor_vec(N_mat);
+ std::vector alphaTexture_ptrs(N_mat);
+ std::vector H_aTex(N_mat), W_aTex(N_mat);
+ std::vector normalTexture_ptrs(N_mat);
+ std::vector H_nTex(N_mat), W_nTex(N_mat);
+
+ for (int i = 0; i < N_mat; ++i) {
+ baseColorFactor_ptrs[i] = baseColorFactor[i].contiguous().data_ptr();
+ if (baseColorTexture[i].numel() > 0) {
+ baseColorTexture_ptrs[i] = baseColorTexture[i].contiguous().data_ptr();
+ H_bcTex[i] = baseColorTexture[i].size(0);
+ W_bcTex[i] = baseColorTexture[i].size(1);
+ }
+ else {
+ baseColorTexture_ptrs[i] = nullptr;
+ H_bcTex[i] = 0;
+ W_bcTex[i] = 0;
+ }
+ metallicFactor_vec[i] = metallicFactor[i];
+ if (metallicTexture[i].numel() > 0) {
+ metallicTexture_ptrs[i] = metallicTexture[i].contiguous().data_ptr();
+ H_mtlTex[i] = metallicTexture[i].size(0);
+ W_mtlTex[i] = metallicTexture[i].size(1);
+ }
+ else {
+ metallicTexture_ptrs[i] = nullptr;
+ H_mtlTex[i] = 0;
+ W_mtlTex[i] = 0;
+ }
+ roughnessFactor_vec[i] = roughnessFactor[i];
+ if (roughnessTexture[i].numel() > 0) {
+ roughnessTexture_ptrs[i] = roughnessTexture[i].contiguous().data_ptr();
+ H_rghTex[i] = roughnessTexture[i].size(0);
+ W_rghTex[i] = roughnessTexture[i].size(1);
+ }
+ else {
+ roughnessTexture_ptrs[i] = nullptr;
+ H_rghTex[i] = 0;
+ W_rghTex[i] = 0;
+ }
+ emissiveFactor_ptrs[i] = emissiveFactor[i].contiguous().data_ptr();
+ if (emissiveTexture[i].numel() > 0) {
+ emissiveTexture_ptrs[i] = emissiveTexture[i].contiguous().data_ptr();
+ H_emTex[i] = emissiveTexture[i].size(0);
+ W_emTex[i] = emissiveTexture[i].size(1);
+ }
+ else {
+ emissiveTexture_ptrs[i] = nullptr;
+ H_emTex[i] = 0;
+ W_emTex[i] = 0;
+ }
+ alphaMode_vec[i] = alphaMode[i];
+ alphaCutoff_vec[i] = alphaCutoff[i];
+ alphaFactor_vec[i] = alphaFactor[i];
+ if (alphaTexture[i].numel() > 0) {
+ alphaTexture_ptrs[i] = alphaTexture[i].contiguous().data_ptr();
+ H_aTex[i] = alphaTexture[i].size(0);
+ W_aTex[i] = alphaTexture[i].size(1);
+ }
+ else {
+ alphaTexture_ptrs[i] = nullptr;
+ H_aTex[i] = 0;
+ W_aTex[i] = 0;
+ }
+ if (normalTexture[i].numel() > 0) {
+ normalTexture_ptrs[i] = normalTexture[i].contiguous().data_ptr();
+ H_nTex[i] = normalTexture[i].size(0);
+ W_nTex[i] = normalTexture[i].size(1);
+ }
+ else {
+ normalTexture_ptrs[i] = nullptr;
+ H_nTex[i] = 0;
+ W_nTex[i] = 0;
+ }
+ }
+
+ auto outputs = voxelize_trimesh_pbr_impl(
+ voxel_size.contiguous().data_ptr(),
+ grid_range.contiguous().data_ptr(),
+ N_tri,
+ vertices.contiguous().data_ptr(),
+ normals.contiguous().data_ptr(),
+ uvs.contiguous().data_ptr(),
+ materialIds.contiguous().data_ptr(),
+ baseColorFactor_ptrs,
+ baseColorTexture_ptrs,
+ H_bcTex, W_bcTex,
+ baseColorTextureFilter, baseColorTextureWrap,
+ metallicFactor_vec,
+ metallicTexture_ptrs,
+ H_mtlTex, W_mtlTex,
+ metallicTextureFilter, metallicTextureWrap,
+ roughnessFactor_vec,
+ roughnessTexture_ptrs,
+ H_rghTex, W_rghTex,
+ roughnessTextureFilter, roughnessTextureWrap,
+ emissiveFactor_ptrs,
+ emissiveTexture_ptrs,
+ H_emTex, W_emTex,
+ emissiveTextureFilter, emissiveTextureWrap,
+ alphaMode_vec,
+ alphaCutoff_vec,
+ alphaFactor_vec,
+ alphaTexture_ptrs,
+ H_aTex, W_aTex,
+ alphaTextureFilter, alphaTextureWrap,
+ normalTexture_ptrs,
+ H_nTex, W_nTex,
+ normalTextureFilter, normalTextureWrap,
+ mipLevelOffset,
+ timing
+ );
+
+ std::vector coords_vec = std::get<0>(outputs);
+ std::vector baseColors_vec = std::get<1>(outputs);
+ std::vector metallics_vec = std::get<2>(outputs);
+ std::vector roughnesses_vec = std::get<3>(outputs);
+ std::vector emissives_vec = std::get<4>(outputs);
+ std::vector alphas_vec = std::get<5>(outputs);
+ std::vector normals_vec = std::get<6>(outputs);
+
+ // Create output tensors
+ auto out_coords = torch::from_blob(coords_vec.data(), {static_cast(coords_vec.size() / 3), 3}, torch::kInt32).clone();
+ auto out_baseColors = torch::from_blob(baseColors_vec.data(), {static_cast(baseColors_vec.size() / 3), 3}, torch::kFloat32).clone();
+ auto out_metallics = torch::from_blob(metallics_vec.data(), {static_cast(metallics_vec.size())}, torch::kFloat32).clone();
+ auto out_roughnesses = torch::from_blob(roughnesses_vec.data(), {static_cast(roughnesses_vec.size())}, torch::kFloat32).clone();
+ auto out_emissives = torch::from_blob(emissives_vec.data(), {static_cast(emissives_vec.size() / 3), 3}, torch::kFloat32).clone();
+ auto out_alphas = torch::from_blob(alphas_vec.data(), {static_cast