- server/operators/hunyuan3d_mlx: native-MLX Hunyuan3D 2.1, both stages, PBR. Verified end-to-end on M3 Ultra: 260s total, 40k-face GLB w/ 2048² PBR. Weights public (no HF login). Runs on M1 (MLX-native). - scripts/install_hunyuan3d_mlx.sh: uv py3.11 venv + MLX-path deps. - CLUSTER.md: local-first policy + M3/M1/M4 fleet roles + central-queue howto. - BENCHMARKS/README/AGENTS: hunyuan3d_mlx rows + trellis head-to-head.
11 KiB
HANDOFF: hunyuan3d_mlx operator — fully-local Hunyuan3D 2.1 via MLX
For: Opus 4.8 executing autonomously in this repo.
Goal: Add a second fully-local image→3D generator (Hunyuan3D 2.1, native MLX) alongside trellis_mac, benchmark it on both nodes, and (stretch) fork-improve it to exploit Studio-class GPUs (M3 Ultra 256GB / 819GB/s, M1 Ultra 128GB). Written 2026-07-16 after code-level assessment of the upstream repo.
1. Context & assessment (read first, don't re-derive)
Upstream: https://github.com/dgrauet/Hunyuan3D-2.1-mlx — fork of Tencent's Hunyuan3D-2.1 adding native MLX inference for BOTH stages:
- Stage 1 (image → mesh):
hy3dshape/hy3dshape/pipeline_mlx.py→ShapePipeline.from_pretrained("dgrauet/hunyuan3d-2.1-mlx"). Validated numerically vs PyTorch within 1e-5. - Stage 2 (mesh + reference image → PBR-textured GLB):
hy3dpaint/textureGenPipeline_mlx.py→Hunyuan3DPaintPipelineMLX. UNet match 1.17e-5 vs PyTorch. Includes MLX RealESRGAN x4 (512²→2048² per view), cosine-weighted bake blend, EDT UV-gutter fill, proper glTF PBR export (baseColorTexture+metallicRoughnessTexture, doubleSided).
Weights: HF dgrauet/hunyuan3d-2.1-mlx, ~15.1GB total, auto-downloaded on first use. Override dir via env HUNYUAN3D_MLX_WEIGHTS_DIR. FP16 DiT is 5.68GB (dit.safetensors); paint stack adds paint_unet 3.66GB + paint_dino 2.12GB + paint_clip 1.18GB + VAEs + realesrgan_x4plus_mlx. Peak memory ~10GB stage 1 fp16 + ~6GB stage 2 → trivial on both our nodes. Use FP16, not INT8 — both Ultras have memory to burn and the owner wants best quality; INT8/INT4 exist only for 16GB Macs.
Port quality verdict: high for a 4-star repo. Disciplined single-author work: docs/forward_pass.md documents porting principles, docs/adr/ has architecture decisions, tests/ includes an end-to-end stage1→stage2 chain and unit tests. Deviations from the PyTorch reference are documented, not silent. Rough edges to expect: single author, ~188 weight downloads, no community battle-testing — treat every first run as a probe.
Reference timings (M2 Pro): ~9 min for 6-view texture synthesis at 512px. Our Ultras should beat this substantially; M1 Ultra runs MLX natively (unlike TRELLIS.2's torch-MPS bf16 question flagged in AGENTS.md).
The 4096-texture situation (this is the fork opportunity)
The README claims texture_size=2048 vs PyTorch's 4096 because "the MLX Metal rasterizer has no tiling." The code is ahead of the README:
hy3dpaint/textureGenPipeline_mlx.py:82— config default is ALREADYtexture_size = 1024 * 4(4096).hy3dpaint/DifferentiableRenderer/mesh_render_mlx.py:267—_rasterize_tiled(pos_clip, resolution, tiles=4)exists, verified against single-dispatch at 2048².mesh_render_mlx.py:647-658—extract_textiles()(the main UV-space raster feedingback_project(method="back_sample")) already auto-tiles: 2 tiles at ≥3072, 4 tiles at ≥4096.- Remaining untiled gaps:
uv_feature_map()atmesh_render_mlx.py:728calls plain_rasterize(vtx_uv_clip, self.texture_size)— this will blow the Metal command-buffer budget at 4096 if it's on the hot path. (mesh_render_mlx.py:815is camera-view raster atrender_size=2048, fine as-is.) The e2e test (hy3dpaint/tests/test_e2e_mesh.py:49-51) pins 2048 "because we don't tile the bake." - The command-buffer error (
kIOGPUCommandBufferCallbackErrorImpactingInteractivity) is a watchdog concern documented on laptop GPUs. An M3 Ultra Studio may simply not hit it untiled — test before patching.
Other Studio-class headroom knobs (all currently tuned for laptops):
- Remesh budget:
tests/test_stage1_to_stage2.py:82-91decimates Stage 1's ~500k-face mesh to ~40-60k before texturing (rasterizer budget again). On Ultra, try 100-200k for bake fidelity. - Stage 1
octree_resolution: default 256; try 384 for finer geometry (memory is not our constraint). max_num_view: default 6; the candidate camera list supports many more views.
2. Phase A — Fork + vendor + install (M3 first)
- Fork
dgrauet/Hunyuan3D-2.1-mlxto the owner's GitHub account withgh repo fork dgrauet/Hunyuan3D-2.1-mlx --clone=false(confirm the accountgh auth statusshows; do NOT create the fork under an org without asking). All our patches go to the fork'smodelbeastbranch; keepmaintracking upstream for rebases. - Vendor: clone the fork to
vendor/hunyuan3d-mlx(repo convention:vendor/trellis-mac,vendor/stable-fast-3d). - Install script
scripts/install_hunyuan3d_mlx.sh, modeled onscripts/install_trellis_mac.sh:- Create
vendor/hunyuan3d-mlx/.venv(python3.11, same as trellis-mac). pip install mlx mlx-arsenal safetensors Pillow trimesh scikit-image PyMCubes scipy huggingface_hub xatlas opencv-python(README's list) pluspymeshlab(needed foruse_remesh=True— seetextureGenPipeline_mlx.py:39-41, it soft-fails to None without it).- NOTE: Stage 2's
Hunyuan3DPaintConfigMLXreferences torch-era paths (device="cuda",multiview_pretrained_path) but withuse_mlx_diffusion=True(the default) the PyTorch multiview model is skipped — verify at runtime whethertorchis still imported anywhere on the MLX path before adding it to the venv. Prefer NOT installing torch if the MLX path doesn't need it. - Verify:
.venv/bin/python -c "import mlx.core as mx; print(mx.default_device())".
- Create
- Env guards: reuse the
HF_HUB_DISABLE_XET=1guard fromtrellis_mac/run.py(xet downloader breaks on some repos); the OMP guards likely unneeded without torch but harmless.
3. Phase B — CLI wrapper + operator
The upstream repo has no single-command CLI for the full chain — tests/test_stage1_to_stage2.py is the reference implementation of image→textured-GLB. Build:
- In the fork (so it's upstreamable):
generate_e2e.pyat repo root —python generate_e2e.py input.png --output out_stem [--steps 50] [--guidance 7.5] [--octree-resolution 256] [--max-num-view 6] [--view-resolution 512] [--texture-size 2048|4096] [--seed 42] [--no-texture] [--remesh-faces 40000]- Stage 1:
ShapePipeline.from_pretrained(...), export<stem>_shape.glb. - If
--no-texture, stop there. Else Stage 2 per the test file: remesh →Hunyuan3DPaintPipelineMLX→<stem>.glb. - Print stage timings and peak memory (
mx.get_peak_memory()) — we want these in job logs.
- Stage 1:
- Operator
server/operators/hunyuan3d_mlx/mirroringtrellis_macexactly:manifest.json: idhunyuan3d_mlx, name "Hunyuan3D 2.1 (local MLX)", categorymesh-gen, accepts["image"], produces["model"], resources"gpu", pythonvendor/hunyuan3d-mlx/.venv/bin/python. Params:steps(default 50),guidance_scale(7.5),octree_resolution(enum 256/384, default 256),texture_size(enum 1024/2048/4096, default 2048 until Phase D proves 4096),max_num_view(default 6),seed,no_texture.run.py: copytrellis_mac/run.pyshape — subprocessgenerate_e2e.py, cwd vendor dir, collect*.glb(prefer the textured one over*_shape.glb), writeresult.jsonwith{"outputs": [...], "meta": {"tool": "hunyuan3d_mlx"}}.
- Smoke test on scratch DB (per repo rule: never test against the running gens DB):
./mb run hunyuan3d_mlx --file <test image> --wait --download out/. First run downloads ~15GB — job log shows progress, don't kill it (AGENTS.md warns 10-30+ min). - Best-practice chain:
fal_bg_remove(or localbg_remove_local) → cutout →hunyuan3d_mlx, same as the fal chain in AGENTS.md.
4. Phase C — M1 Ultra node + benchmarks
- Install on the M1 node (
http://100.91.239.7:8777, see AGENTS.md) — same scripts; MLX is architecture-native on M1, no bf16 concern. Mind the unified worker pool (commitb8c333a): check how the gpu lane discovers operators per node inserver/runner.py/server/remote.pybefore assuming both nodes can serve the op. - Benchmark protocol — same cutout image through all three, one at a time (gpu lane serializes anyway):
trellis_mac(M3, defaults: 1024 pipeline, 2048 texture)hunyuan3d_mlxon M3 AND on M1 (fp16, defaults)fal_hunyuan3d_v21(single-image mode — multi-view is broken on fal, verified 2026-07) Record per run: wall time per stage, peak memory, GLB size, vert/face count, and a viewer screenshot. Subjective quality notes: geometry coherence, texture seams, PBR plausibility.
- Record in BENCHMARKS.md following the existing table format, and update the local-verdict line if Hunyuan changes the recommendation (e.g. "trellis_mac for geometry, hunyuan3d_mlx for textures"). Add the operator row to README.md's table and AGENTS.md's mesh-gen row.
5. Phase D (stretch) — Studio-class fork improvements
Work on the fork's modelbeast branch; each item is independently shippable and upstreamable as a PR to dgrauet:
- 4096² textures. First just run
--texture-size 4096on the M3 Ultra untiled — the command-buffer watchdog may not fire on Studio GPUs. If it fires: patchuv_feature_map()(mesh_render_mlx.py:728) to route through the existing_rasterize_tiled()using the same ≥3072/≥4096 tile thresholds asextract_textiles()(line 647). Grep for any other_rasterize(..., self.texture_size)call. Validate: tiled output must equal untiled at 2048 (the codebase already did this forextract_textiles; reuse that method). Compare 2048 vs 4096 bakes on the same mesh — if 4096 wins visibly, flip the operator default on the M3. - Bigger bake mesh. Raise the remesh decimation target from ~40k to 100-200k faces (flag already exposed via
--remesh-faces); check bake time scaling and whether the rasterizer holds. - Stage 1 detail:
octree_resolution=384A/B vs 256 on a detail-heavy cutout. - More views:
max_num_view=8-12A/B — the candidate camera ring (30° azimuth steps at ±20° elevation,textureGenPipeline_mlx.py:89-100) already supports it. Watch for texture-blend regressions, not just wall time. - Log every result in BENCHMARKS.md; if any patch is a clear win, open the upstream PR (small, focused, with the validation evidence — the author clearly values PT-parity proofs).
6. Guardrails
- Never test against the production DB with running generations — scratch DB only (repo rule).
- gpu lane = ONE job at a time; don't parallelize benchmarks on a node.
- First-run weight downloads are long; don't cancel "stuck" jobs that are downloading.
- The reference image must match the mesh content (upstream caveat) — for e2e runs this is automatic (same image drives both stages).
- If Stage 2 quality disappoints out of the box, benchmark Stage 1 +
--no-textureanyway: Hunyuan shape + TRELLIS texture routing is a valid future pipeline. - Ask the owner before: forking under an org account, pushing PRs upstream, or changing existing operator defaults.