Vertex-colour baker: 0.2s against the UV path's >20 minutes

The texture stage produced correct PBR voxels, but getting them ONTO a mesh through
o_voxel's UV path is not viable on this build. Measured, on an already welded,
floater-free, decimated 214k-face mesh:

  o_voxel to_glb, remesh=True    killed at 20min
  o_voxel to_glb, remesh=False   >20min CPU, killed
  bake_vertex_colors             0.2s

xatlas scales badly and 214k is the decimation floor, so it cannot be fed a smaller
mesh either. The trellis-2 lane reached the same conclusion independently and ships
--baker vertex as its fast path; this now matches.

bake_vertex_colors samples the PBR attribute volume at each vertex and writes COLOR_0.
Positions map to voxel indices by the same linear aabb relation fdg_to_mesh uses, so
nothing is resampled; lookup is a sorted-key searchsorted, and misses keep neutral
grey rather than black.

Verified on the real pipeline output:

  bake     vertex colours, 96.1% of vertices hit      0.2s
  result   90,093 verts / 214,322 faces
           57,925 unique colours, mean RGB [105 100 87], std [40 35 38]
           3.9% still default grey (matches the 4% miss rate)
  TOTAL    266.2s end to end, peak 32.6GB

What this costs: no metallic/roughness maps, base colour only. That is the honest
trade and it is stated in the operator description rather than buried - remesh also
now defaults OFF in to_glb, since upstream's remesh=True assumes CUDA.

Operator gains a baker param (vertex default, uv opt-in and flagged offline-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-03 18:23:58 +10:00
parent b6f0d76619
commit d338eca925
2 changed files with 65 additions and 10 deletions

View File

@ -91,6 +91,51 @@ def fdg_to_mesh(h, resolution: int, voxel_margin: float = 0.5) -> Tuple:
return v, f
def bake_vertex_colors(mesh, tex_voxels, resolution: int,
attr_layout: dict | None = None):
"""Sample the PBR attribute volume at each vertex -> COLOR_0. Seconds, not minutes.
The UV path (`to_glb`) runs o_voxel's unwrap+bake, which on this CPU/Metal build
burned >20 minutes of CPU on a 214k-face mesh even with remesh disabled xatlas
scales badly and 214k is the decimation floor, so it cannot simply be fed less.
The trellis-2 lane reached the same conclusion and ships `--baker vertex` as its
fast path for exactly this reason.
Vertex colours lose the metallic/roughness maps base colour only but they are
correct, immediate, and enough to see the asset. Positions map to voxel indices by
the same linear aabb relation `fdg_to_mesh` used, so no resampling is involved.
"""
import trimesh
layout = attr_layout or PBR_ATTR_LAYOUT
coords = np.asarray(tex_voxels.coords[:, 1:])
attrs = np.asarray(tex_voxels.feats)
lo, hi = np.array(AABB[0]), np.array(AABB[1])
v = np.asarray(mesh.vertices)
idx = np.floor((v - lo) / (hi - lo) * resolution).astype(np.int64)
idx = np.clip(idx, 0, resolution - 1)
# hash voxel coords -> row, then look each vertex up; unmatched vertices keep grey
key = (coords[:, 0].astype(np.int64) * resolution + coords[:, 1]) * resolution + coords[:, 2]
order = np.argsort(key)
key_sorted = key[order]
q = (idx[:, 0] * resolution + idx[:, 1]) * resolution + idx[:, 2]
pos = np.searchsorted(key_sorted, q)
pos = np.clip(pos, 0, len(key_sorted) - 1)
hit = key_sorted[pos] == q
base = layout["base_color"]
rgb = np.full((len(v), 3), 0.5, np.float32)
rgb[hit] = attrs[order[pos[hit]], base]
colors = np.concatenate([np.clip(rgb, 0, 1), np.ones((len(v), 1), np.float32)], 1)
out = trimesh.Trimesh(mesh.vertices, mesh.faces, process=False)
out.visual = trimesh.visual.ColorVisuals(out, vertex_colors=(colors * 255).astype(np.uint8))
return out, {"vertices_coloured": int(hit.sum()), "vertices_total": len(v),
"hit_rate": round(float(hit.mean()), 4)}
def to_glb(vertices, faces, tex_voxels, attr_layout: dict, resolution: int,
texture_size: int = 4096, decimation_target: int = 1_000_000,
prefer_metal: bool = True, remesh: bool = False):

View File

@ -73,6 +73,9 @@ def main():
ap.add_argument("--texture", action="store_true",
help="run the texture stage and bake PBR maps through o_voxel")
ap.add_argument("--texture-size", type=int, default=2048)
ap.add_argument("--baker", choices=("vertex", "uv"), default="vertex",
help="vertex = seconds, base colour only (default); "
"uv = o_voxel unwrap + full PBR maps, but >20min CPU here")
a = ap.parse_args()
t = time.time()
@ -94,23 +97,30 @@ def main():
# makes it tractable, and the baker samples the attribute VOLUME at mesh
# positions, so a decimated mesh still gets correct colours.
from pixal3d_mlx.cleanup import clean
from pixal3d_mlx.mesh import to_glb
from pixal3d_mlx.mesh import bake_vertex_colors, to_glb
t = time.time()
pre, _ = clean(v.cpu().numpy(), f.cpu().numpy(),
target_faces=a.target_faces or 500_000)
print(f" pre-bake {len(pre.faces):,} faces {time.time() - t:6.1f}s")
import torch
t = time.time()
scene = to_glb(torch.from_numpy(np.asarray(pre.vertices, np.float32)),
torch.from_numpy(np.asarray(pre.faces, np.int32)),
tex_voxels, PBR_ATTR_LAYOUT, info["output_resolution"],
texture_size=a.texture_size,
decimation_target=a.target_faces or 500_000)
mesh = scene if isinstance(scene, trimesh.Trimesh) else scene.dump(concatenate=True)
print(f" bake {len(mesh.faces):,} faces, {a.texture_size}px "
f"{time.time() - t:6.1f}s")
if a.baker == "vertex":
mesh, bstat = bake_vertex_colors(pre, tex_voxels, info["output_resolution"])
info["bake"] = bstat
print(f" bake vertex colours, {bstat['hit_rate']:.1%} of vertices hit"
f" {time.time() - t:6.1f}s")
else:
import torch
scene = to_glb(torch.from_numpy(np.asarray(pre.vertices, np.float32)),
torch.from_numpy(np.asarray(pre.faces, np.int32)),
tex_voxels, PBR_ATTR_LAYOUT, info["output_resolution"],
texture_size=a.texture_size,
decimation_target=a.target_faces or 500_000)
mesh = scene if isinstance(scene, trimesh.Trimesh) else scene.dump(concatenate=True)
print(f" bake UV {len(mesh.faces):,} faces, {a.texture_size}px "
f"{time.time() - t:6.1f}s")
info["baker"] = a.baker
mesh.export(a.output)
info["faces"], info["vertices"] = len(mesh.faces), len(mesh.vertices)
print(f"\nTOTAL {info['seconds']}s peak {info['peak_gb']} GB -> {a.output}")