Jorge Condor1,2, Nicolas Moenne-Loccoz1, Merlin Nimier-David1, Piotr Didyk2, Zan Gojcic1, Qi Wu1
1NVIDIA 2Università della Svizzera italiana, Lugano, Switzerland
[Paper] [Project Page] [Video]
NHT has been accepted as an Oral at ECCV'26! See you in Malmo.
To celebrate, we have added fully-fused rasterize+MLP kernels (now the default).
NHT rendering and training now run on new fully-fused CUDA kernels that evaluate the deferred MLP inline in the rasterizer (warp-cooperative WMMA, no intermediate feature-buffer round trip and no separate tcnn launches). The backward is fused too: a single kernel backpropagates dL/dRGB and dL/dalpha to the splat parameters and the MLP weights, with per-block weight-gradient accumulation in shared memory.
Measured on an RTX 4090 (garden, 1M primitives, 1667×1080 (~1080p), feature_dim=48, 128×3 MLP; training figures over 3,000 iterations, inference over 300):
| Metric | Two-stage (raster + tcnn) | Fused | Improvement |
|---|---|---|---|
| Inference time | 5.47 ms (183 FPS) | 4.35 ms (230 FPS) | 1.26× |
| Inference memory | 2.53 GB | 1.88 GB | -26% |
| Training step (fwd+bwd) | 57.78 ms (17.3 it/s) | 24.57 ms (40.7 it/s) | 2.35× |
| Training memory | 4.53 GB | 2.56 GB | -44% |
Expected gains are scene, resolution and mlp size-dependent (roughly 1.1–1.9× for inference and 1.2–2.4× for training across the MipNeRF-360 scenes). Memory usage is drastically reduced too: fusing the rasterizer and MLP skips the intermediate feature buffer and tcnn's separate allocator. The new kernel also achieves slightly better quality. The trainer (--nht_fused, on by default), the viewers, and benchmarks/benchmark_nht.py all use the fused path automatically when the shader config supports it, and fall back to the two-stage tcnn path otherwise. AOV mode in particular stays on the tcnn backend (see AOV Mode). The comparison above can be reproduced with python benchmarks/benchmark_nht.py --train --results_dir <results> --scene_dir <data> (timing) and python scripts/profile_nht_memory.py --cases fused_infer unfused_infer fused_train unfused_train --scales <scale> --iters <n> --ckpt <ckpt> --data-dir <scene> (memory).
Plus, we have rebased on current gsplat, bringing a number of new features like new camera models. Check https://github.com/nerfstudio-project/gsplat for more.
Primitive-based methods such as 3D Gaussian Splatting have recently become the state-of-the-art for novel-view synthesis and related reconstruction tasks. Compared to neural fields, these representations are more flexible, adaptive, and scale better to large scenes. However, the limited expressivity of individual primitives makes modeling high-frequency detail challenging.
We introduce Neural Harmonic Textures, a neural representation approach that anchors latent feature vectors on a virtual scaffold surrounding each primitive. These features are interpolated within the primitive at ray intersection points. Inspired by Fourier analysis, we apply periodic activations to the interpolated features, turning alpha blending into a weighted sum of harmonic components. The resulting signal is then decoded in a single deferred pass using a small neural network, significantly reducing computational cost.
Neural Harmonic Textures yield state-of-the-art results in real-time novel view synthesis while bridging the gap between primitive- and neural-field-based reconstruction. It can be interpreted as a Lagrangian alternative to positional encoding in neural fields.
| Component | Requirement |
|---|---|
| Python | >= 3.8 |
| PyTorch | >= 2.0 with CUDA support |
| CUDA | >= 12.0 (required for tiny-cuda-nn cooperative vectors) |
| GPU | Ada Lovelace or newer recommended (RTX 4090, A6000 Ada, L40, etc.); Ampere GPUs (A100, RTX 3090) also work |
Requires uv (will auto-download Python 3.11 if needed).
# Clone with submodule
git clone --recursive https://github.com/nv-tlabs/neural-harmonic-textures.git
# Run the setup script (Linux)
cd neural-harmonic-textures
bash setup.sh
source .venv/bin/activate# Windows (PowerShell)
git clone --recursive https://github.com/nv-tlabs/neural-harmonic-textures.git
cd neural-harmonic-textures
.\setup.ps1
.\.venv\Scripts\Activate.ps1Windows note — Visual Studio Build Tools required: Building the CUDA extensions (gsplat, fused-ssim, tiny-cuda-nn) requires the MSVC C++ compiler (
cl.exe). Install Visual Studio 2022 with the "Desktop development with C++" workload.
setup.ps1will automatically try to locate your Visual Studio installation and set up the build environment. If auto-detection fails, you will need to load the VS environment manually before running setup. Either:
Open "x64 Native Tools Command Prompt for VS 2022" from the Start Menu, then run
powershelland.\setup.ps1, orSource the VS environment in your existing PowerShell session before running setup:
# Adjust the path for your VS edition (Enterprise / Professional / Community) & "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64 .\setup.ps1
git clone --recursive https://github.com/nv-tlabs/neural-harmonic-textures.git
cd neural-harmonic-textures
uv venv --python 3.11 .venv && source .venv/bin/activate
# Build dependencies + PyTorch (adjust --index-url for your CUDA version)
uv pip install "setuptools==78.1.1" wheel ninja numpy rich
uv pip install torch==2.9.1 torchvision==0.24.1 --index-url https://download.pytorch.org/whl/cu126
# Install gsplat from submodule (needs torch at build time)
uv pip install --no-build-isolation -e ./gsplat
# Install the aov helpers package and remaining dependencies
uv pip install --no-build-isolation -e .
uv pip install --no-build-isolation -r gsplat/examples/requirements.txtDownload the MipNeRF 360 dataset and extract it under data/:
bash scripts/download_data.shYour directory layout should look like:
data/
mipnerf360/{garden,bicycle,stump,bonsai,counter,kitchen,room,...}
# Train on the garden scene (MipNeRF 360, outdoor, factor 4, 1M primitives)
bash scripts/train.sh
# Train on kitchen (indoor, factor 2)
bash scripts/train.sh --scene kitchen --data_factor 2
# Train with 2M primitives
bash scripts/train.sh --scene bonsai --data_factor 2 --cap_max 2000000# Windows
.\scripts\train.ps1
.\scripts\train.ps1 -Scene kitchen -DataFactor 2# Launch the interactive viewer
bash scripts/view.sh --ckpt results/nht_mcmc_1000000/garden/ckpts/ckpt_29999_rank0.pt# Windows
.\scripts\view.ps1 -Ckpt results\nht_mcmc_1000000\garden\ckpts\ckpt_29999_rank0.ptThe viewer starts a viser server. Open http://localhost:8080 in your browser.
Viewer render modes (selectable in the UI dropdown):
| Mode | Description | Fused kernel |
|---|---|---|
rgb |
Final decoded RGB color (features -> MLP -> color) | yes |
alpha |
Accumulated opacity / transmittance map | yes |
depth(accumulated) |
Accumulated z-depth (alpha-weighted sum of depths) | no |
depth(expected) |
Expected depth (accumulated depth normalized by alpha) | no |
normal |
Rendered surface normals | no |
The viewer runs on the fused kernel by default. The fused rasterize+MLP kernel emits RGB and alpha through a pinhole camera only, so the viewer greys out what it cannot render — the depth and normal modes are removed from the dropdown, and the Camera, Ortho Scale, Anti-Aliasing and Radius Clip controls are disabled, with an on-screen note explaining why. Near/far and 2D-epsilon stay live.
To use any of them, relaunch the viewer on the two-stage rasterize + tcnn path:
bash scripts/view.sh --ckpt <ckpt.pt> --no_fused.\scripts\view.ps1 -Ckpt <ckpt.pt> -NoFusedThe trainer's embedded viewer follows the training config: it previews through the fused path (and applies the same restrictions) unless you train with
--no-nht_fused.
# Evaluate quality metrics + runtime benchmark
bash scripts/eval.sh --ckpt results/nht_mcmc_1000000/garden/ckpts/ckpt_29999_rank0.pt \
--scene garden --scene_dir data/mipnerf360 --data_factor 4
# Skip runtime benchmark
bash scripts/eval.sh --ckpt results/nht_mcmc_1000000/garden/ckpts/ckpt_29999_rank0.pt --skip_runtimeImportant: All benchmarks in this repository evaluate LPIPS using the VGG backbone with normalized inputs (
--lpips_net vgg --lpips_normalize, both defaults). This differs from INRIA 3DGS and upstream gsplat, which use VGG withnormalize=False(technically incorrect, as the VGG network expects inputs in ([-1, 1]), not ([0, 1])).All numbers reported in the NHT paper use VGG normalized. To reproduce those numbers, use the default settings (no extra flags needed).
To switch to INRIA-style VGG unnormalized evaluation (e.g. for side-by-side comparison with other codebases), disable normalization:
# Bash — disable normalization via environment variable LPIPS_NORMALIZE=0 bash benchmarks/nht/benchmark_nht.sh# PowerShell — disable normalization via switch .\benchmarks\nht\benchmark_nht.ps1 -NoLpipsNormalizeOr pass
--no-lpips_normalizedirectly to the trainer:python gsplat/examples/simple_trainer_nht.py default --lpips_net vgg --no-lpips_normalize ...
Trainer flag Default Description --lpips_netvggLPIPS backbone: vggoralex--lpips_normalize/--no-lpips_normalizeTrueNormalize inputs to ([-1, 1]). Set --no-lpips_normalizeto match INRIA 3DGS
From the repository root (with the environment from Installation and a CUDA-visible GPU):
| What | Command |
|---|---|
| Paper Table 2 (unified MCMC) | bash benchmarks/nht/benchmark_nht.sh |
| Paper Table 1 (split strategy) | bash benchmarks/nht/benchmark_nht_split.sh |
| Paper Table 7 (high primitive count) | bash benchmarks/nht/benchmark_nht_high.sh |
| AOV (LSEG / DINOv3/ RGB2X) | bash benchmarks/nht/benchmark_nht_aov.sh |
| Basic MipNeRF360 trainer | bash benchmarks/basic_nht.sh |
Standalone runtime timing (fused by default; --unfused for the two-stage breakdown) |
python benchmarks/benchmark_nht.py --ckpt <ckpt.pt> --data_dir <scene_dir> --data_factor <N> |
| Fused vs tcnn comparison (forward + training step) | python benchmarks/benchmark_nht.py --train --ckpt <ckpt.pt> --data_dir <scene_dir> --data_factor <N> |
On Windows, use the matching scripts under benchmarks/nht/ (for example .\benchmarks\nht\benchmark_nht.ps1).
Useful environment variables (bash benchmarks under benchmarks/nht/):
| Variable | Default | Role |
|---|---|---|
GPU |
0 |
CUDA_VISIBLE_DEVICES for training and timing |
DATA_ROOT |
<repo>/data |
Root folder used to resolve scene paths (see below) |
SCENE_LIST |
(all paper scenes) | Space-separated subset, e.g. SCENE_LIST="garden bonsai" |
RESULT_BASE |
varies per script | Where checkpoints and stats are written |
LPIPS_NET |
vgg |
LPIPS backbone: vgg or alex |
LPIPS_NORMALIZE |
1 |
Set to 0 for INRIA-style VGG unnormalized (--no-lpips_normalize) |
CAP_MAX, MAX_STEPS, FEATURE_DIM |
script defaults | Training budget overrides for Table 2-style runs |
Flags such as --metrics_only (split / high / AOV) and --runtime_only (high) skip training or metric collection when you already have outputs. For eval plus timing on one checkpoint, use scripts/eval.sh / scripts/eval.ps1 (see Evaluation).
benchmark_nht.py batch mode (one timing run per scene under a results tree):
python benchmarks/benchmark_nht.py --results_dir results/benchmark_nht --scene_dir dataScene names are taken from subdirectories of --results_dir. With --scene_dir, each scene path is resolved by trying <scene_dir>/<scene>, then <scene_dir>/mipnerf360/<scene>, tandt_db/tandt, tandt_db/db, and a few other dataset layouts. Use --collect_only to aggregate existing stats/timing.json files without re-running GPU timing.
Datasets are not shipped with the repo. By convention they live under <repo>/data/. The trainer expects a single scene directory in COLMAP / MipNeRF-360 style (images, poses, sparse reconstruction), passed as --data_dir.
Repo helper scripts (scripts/train.sh, scripts/eval.sh, scripts/view.sh):
--scene_dir— parent directory containing one folder per scene name.--scene— scene folder name; the full path isscene_dir/scene.
Defaults use data/mipnerf360 and garden. PowerShell equivalents use -SceneDir and -Scene.
Paper benchmark shell scripts (benchmarks/nht/*.sh) set DATA_ROOT to the directory that contains the dataset trees. For each scene they search in order, for example:
- MipNeRF 360:
DATA_ROOT/mipnerf360/<scene>, thenDATA_ROOT/360_v2/<scene>, thenDATA_ROOT/<scene>. - Tanks & Temples:
DATA_ROOT/tandt_db/tandt/<scene>orDATA_ROOT/<scene>. - Deep Blending:
DATA_ROOT/tandt_db/db/<scene>orDATA_ROOT/<scene>.
To use a different disk location, either symlink that layout under data/ or set DATA_ROOT to the parent of mipnerf360/ / tandt_db/ (or to a flat folder of scene directories).
Direct Python (see gsplat/examples/simple_trainer_nht.py): pass --data_dir /path/to/one/scene and --data_factor explicitly; no separate scene_dir argument in the trainer itself.
The paper evaluates on three standard benchmarks: MipNeRF 360, Tanks & Temples, and Deep Blending. Place datasets under data/:
data/
mipnerf360/{garden,bicycle,stump,...}
tandt_db/tandt/{train,truck}
tandt_db/db/{drjohnson,playroom}
bash benchmarks/nht/benchmark_nht.sh
# If you want to override defaults
GPU=1 CAP_MAX=2000000 bash benchmarks/nht/benchmark_nht.sh
SCENE_LIST="bonsai garden truck" bash benchmarks/nht/benchmark_nht.shMeasured results (RTX A6000 Ada):
| Method (w/ MCMC) | M360 PSNR | M360 SSIM | M360 LPIPS | T&T PSNR | T&T SSIM | T&T LPIPS | DB PSNR | DB SSIM | DB LPIPS |
|---|---|---|---|---|---|---|---|---|---|
| 3DGS + SH | 27.94 | 0.829 | 0.246 | 24.25 | 0.861 | 0.188 | 29.98 | 0.912 | 0.317 |
| 3DGUT + SH | 27.93 | 0.828 | 0.247 | 23.99 | 0.859 | 0.192 | 30.21 | 0.913 | 0.318 |
| 3DGUT + NHT (Ours) | 28.63 | 0.834 | 0.233 | 24.79 | 0.875 | 0.169 | 30.88 | 0.918 | 0.311 |
| Dataset Group | Primitives | Steps | Ray Encoding |
|---|---|---|---|
| M360 Outdoor | 5M | 25k | per-pixel ray |
| M360 Indoor | 2M | 45k | center ray |
| Tanks & Temples | 2.5M | 40k | center ray |
| Deep Blending | 2M | 30k | center ray |
Learning rates and other hyperparameters are kept at their defaults across all datasets for consistency.
bash benchmarks/nht/benchmark_nht_split.sh
# Collect results only (skip training)
bash benchmarks/nht/benchmark_nht_split.sh --metrics_onlyMeasured results:
| Dataset | PSNR | SSIM | LPIPS |
|---|---|---|---|
| M360 Outdoor Avg | 25.58 | 0.764 | 0.214 |
| M360 Indoor Avg | 33.33 | 0.945 | 0.188 |
| M360 Total | 29.02 | 0.845 | 0.203 |
| T&T Avg | 25.68 | 0.882 | 0.141 |
| DB Avg | 30.94 | 0.919 | 0.302 |
Per-scene breakdown:
| Scene | PSNR | SSIM | LPIPS |
|---|---|---|---|
| garden | 28.48 | 0.883 | 0.101 |
| bicycle | 25.99 | 0.796 | 0.192 |
| stump | 27.47 | 0.810 | 0.196 |
| treehill | 23.46 | 0.672 | 0.286 |
| flowers | 22.49 | 0.659 | 0.295 |
| bonsai | 35.19 | 0.961 | 0.199 |
| counter | 30.89 | 0.932 | 0.200 |
| kitchen | 33.75 | 0.945 | 0.124 |
| room | 33.51 | 0.942 | 0.230 |
| truck | 26.91 | 0.900 | 0.112 |
| train | 24.45 | 0.865 | 0.169 |
| drjohnson | 30.43 | 0.918 | 0.309 |
| playroom | 31.45 | 0.921 | 0.296 |
bash benchmarks/nht/benchmark_nht_high.sh
SCENE_LIST="garden bonsai truck" bash benchmarks/nht/benchmark_nht_high.sh
bash benchmarks/nht/benchmark_nht_high.sh --runtime_only
bash benchmarks/nht/benchmark_nht_high.sh --metrics_onlyMeasured results (RTX A6000 Ada):
| Method (w/ MCMC) | M360 PSNR | M360 SSIM | M360 LPIPS | T&T PSNR | T&T SSIM | T&T LPIPS | DB PSNR | DB SSIM | DB LPIPS |
|---|---|---|---|---|---|---|---|---|---|
| 3DGS + SH | 28.21 | 0.841 | 0.214 | 24.46 | 0.866 | 0.174 | 29.49 | 0.912 | 0.306 |
| 3DGUT + SH | 28.08 | 0.837 | 0.218 | 24.20 | 0.861 | 0.180 | 29.87 | 0.913 | 0.309 |
| 3DGUT + NHT (Ours, 64F) | 28.75 | 0.838 | 0.208 | 25.12 | 0.879 | 0.158 | 30.71 | 0.918 | 0.304 |
Per-scene breakdown (3DGUT + NHT, 64F):
| Scene | PSNR | SSIM | LPIPS | Cap |
|---|---|---|---|---|
| bonsai | 34.659 | 0.9612 | 0.2062 | 1,300,000 |
| counter | 30.719 | 0.9323 | 0.2057 | 1,200,000 |
| kitchen | 33.475 | 0.9455 | 0.1268 | 1,800,000 |
| room | 33.454 | 0.9440 | 0.2325 | 1,500,000 |
| Indoor | 33.077 | 0.9457 | 0.1928 | |
| garden | 28.331 | 0.8813 | 0.0994 | 5,200,000 |
| bicycle | 25.773 | 0.7873 | 0.1937 | 5,900,000 |
| stump | 27.145 | 0.7982 | 0.2041 | 4,750,000 |
| treehill | 23.141 | 0.6550 | 0.2932 | 3,500,000 |
| flowers | 22.037 | 0.6409 | 0.3105 | 3,000,000 |
| Outdoor | 25.285 | 0.7526 | 0.2202 | |
| train | 23.394 | 0.8551 | 0.2005 | 1,100,000 |
| truck | 26.851 | 0.9036 | 0.1144 | 2,600,000 |
| T&T | 25.123 | 0.8794 | 0.1575 | |
| drjohnson | 30.258 | 0.9164 | 0.3086 | 3,400,000 |
| playroom | 31.163 | 0.9192 | 0.2994 | 2,500,000 |
| DB | 30.710 | 0.9178 | 0.3040 | |
| M360 | 28.748 | 0.8384 | 0.2080 | |
| All13 | 28.492 | 0.8569 | 0.2150 |
Experimental: AOV (arbitrary output variables / semantic heads) is an experimental feature and still work in progress. Expect varying quality and performance.
Note — AOV runs on the tcnn (unfused) backend. The fused rasterize+MLP kernels decode a 16-wide padded sigmoid RGB output inside the rasterizer; AOV shaders instead decode hundreds of auxiliary channels per pixel (LSEG 512-d, DINOv3 384/768-d), often through a second split-head network with mixed per-channel activations. Emitting such wide outputs from the per-pixel warp epilogue would blow up register pressure and memory traffic and forfeit the fused kernel's advantage — those decodes are GEMM-bound and are exactly what tcnn's batched fully-fused MLP is already optimal for. AOV training and viewing therefore always use the two-stage rasterization + tcnn path (
aov/examples/*do this automatically, andsimple_trainer_nht.pyturns--nht_fusedoff by itself when--aov_target_keyis set); the fused kernels remain RGB-only for now. Small RGB2X-only bundles (3 + K < 128outputs) would be technically feasible in the fused kernel via a wider output pad, but are not worth the extra template surface while AOV is experimental.
# LSEG features
bash benchmarks/nht/benchmark_nht_aov.sh
# DINOv3 features
AOV_TARGET=dinov3 bash benchmarks/nht/benchmark_nht_aov.sh
# Specific scenes
SCENE_LIST="garden bonsai" AOV_TARGET=lseg bash benchmarks/nht/benchmark_nht_aov.shTraining reads precomputed maps from disk: LSEG features, DINOv3 features, and RGB2X PBR maps (albedo, roughness, etc.). This repository does not ship those models or preprocessing pipelines as dependencies—you must generate (or otherwise obtain) the AOV dataset yourself before running benchmark_nht_aov.sh or aov/examples/simple_trainer_nht_aov.py, and lay it out next to your RGB captures as documented in aov/aov_dataset.py (expected directory names, file formats, and pointers to external projects you can adapt).
By default, --data_factor N uses the base gsplat downsampling scheme: if the images_N/ folder contains JPEGs, the trainer resizes the full-resolution images from images/ into a new images_N_png/ directory at 1/N resolution. This on-the-fly resize is convenient but different to images already pre-downsampled by some datasets (e.g. MipNeRF360) and will produce slightly different results.
To bypass this and load your own pre-downsampled images directly, pass --native_images_factor:
python gsplat/examples/simple_trainer_nht.py default \
--data_dir data/mipnerf360/garden --data_factor 4 \
--native_images_factorAll of the results below use the default gsplat behavior. See the paper for more details on how the choice of downsampling affects reconstruction quality.
| Argument | Default | Description |
|---|---|---|
--deferred_opt_feature_dim |
48 |
Total feature dimensionality per primitive (divided among 4 tetrahedron vertices) |
--deferred_features_lr |
0.015 |
Learning rate for per-primitive features |
--deferred_mlp_lr |
0.00068 |
Learning rate for the deferred MLP |
--deferred_mlp_hidden_dim |
128 |
Width of each hidden layer in the deferred MLP |
--deferred_mlp_num_layers |
3 |
Number of hidden layers |
--deferred_mlp_ema |
True |
Enable EMA on MLP weights (decay=0.95) |
--deferred_opt_center_ray_encoding |
False |
Use per-tile center ray instead of per-pixel ray for view encoding |
--deferred_opt_view_encoding_type |
"sh" |
View encoding: "sh" or "fourier" |
--deferred_opt_sh_degree |
3 |
SH degree for view direction encoding |
--deferred_opt_sh_scale |
3.0 |
Scale applied to normalized directions before SH evaluation |
--deferred_lr_scheduler |
"cosine" |
LR schedule: "cosine" or "exponential" |
--color_refine_steps |
3000 |
Steps at end of training where geometry is frozen |
--opacity_reg |
0.02 |
Opacity regularization weight |
--scale_reg |
0.005 |
Scale regularization weight |
--ssim_lambda |
0.1 |
D-SSIM weight in the loss |
--tile_size |
16 |
Rasterization tile size (lower to 8 for large feature_dim) |
--native_images_factor |
False |
Load pre-downsampled images from images_N/ as-is instead of resizing full-res (see Image Downsampling) |
--lpips_net |
"vgg" |
LPIPS backbone: "vgg" or "alex" |
--lpips_normalize / --no-lpips_normalize |
True |
Normalize LPIPS inputs to [-1, 1]; disable to match INRIA 3DGS |
from gsplat.nht import DeferredShaderModule, HarmonicFeatures
from gsplat.rendering import rasterization
# Rasterize features + ray directions
renders, alphas, meta = rasterization(
means, quats, scales, opacities, features,
viewmats, Ks, width, height,
nht=True, with_eval3d=True, with_ut=True,
sh_degree=None,
)
# renders[..., :-3] = encoded features, renders[..., -3:] = ray dirs
# Decode to RGB with the deferred shader
rgb = deferred_shader(renders)import torch
from gsplat.rendering import rasterization
from gsplat.nht.deferred_shader import DeferredShaderModule
device = torch.device("cuda:0")
ckpt = torch.load("results/garden/ckpts/ckpt_29999_rank0.pt", map_location=device)
splats = {k: v.to(device) for k, v in ckpt["splats"].items()}
# Restore deferred module
dm_state = ckpt["deferred_module"]
dm = DeferredShaderModule(**dm_state["config"]).to(device)
dm.load_state_dict(dm_state["state_dict"])
if "ema" in dm_state:
for n, p in dm.named_parameters():
if n in dm_state["ema"]:
p.data.copy_(dm_state["ema"][n])
dm.eval()
# Prepare splats
means = splats["means"]
quats = torch.nn.functional.normalize(splats["quats"], p=2, dim=-1)
scales = torch.exp(splats["scales"])
opacities = torch.sigmoid(splats["opacities"])
features = splats["features"].half()
# Rasterize
with torch.no_grad():
render_colors, render_alphas, info = rasterization(
means=means, quats=quats, scales=scales,
opacities=opacities, colors=features,
viewmats=viewmat[None], Ks=K[None],
width=W, height=H,
nht=True, with_eval3d=True, with_ut=True,
sh_degree=None,
center_ray_mode=dm.center_ray_encoding,
ray_dir_scale=dm.ray_dir_scale,
)
rgb, extras = dm(render_colors)
rgb = rgb[0].clamp(0, 1)All paper results were measured on an NVIDIA RTX A6000 Ada (48 GB, Ada Lovelace architecture).
Note on LPIPS: All reported numbers use VGG with
normalize=True(inputs scaled to ([-1, 1])). This differs from INRIA 3DGS / upstream gsplat which usenormalize=False. See LPIPS metric for details and how to switch.
@article{condor2026nht,
title={Neural Harmonic Textures for High-Quality Primitive Based Neural Reconstruction},
author={Condor, Jorge and Moenne-Loccoz, Nicolas and Nimier-David, Merlin and Didyk, Piotr and Gojcic, Zan and Wu, Qi},
journal={arXiv preprint arXiv:2604.01204},
year={2026}
}This project is licensed under the Apache License 2.0. See the gsplat submodule for its own license terms.