From d338eca925dda3a37d6221e51db887759d37a650 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Mon, 3 Aug 2026 18:23:58 +1000 Subject: [PATCH] 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 --- pixal3d_mlx/mesh.py | 45 ++++++++++++++++++++++++++++++++++++++++ scripts/image_to_mesh.py | 30 ++++++++++++++++++--------- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/pixal3d_mlx/mesh.py b/pixal3d_mlx/mesh.py index 483994e..5682c76 100644 --- a/pixal3d_mlx/mesh.py +++ b/pixal3d_mlx/mesh.py @@ -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): diff --git a/scripts/image_to_mesh.py b/scripts/image_to_mesh.py index 3afd297..ab8b398 100644 --- a/scripts/image_to_mesh.py +++ b/scripts/image_to_mesh.py @@ -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}")