mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-12 09:27:15 +08:00
Normal and AO baking
This commit is contained in:
parent
ab58d1b79f
commit
42ac23f6f6
@ -7,7 +7,7 @@ import copy
|
|||||||
import comfy.utils
|
import comfy.utils
|
||||||
import comfy.model_management
|
import comfy.model_management
|
||||||
from server import PromptServer
|
from server import PromptServer
|
||||||
from comfy_extras.mesh3d.postprocess.qem_decimate import QEMConfig, qem_decimate_simplify, qem_cluster_decimate
|
from comfy_extras.mesh3d.postprocess.qem_decimate import QEMConfig, qem_decimate_simplify, qem_cluster_decimate, _compute_vertex_normals
|
||||||
from comfy_extras.mesh3d.postprocess.remesh import remesh_narrow_band_dc, _point_tri_closest
|
from comfy_extras.mesh3d.postprocess.remesh import remesh_narrow_band_dc, _point_tri_closest
|
||||||
from comfy_extras.mesh3d.uv_unwrap import mesh as _uv_mesh
|
from comfy_extras.mesh3d.uv_unwrap import mesh as _uv_mesh
|
||||||
from comfy_extras.mesh3d.uv_unwrap import segment as _uv_seg
|
from comfy_extras.mesh3d.uv_unwrap import segment as _uv_seg
|
||||||
@ -166,23 +166,23 @@ class PaintMesh(IO.ComfyNode):
|
|||||||
return IO.NodeOutput(out_mesh)
|
return IO.NodeOutput(out_mesh)
|
||||||
|
|
||||||
|
|
||||||
def _bake_position_map(verts_np, faces_np, uvs_np, texture_size):
|
def _rasterize_uv_barycentric(faces_np, uvs_np, texture_size):
|
||||||
"""Rasterize the mesh in UV space and barycentric-interpolate the per-vertex vec3
|
"""Rasterize the mesh in UV space (tiled point-in-triangle, pure torch). Returns per-texel
|
||||||
(world position, or any vec3 attr e.g. normals) at each covered texel. Pure torch,
|
face index [H,W], barycentric coords [H,W,3] and coverage mask [H,W], on the torch device.
|
||||||
tiled point-in-triangle — no GL/EGL, runs anywhere torch does. Returns (attr_map
|
Interpolate any per-vertex attribute from these with _interp_vertex_attr."""
|
||||||
[H,W,3] float32, mask [H,W] bool). """
|
|
||||||
dev = comfy.model_management.get_torch_device()
|
dev = comfy.model_management.get_torch_device()
|
||||||
H = W = int(texture_size)
|
H = W = int(texture_size)
|
||||||
|
face_idx = torch.zeros((H, W), dtype=torch.long, device=dev)
|
||||||
|
bary = torch.zeros((H, W, 3), device=dev)
|
||||||
|
cov = torch.zeros((H, W), dtype=torch.bool, device=dev)
|
||||||
if faces_np.shape[0] == 0:
|
if faces_np.shape[0] == 0:
|
||||||
return np.zeros((H, W, 3), dtype=np.float32), np.zeros((H, W), dtype=bool)
|
return face_idx, bary, cov
|
||||||
|
|
||||||
verts = torch.from_numpy(np.ascontiguousarray(verts_np, dtype=np.float32)).to(dev)
|
|
||||||
uvs = torch.from_numpy(np.ascontiguousarray(uvs_np, dtype=np.float32)).to(dev)
|
uvs = torch.from_numpy(np.ascontiguousarray(uvs_np, dtype=np.float32)).to(dev)
|
||||||
faces = torch.from_numpy(np.ascontiguousarray(faces_np).astype(np.int64)).to(dev)
|
faces = torch.from_numpy(np.ascontiguousarray(faces_np).astype(np.int64)).to(dev)
|
||||||
|
|
||||||
# GL convention: window coord = uv * resolution, coverage tested at texel centre.
|
# GL convention: window coord = uv * resolution, coverage tested at texel centre.
|
||||||
tri_uv = (uvs * float(W))[faces] # [F,3,2]
|
tri_uv = (uvs * float(W))[faces] # [F,3,2]
|
||||||
tri_attr = verts[faces] # [F,3,3]
|
|
||||||
x0, y0 = tri_uv[:, 0, 0], tri_uv[:, 0, 1]
|
x0, y0 = tri_uv[:, 0, 0], tri_uv[:, 0, 1]
|
||||||
x1, y1 = tri_uv[:, 1, 0], tri_uv[:, 1, 1]
|
x1, y1 = tri_uv[:, 1, 0], tri_uv[:, 1, 1]
|
||||||
x2, y2 = tri_uv[:, 2, 0], tri_uv[:, 2, 1]
|
x2, y2 = tri_uv[:, 2, 0], tri_uv[:, 2, 1]
|
||||||
@ -194,9 +194,6 @@ def _bake_position_map(verts_np, faces_np, uvs_np, texture_size):
|
|||||||
ymin = torch.minimum(torch.minimum(y0, y1), y2).floor().clamp_(0, H - 1).long()
|
ymin = torch.minimum(torch.minimum(y0, y1), y2).floor().clamp_(0, H - 1).long()
|
||||||
ymax = torch.maximum(torch.maximum(y0, y1), y2).ceil().clamp_(0, H - 1).long()
|
ymax = torch.maximum(torch.maximum(y0, y1), y2).ceil().clamp_(0, H - 1).long()
|
||||||
|
|
||||||
pos_out = torch.zeros((H, W, 3), device=dev)
|
|
||||||
cov = torch.zeros((H, W), dtype=torch.bool, device=dev)
|
|
||||||
|
|
||||||
# Tile so point-in-triangle only runs over the triangles whose bbox hits the tile.
|
# Tile so point-in-triangle only runs over the triangles whose bbox hits the tile.
|
||||||
TILE = 64
|
TILE = 64
|
||||||
eps = 1e-6
|
eps = 1e-6
|
||||||
@ -224,15 +221,40 @@ def _bake_position_map(verts_np, faces_np, uvs_np, texture_size):
|
|||||||
continue
|
continue
|
||||||
hit = inside.any(dim=0) # [th,tw]
|
hit = inside.any(dim=0) # [th,tw]
|
||||||
sel = inside.int().argmax(dim=0) # [th,tw] first covering local tri
|
sel = inside.int().argmax(dim=0) # [th,tw] first covering local tri
|
||||||
b0s = b0.gather(0, sel[None]).squeeze(0) # [th,tw] bary of selected tri
|
bsel = torch.stack([b0.gather(0, sel[None]).squeeze(0),
|
||||||
b1s = b1.gather(0, sel[None]).squeeze(0)
|
b1.gather(0, sel[None]).squeeze(0),
|
||||||
b2s = b2.gather(0, sel[None]).squeeze(0)
|
b2.gather(0, sel[None]).squeeze(0)], dim=-1) # [th,tw,3]
|
||||||
p = tri_attr[idx[sel]] # [th,tw,3,3]
|
face_idx[ty:ty_end, tx:tx_end][hit] = idx[sel][hit] # slice is a view → writes through
|
||||||
attr = b0s[..., None] * p[..., 0, :] + b1s[..., None] * p[..., 1, :] + b2s[..., None] * p[..., 2, :]
|
bary[ty:ty_end, tx:tx_end][hit] = bsel[hit]
|
||||||
pos_out[ty:ty_end, tx:tx_end][hit] = attr[hit] # slice is a view → writes through
|
|
||||||
cov[ty:ty_end, tx:tx_end] |= hit
|
cov[ty:ty_end, tx:tx_end] |= hit
|
||||||
|
|
||||||
return pos_out.cpu().numpy(), cov.cpu().numpy()
|
return face_idx, bary, cov
|
||||||
|
|
||||||
|
|
||||||
|
def _interp_vertex_attr(attr_v, faces, face_idx, bary, mask):
|
||||||
|
"""Interpolate a per-vertex attribute [N,C] into a [H,W,C] map via a rasterized
|
||||||
|
(face_idx, bary, mask). Uncovered texels stay zero."""
|
||||||
|
H, W = mask.shape
|
||||||
|
out = torch.zeros((H, W, attr_v.shape[1]), device=attr_v.device, dtype=attr_v.dtype)
|
||||||
|
if mask.any():
|
||||||
|
vtri = attr_v[faces[face_idx[mask]]] # [K,3,C]
|
||||||
|
out[mask] = (bary[mask][:, :, None] * vtri).sum(1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bake_position_map(verts_np, faces_np, uvs_np, texture_size):
|
||||||
|
"""Barycentric-interpolate a per-vertex vec3 (world position, or any vec3 e.g. normals)
|
||||||
|
at each covered texel. Returns (attr_map [H,W,3] float32, mask [H,W] bool)."""
|
||||||
|
dev = comfy.model_management.get_torch_device()
|
||||||
|
H = W = int(texture_size)
|
||||||
|
if faces_np.shape[0] == 0:
|
||||||
|
return np.zeros((H, W, 3), dtype=np.float32), np.zeros((H, W), dtype=bool)
|
||||||
|
|
||||||
|
face_idx, bary, mask = _rasterize_uv_barycentric(faces_np, uvs_np, texture_size)
|
||||||
|
verts = torch.from_numpy(np.ascontiguousarray(verts_np, dtype=np.float32)).to(dev)
|
||||||
|
faces = torch.from_numpy(np.ascontiguousarray(faces_np).astype(np.int64)).to(dev)
|
||||||
|
attr = _interp_vertex_attr(verts, faces, face_idx, bary, mask)
|
||||||
|
return attr.cpu().numpy(), mask.cpu().numpy()
|
||||||
|
|
||||||
|
|
||||||
def _trilinear_sample_sparse(positions, voxel_coords_np, color_np, resolution):
|
def _trilinear_sample_sparse(positions, voxel_coords_np, color_np, resolution):
|
||||||
@ -565,9 +587,10 @@ def _build_triangle_bvh(tri):
|
|||||||
return dict(LEAF=LEAF, left=left, right=right, nmin=nmin, nmax=nmax, order=order, T=T)
|
return dict(LEAF=LEAF, left=left, right=right, nmin=nmin, nmax=nmax, order=order, T=T)
|
||||||
|
|
||||||
|
|
||||||
def _closest_points_on_mesh_bvh(Q, tri, bvh, max_stack=64):
|
def _closest_points_on_mesh_bvh(Q, tri, bvh, max_stack=64, return_face=False):
|
||||||
"""Exact closest surface point per query via per-query BVH stack traversal
|
"""Exact closest surface point per query via per-query BVH stack traversal
|
||||||
(nearest-child-first), pure torch. Returns [N,3]. `max_stack` bounds the stack
|
(nearest-child-first), pure torch. Returns [N,3], or (points [N,3], face_idx [N])
|
||||||
|
when return_face=True (face_idx indexes `tri`). `max_stack` bounds the stack
|
||||||
(= tree height); overflow is counted+warned, not silently wrong."""
|
(= tree height); overflow is counted+warned, not silently wrong."""
|
||||||
dev = Q.device
|
dev = Q.device
|
||||||
N = Q.shape[0]
|
N = Q.shape[0]
|
||||||
@ -582,6 +605,7 @@ def _closest_points_on_mesh_bvh(Q, tri, bvh, max_stack=64):
|
|||||||
stack[:, 0] = 0
|
stack[:, 0] = 0
|
||||||
best = torch.full((N,), 1e30, device=dev)
|
best = torch.full((N,), 1e30, device=dev)
|
||||||
bestp = Q.clone()
|
bestp = Q.clone()
|
||||||
|
bestf = torch.full((N,), -1, dtype=torch.long, device=dev)
|
||||||
active = torch.arange(N, device=dev)
|
active = torch.arange(N, device=dev)
|
||||||
overflow = 0
|
overflow = 0
|
||||||
|
|
||||||
@ -599,12 +623,14 @@ def _closest_points_on_mesh_bvh(Q, tri, bvh, max_stack=64):
|
|||||||
lv = within & isleaf
|
lv = within & isleaf
|
||||||
if bool(lv.any()):
|
if bool(lv.any()):
|
||||||
ga = a[lv]
|
ga = a[lv]
|
||||||
tt = tri[order[node[lv] - LEAF]]
|
fidx = order[node[lv] - LEAF] # triangle index of each leaf
|
||||||
|
tt = tri[fidx]
|
||||||
cp, d2 = _point_tri_closest(qa[lv], tt)
|
cp, d2 = _point_tri_closest(qa[lv], tt)
|
||||||
upd = d2 < best[ga]
|
upd = d2 < best[ga]
|
||||||
gu = ga[upd]
|
gu = ga[upd]
|
||||||
best[gu] = d2[upd]
|
best[gu] = d2[upd]
|
||||||
bestp[gu] = cp[upd]
|
bestp[gu] = cp[upd]
|
||||||
|
bestf[gu] = fidx[upd]
|
||||||
iv = within & ~isleaf
|
iv = within & ~isleaf
|
||||||
if bool(iv.any()):
|
if bool(iv.any()):
|
||||||
gi = a[iv]
|
gi = a[iv]
|
||||||
@ -627,6 +653,8 @@ def _closest_points_on_mesh_bvh(Q, tri, bvh, max_stack=64):
|
|||||||
logging.warning(f"[back-project] BVH stack overflow on {overflow} pushes "
|
logging.warning(f"[back-project] BVH stack overflow on {overflow} pushes "
|
||||||
f"(max_stack={max_stack}); a few texels may be slightly off — "
|
f"(max_stack={max_stack}); a few texels may be slightly off — "
|
||||||
f"raise max_stack if this is large.")
|
f"raise max_stack if this is large.")
|
||||||
|
if return_face:
|
||||||
|
return bestp, bestf
|
||||||
return bestp
|
return bestp
|
||||||
|
|
||||||
|
|
||||||
@ -652,6 +680,352 @@ def _back_project_positions(position_map, mask, ref_v, ref_f):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ray_tri_hit(o, d, tri, tmin, tmax):
|
||||||
|
"""Möller-Trumbore any-hit per (ray, triangle) pair, double-sided. Returns bool [N]."""
|
||||||
|
a, b, c = tri[:, 0], tri[:, 1], tri[:, 2]
|
||||||
|
e1, e2 = b - a, c - a
|
||||||
|
p = torch.cross(d, e2, dim=-1)
|
||||||
|
det = (e1 * p).sum(-1)
|
||||||
|
inv = 1.0 / torch.where(det.abs() < 1e-20, torch.full_like(det, 1e-20), det)
|
||||||
|
tvec = o - a
|
||||||
|
u = (tvec * p).sum(-1) * inv
|
||||||
|
q = torch.cross(tvec, e1, dim=-1)
|
||||||
|
v = (d * q).sum(-1) * inv
|
||||||
|
t = (e2 * q).sum(-1) * inv
|
||||||
|
return (det.abs() > 1e-20) & (u >= 0) & (v >= 0) & (u + v <= 1) & (t > tmin) & (t < tmax)
|
||||||
|
|
||||||
|
|
||||||
|
def _any_hit_rays_bvh(orig, dirs, tri, bvh, tmin=0.0, tmax=1e30, max_stack=64):
|
||||||
|
"""Any-hit ray test over the BVH (slab cull + Möller-Trumbore), pure torch. Returns bool
|
||||||
|
[N]: True if the ray hits any triangle in (tmin, tmax). Rays early-out once they hit."""
|
||||||
|
dev = orig.device
|
||||||
|
N = orig.shape[0]
|
||||||
|
LEAF = bvh['LEAF']
|
||||||
|
nmin, nmax = bvh['nmin'], bvh['nmax']
|
||||||
|
left, right, order = bvh['left'], bvh['right'], bvh['order']
|
||||||
|
inv = 1.0 / torch.where(dirs.abs() < 1e-20, torch.full_like(dirs, 1e-20), dirs)
|
||||||
|
hit = torch.zeros(N, dtype=torch.bool, device=dev)
|
||||||
|
# int32 stack: node indices fit in 31 bits and this [N, max_stack] array dominates memory.
|
||||||
|
stack = torch.full((N, max_stack), -1, dtype=torch.int32, device=dev)
|
||||||
|
sp = torch.ones(N, dtype=torch.long, device=dev)
|
||||||
|
stack[:, 0] = 0
|
||||||
|
active = torch.arange(N, device=dev)
|
||||||
|
|
||||||
|
def slab(node, o, i):
|
||||||
|
t1 = (nmin[node] - o) * i
|
||||||
|
t2 = (nmax[node] - o) * i
|
||||||
|
tnear = torch.minimum(t1, t2).amax(-1)
|
||||||
|
tfar = torch.maximum(t1, t2).amin(-1)
|
||||||
|
return (tfar >= tnear.clamp_min(tmin)) & (tnear <= tmax) & (tfar >= tmin)
|
||||||
|
|
||||||
|
while active.numel() > 0:
|
||||||
|
a = active
|
||||||
|
node = stack[a, sp[a] - 1]
|
||||||
|
sp[a] = sp[a] - 1
|
||||||
|
within = slab(node, orig[a], inv[a])
|
||||||
|
isleaf = node >= LEAF
|
||||||
|
lv = within & isleaf
|
||||||
|
if bool(lv.any()):
|
||||||
|
ga = a[lv]
|
||||||
|
tt = tri[order[node[lv] - LEAF]]
|
||||||
|
h = _ray_tri_hit(orig[ga], dirs[ga], tt, tmin, tmax)
|
||||||
|
hit[ga[h]] = True
|
||||||
|
iv = within & ~isleaf
|
||||||
|
if bool(iv.any()):
|
||||||
|
gi = a[iv]
|
||||||
|
s0 = sp[gi]
|
||||||
|
stack[gi, s0.clamp(max=max_stack - 1)] = left[node[iv]].to(torch.int32)
|
||||||
|
sp[gi] = (s0 + 1).clamp(max=max_stack)
|
||||||
|
s1 = sp[gi]
|
||||||
|
stack[gi, s1.clamp(max=max_stack - 1)] = right[node[iv]].to(torch.int32)
|
||||||
|
sp[gi] = (s1 + 1).clamp(max=max_stack)
|
||||||
|
active = a[(sp[a] > 0) & ~hit[a]] # drop finished + already-hit rays
|
||||||
|
return hit
|
||||||
|
|
||||||
|
|
||||||
|
def _ray_tri_intersect(o, d, tri, tmin, tmax, cull_backface=False):
|
||||||
|
"""Möller-Trumbore per (ray, triangle) pair. Returns (hit [N], t [N]) where t is the ray
|
||||||
|
parameter and hit means the meeting is in (tmin, tmax). With cull_backface, drops faces whose
|
||||||
|
outward (winding) normal points along the ray — i.e. only keep surfaces facing the origin."""
|
||||||
|
a, b, c = tri[:, 0], tri[:, 1], tri[:, 2]
|
||||||
|
e1, e2 = b - a, c - a
|
||||||
|
p = torch.cross(d, e2, dim=-1)
|
||||||
|
det = (e1 * p).sum(-1)
|
||||||
|
inv = 1.0 / torch.where(det.abs() < 1e-20, torch.full_like(det, 1e-20), det)
|
||||||
|
tvec = o - a
|
||||||
|
u = (tvec * p).sum(-1) * inv
|
||||||
|
q = torch.cross(tvec, e1, dim=-1)
|
||||||
|
v = (d * q).sum(-1) * inv
|
||||||
|
t = (e2 * q).sum(-1) * inv
|
||||||
|
hit = (det.abs() > 1e-20) & (u >= 0) & (v >= 0) & (u + v <= 1) & (t > tmin) & (t < tmax)
|
||||||
|
if cull_backface:
|
||||||
|
hit = hit & ((torch.cross(e1, e2, dim=-1) * d).sum(-1) < 0) # keep only front-facing
|
||||||
|
return hit, t
|
||||||
|
|
||||||
|
|
||||||
|
def _closest_hit_rays_bvh(orig, dirs, tri, bvh, tmin=0.0, tmax=1e30, max_stack=64, cull_backface=False):
|
||||||
|
"""Nearest-hit ray cast over the BVH, pure torch. Returns (t [N], face [N] long, -1 on
|
||||||
|
miss; hit [N] bool) — the closest intersection in (tmin, tmax), pruning nodes past best_t."""
|
||||||
|
dev = orig.device
|
||||||
|
N = orig.shape[0]
|
||||||
|
LEAF = bvh['LEAF']
|
||||||
|
nmin, nmax = bvh['nmin'], bvh['nmax']
|
||||||
|
left, right, order = bvh['left'], bvh['right'], bvh['order']
|
||||||
|
inv = 1.0 / torch.where(dirs.abs() < 1e-20, torch.full_like(dirs, 1e-20), dirs)
|
||||||
|
best_t = torch.full((N,), float(tmax), device=dev)
|
||||||
|
best_f = torch.full((N,), -1, dtype=torch.long, device=dev)
|
||||||
|
stack = torch.full((N, max_stack), -1, dtype=torch.int32, device=dev)
|
||||||
|
sp = torch.ones(N, dtype=torch.long, device=dev)
|
||||||
|
stack[:, 0] = 0
|
||||||
|
active = torch.arange(N, device=dev)
|
||||||
|
|
||||||
|
while active.numel() > 0:
|
||||||
|
a = active
|
||||||
|
node = stack[a, sp[a] - 1]
|
||||||
|
sp[a] = sp[a] - 1
|
||||||
|
t1 = (nmin[node] - orig[a]) * inv[a]
|
||||||
|
t2 = (nmax[node] - orig[a]) * inv[a]
|
||||||
|
tnear = torch.minimum(t1, t2).amax(-1)
|
||||||
|
tfar = torch.maximum(t1, t2).amin(-1)
|
||||||
|
within = (tfar >= tnear.clamp_min(tmin)) & (tfar >= tmin) & (tnear < best_t[a]) # prune past best
|
||||||
|
isleaf = node >= LEAF
|
||||||
|
lv = within & isleaf
|
||||||
|
if bool(lv.any()):
|
||||||
|
ga = a[lv]
|
||||||
|
fidx = order[node[lv] - LEAF]
|
||||||
|
h, t = _ray_tri_intersect(orig[ga], dirs[ga], tri[fidx], tmin, tmax, cull_backface)
|
||||||
|
upd = h & (t < best_t[ga])
|
||||||
|
gu = ga[upd]
|
||||||
|
best_t[gu] = t[upd]
|
||||||
|
best_f[gu] = fidx[upd]
|
||||||
|
iv = within & ~isleaf
|
||||||
|
if bool(iv.any()):
|
||||||
|
gi = a[iv]
|
||||||
|
s0 = sp[gi]
|
||||||
|
stack[gi, s0.clamp(max=max_stack - 1)] = left[node[iv]].to(torch.int32)
|
||||||
|
sp[gi] = (s0 + 1).clamp(max=max_stack)
|
||||||
|
s1 = sp[gi]
|
||||||
|
stack[gi, s1.clamp(max=max_stack - 1)] = right[node[iv]].to(torch.int32)
|
||||||
|
sp[gi] = (s1 + 1).clamp(max=max_stack)
|
||||||
|
active = a[sp[a] > 0]
|
||||||
|
return best_t, best_f, best_f >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def _onb(n):
|
||||||
|
"""Branchless orthonormal basis (t, b) around unit normals n [N,3]."""
|
||||||
|
up = torch.where(n[..., 2:3].abs() < 0.999,
|
||||||
|
torch.tensor([0.0, 0.0, 1.0], device=n.device).expand_as(n),
|
||||||
|
torch.tensor([1.0, 0.0, 0.0], device=n.device).expand_as(n))
|
||||||
|
t = torch.nn.functional.normalize(torch.cross(up, n, dim=-1), dim=-1, eps=1e-6)
|
||||||
|
return t, torch.cross(n, t, dim=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bake_ambient_occlusion(high_v, high_f, low_v_np, low_f_np, low_uv_np, low_n, resolution,
|
||||||
|
num_samples=64, max_distance=0.5, strength=1.0, bias=0.01,
|
||||||
|
ray_chunk=None, pbar=None):
|
||||||
|
"""Bake high-poly ambient occlusion into the low-poly's UV layout: per texel, cosine-weight
|
||||||
|
a hemisphere of rays around the normal and cast them at the high-poly. AO = 1 - hit-fraction
|
||||||
|
(cosine weighting makes the hit-fraction the estimator). Returns ao_img [H,W,3] in [0,1].
|
||||||
|
|
||||||
|
ray_chunk caps rays cast at once (the per-chunk BVH stack is its dominant transient VRAM);
|
||||||
|
None auto-sizes it to a slice of free VRAM — big chunks (fast) on large GPUs, small (safe)
|
||||||
|
on small ones."""
|
||||||
|
dev = comfy.model_management.get_torch_device()
|
||||||
|
H = W = int(resolution)
|
||||||
|
S = int(num_samples)
|
||||||
|
if ray_chunk is None:
|
||||||
|
# ~376 B/ray (int32 stack max_stack*4 + a few [N,3] ray buffers); spend a quarter of free
|
||||||
|
# VRAM. Speed saturates around 4M rays/chunk, so cap there (≈2 GB peak) rather than grow
|
||||||
|
# memory for no gain; floor keeps tiny GPUs from thrashing into too many chunks.
|
||||||
|
try:
|
||||||
|
free = torch.cuda.mem_get_info(dev)[0] if dev.type == "cuda" else (2 << 30)
|
||||||
|
except Exception:
|
||||||
|
free = 2 << 30
|
||||||
|
ray_chunk = int(min(1 << 22, max(1 << 20, (free * 0.25) / (num_samples * 4 + 200))))
|
||||||
|
face_idx, bary_uv, mask = _rasterize_uv_barycentric(low_f_np, low_uv_np, resolution)
|
||||||
|
if not mask.any():
|
||||||
|
return np.ones((H, W, 3), dtype=np.float32)
|
||||||
|
lf = torch.from_numpy(np.ascontiguousarray(low_f_np).astype(np.int64)).to(dev)
|
||||||
|
lv = torch.from_numpy(np.ascontiguousarray(low_v_np, dtype=np.float32)).to(dev)
|
||||||
|
low_n = low_n.to(dev).float()
|
||||||
|
m = mask
|
||||||
|
vtri = lf[face_idx[m]] # [K,3] vertex ids
|
||||||
|
bsel = bary_uv[m] # [K,3]
|
||||||
|
P = (bsel[:, :, None] * lv[vtri]).sum(1) # [K,3]
|
||||||
|
Nl = torch.nn.functional.normalize((bsel[:, :, None] * low_n[vtri]).sum(1), dim=-1, eps=1e-6)
|
||||||
|
|
||||||
|
hv = high_v.to(dev).float()
|
||||||
|
hf = high_f.to(dev).long()
|
||||||
|
tri = hv[hf]
|
||||||
|
bvh = _build_triangle_bvh(tri)
|
||||||
|
diag = float((hv.amax(0) - hv.amin(0)).norm().clamp_min(1e-6))
|
||||||
|
biasw = max(1e-5, float(bias) * diag)
|
||||||
|
tmax = float(max_distance) * diag
|
||||||
|
|
||||||
|
# Back-project onto the high surface, then lift along the normal: the low-poly chord can sit
|
||||||
|
# below the high surface, and casting from below floods false self-occlusion (dark blotches).
|
||||||
|
bp = _closest_points_on_mesh_bvh(P, tri, bvh)
|
||||||
|
origins = bp + Nl * biasw
|
||||||
|
|
||||||
|
K = P.shape[0]
|
||||||
|
T, B = _onb(Nl)
|
||||||
|
occ = torch.zeros(K, device=dev)
|
||||||
|
tex_per_chunk = max(1, int(ray_chunk) // max(1, S))
|
||||||
|
for s in range(0, K, tex_per_chunk):
|
||||||
|
e = min(s + tex_per_chunk, K)
|
||||||
|
kk = e - s
|
||||||
|
o, n, t, b = origins[s:e], Nl[s:e], T[s:e], B[s:e]
|
||||||
|
r1 = torch.rand(kk, S, device=dev)
|
||||||
|
r2 = torch.rand(kk, S, device=dev)
|
||||||
|
sr = r1.sqrt()
|
||||||
|
lz = r1.mul_(-1.0).add_(1.0).clamp_min_(0.0).sqrt_() # sqrt(1-r1) (r1 dead after sr)
|
||||||
|
ang = r2.mul_(2.0 * math.pi) # in place (r2 dead)
|
||||||
|
lx = ang.cos().mul_(sr)
|
||||||
|
ly = ang.sin().mul_(sr)
|
||||||
|
d = t[:, None, :] * lx[..., None] # cosine-weighted hemisphere,
|
||||||
|
d.addcmul_(b[:, None, :], ly[..., None]) # fused d += b*ly
|
||||||
|
d.addcmul_(n[:, None, :], lz[..., None]) # fused d += n*lz (no extra temps)
|
||||||
|
d = torch.nn.functional.normalize(d.reshape(-1, 3), dim=-1, eps=1e-6)
|
||||||
|
oo = o[:, None, :].expand(-1, S, -1).reshape(-1, 3)
|
||||||
|
hit = _any_hit_rays_bvh(oo, d, tri, bvh, tmin=biasw, tmax=tmax)
|
||||||
|
occ[s:e] = hit.reshape(kk, S).sum(1, dtype=torch.float32).div_(float(S)) # mean without a float copy
|
||||||
|
if pbar is not None:
|
||||||
|
pbar.update(1)
|
||||||
|
|
||||||
|
ao = occ.mul_(-float(strength)).add_(1.0).clamp_(0.0, 1.0) # 1 - occ*strength, in place (occ is dead)
|
||||||
|
out = torch.ones((H, W), device=dev)
|
||||||
|
out[m] = ao
|
||||||
|
out3 = np.repeat(out.cpu().numpy()[..., None], 3, axis=2)
|
||||||
|
return _jfa_fill_gpu(np.ascontiguousarray(out3, dtype=np.float32), mask.cpu().numpy())
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_vertex_tangents(verts, faces, uvs, normals):
|
||||||
|
"""Per-vertex tangents (Lengyel) orthonormalized against `normals`. Returns [N,4]:
|
||||||
|
unit tangent xyz + handedness w (the bitangent is w * cross(N, T)). Pure torch."""
|
||||||
|
N = verts.shape[0]
|
||||||
|
i0, i1, i2 = faces[:, 0].long(), faces[:, 1].long(), faces[:, 2].long()
|
||||||
|
e1, e2 = verts[i1] - verts[i0], verts[i2] - verts[i0]
|
||||||
|
d1, d2 = uvs[i1] - uvs[i0], uvs[i2] - uvs[i0]
|
||||||
|
denom = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
|
||||||
|
r = 1.0 / torch.where(denom.abs() < 1e-20, torch.full_like(denom, 1e-20), denom)
|
||||||
|
tan = (d2[:, 1:2] * e1 - d1[:, 1:2] * e2) * r[:, None] # [F,3]
|
||||||
|
bit = (d1[:, 0:1] * e2 - d2[:, 0:1] * e1) * r[:, None]
|
||||||
|
tacc = torch.zeros((N, 3), device=verts.device, dtype=verts.dtype)
|
||||||
|
bacc = torch.zeros((N, 3), device=verts.device, dtype=verts.dtype)
|
||||||
|
for idx in (i0, i1, i2):
|
||||||
|
tacc.scatter_add_(0, idx[:, None].expand(-1, 3), tan)
|
||||||
|
bacc.scatter_add_(0, idx[:, None].expand(-1, 3), bit)
|
||||||
|
n = torch.nn.functional.normalize(normals, dim=-1, eps=1e-6)
|
||||||
|
# Gram-Schmidt: drop the normal component, then renormalize.
|
||||||
|
t = torch.nn.functional.normalize(tacc - n * (n * tacc).sum(-1, keepdim=True), dim=-1, eps=1e-6)
|
||||||
|
w = torch.sign((torch.cross(n, t, dim=-1) * bacc).sum(-1))
|
||||||
|
w = torch.where(w == 0, torch.ones_like(w), w) # degenerate → right-handed
|
||||||
|
return torch.cat([t, w[:, None]], dim=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def _vertex_tangents_for_item(lv, lf, uv, low_n_attr_i, dev):
|
||||||
|
"""Per-item shading normals + tangents. Shared by the bake (BakeNormalMapFromMesh) and the
|
||||||
|
export attach (ApplyTextureToMesh) so their basis can't diverge. `low_n_attr_i` is the
|
||||||
|
mesh's per-item normals or None (then computed). Returns (low_n [N,3], tangents [N,4])."""
|
||||||
|
low_n = low_n_attr_i.to(dev).float() if low_n_attr_i is not None else _compute_vertex_normals(lv, lf)
|
||||||
|
tangents = _compute_vertex_tangents(lv, lf, uv.to(dev).float(), low_n)
|
||||||
|
return low_n, tangents
|
||||||
|
|
||||||
|
|
||||||
|
def _barycentric(p, tri):
|
||||||
|
"""Barycentric coords [N,3] of points p [N,3] wrt triangles tri [N,3,3] (per-pair)."""
|
||||||
|
a, b, c = tri[:, 0], tri[:, 1], tri[:, 2]
|
||||||
|
v0, v1, v2 = b - a, c - a, p - a
|
||||||
|
d00 = (v0 * v0).sum(-1)
|
||||||
|
d01 = (v0 * v1).sum(-1)
|
||||||
|
d11 = (v1 * v1).sum(-1)
|
||||||
|
d20 = (v2 * v0).sum(-1)
|
||||||
|
d21 = (v2 * v1).sum(-1)
|
||||||
|
denom = d00 * d11 - d01 * d01
|
||||||
|
denom = torch.where(denom.abs() < 1e-20, torch.full_like(denom, 1e-20), denom)
|
||||||
|
v = (d11 * d20 - d01 * d21) / denom
|
||||||
|
w = (d00 * d21 - d01 * d20) / denom
|
||||||
|
return torch.stack([1.0 - v - w, v, w], dim=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def _bake_normal_map(high_v, high_f, high_n, low_v_np, low_f_np, low_uv_np, low_n, tangents,
|
||||||
|
resolution, cage_distance=0.05, ignore_backfaces=True):
|
||||||
|
"""Tangent-space normal map (glTF/OpenGL +Y) of the high-poly baked into the low-poly's UV
|
||||||
|
layout. Per texel a cage ray (along the normal, over cage_distance * bbox-diagonal) finds the
|
||||||
|
matching high-poly surface, whose normal is projected into the texel's TBN frame.
|
||||||
|
ignore_backfaces skips surfaces facing away (crevices/enclosures); misses fall back to
|
||||||
|
closest-point. Returns [H,W,3] in [0,1]."""
|
||||||
|
dev = comfy.model_management.get_torch_device()
|
||||||
|
H = W = int(resolution)
|
||||||
|
flat = np.array([0.5, 0.5, 1.0], dtype=np.float32)
|
||||||
|
|
||||||
|
# One rasterization, then interpolate position/normal/tangent/handedness by indexing it.
|
||||||
|
face_idx, bary_uv, mask = _rasterize_uv_barycentric(low_f_np, low_uv_np, resolution)
|
||||||
|
if not mask.any():
|
||||||
|
return np.tile(flat, (H, W, 1))
|
||||||
|
lf = torch.from_numpy(np.ascontiguousarray(low_f_np).astype(np.int64)).to(dev)
|
||||||
|
lv = torch.from_numpy(np.ascontiguousarray(low_v_np, dtype=np.float32)).to(dev)
|
||||||
|
low_n = low_n.to(dev).float()
|
||||||
|
tangents = tangents.to(dev).float()
|
||||||
|
m = mask
|
||||||
|
fsel = face_idx[m] # [K] source face per texel
|
||||||
|
bsel = bary_uv[m] # [K,3]
|
||||||
|
vtri = lf[fsel] # [K,3] vertex ids
|
||||||
|
|
||||||
|
def _interp(attr): # attr [N,C] -> [K,C]
|
||||||
|
return (bsel[:, :, None] * attr[vtri]).sum(1)
|
||||||
|
|
||||||
|
P = _interp(lv) # [K,3] world pos
|
||||||
|
Nl = torch.nn.functional.normalize(_interp(low_n), dim=-1, eps=1e-6)
|
||||||
|
Tl = _interp(tangents[:, :3])
|
||||||
|
Wl = _interp(tangents[:, 3:4])[:, 0]
|
||||||
|
|
||||||
|
hv = high_v.to(dev).float()
|
||||||
|
hf = high_f.to(dev).long()
|
||||||
|
tri = hv[hf]
|
||||||
|
bvh = _build_triangle_bvh(tri)
|
||||||
|
|
||||||
|
# Cage ray-cast: from cage outward, march back along -normal and take the nearest (outermost)
|
||||||
|
# hit. Closest-point is the fallback where the ray misses.
|
||||||
|
diag = float((hv.amax(0) - hv.amin(0)).norm().clamp_min(1e-6))
|
||||||
|
cage = max(1e-6, float(cage_distance) * diag)
|
||||||
|
origin = P + Nl * cage
|
||||||
|
t_hit, f_hit, ray_hit = _closest_hit_rays_bvh(origin, -Nl, tri, bvh, tmin=0.0, tmax=2.0 * cage,
|
||||||
|
cull_backface=bool(ignore_backfaces))
|
||||||
|
bface = f_hit.clamp_min(0)
|
||||||
|
hitpoint = origin - t_hit[:, None] * Nl
|
||||||
|
# Closest-point fallback only for texels the ray missed (usually few) — running it over every
|
||||||
|
# texel wastes a full BVH traversal on the ones already resolved by the ray.
|
||||||
|
miss = ~ray_hit
|
||||||
|
if bool(miss.any()):
|
||||||
|
bp_m, bf_m = _closest_points_on_mesh_bvh(P[miss], tri, bvh, return_face=True)
|
||||||
|
bface = bface.clone()
|
||||||
|
hitpoint = hitpoint.clone()
|
||||||
|
bface[miss] = bf_m.clamp_min(0)
|
||||||
|
hitpoint[miss] = bp_m
|
||||||
|
|
||||||
|
htri = tri[bface] # [K,3,3]
|
||||||
|
bary = _barycentric(hitpoint, htri)
|
||||||
|
hn_tri = high_n.to(dev).float()[hf[bface]] # [K,3,3] vertex normals
|
||||||
|
Nh = torch.nn.functional.normalize((bary[:, :, None] * hn_tri).sum(1), dim=-1, eps=1e-6)
|
||||||
|
|
||||||
|
# Per-texel TBN (Gram-Schmidt tangent against the interpolated normal).
|
||||||
|
T = torch.nn.functional.normalize(Tl - Nl * (Nl * Tl).sum(-1, keepdim=True), dim=-1, eps=1e-6)
|
||||||
|
Bn = Wl[:, None] * torch.cross(Nl, T, dim=-1)
|
||||||
|
nz = (Nh * Nl).sum(-1) # reused as z-channel and the back-face test
|
||||||
|
ts = torch.stack([(Nh * T).sum(-1), (Nh * Bn).sum(-1), nz], dim=-1)
|
||||||
|
ts = torch.nn.functional.normalize(ts, dim=-1, eps=1e-6)
|
||||||
|
# Safety net: if the matched high normal faces away from the texel (a back surface the fallback
|
||||||
|
# grabbed in a deep crevice), use the flat base normal rather than a wrong one.
|
||||||
|
ts[nz < 0.0] = torch.tensor([0.0, 0.0, 1.0], device=dev)
|
||||||
|
enc = ts.mul_(0.5).add_(0.5).clamp_(0.0, 1.0) # encode in place (ts is dead)
|
||||||
|
|
||||||
|
out = torch.from_numpy(np.tile(flat, (H, W, 1))).to(dev)
|
||||||
|
out[m] = enc
|
||||||
|
# Dilate into the UV gutter so bilinear/mip sampling at chart edges doesn't bleed flat blue.
|
||||||
|
return _jfa_fill_gpu(out.cpu().numpy(), mask.cpu().numpy())
|
||||||
|
|
||||||
|
|
||||||
def _jfa_fill_gpu(img01, mask):
|
def _jfa_fill_gpu(img01, mask):
|
||||||
"""Fill uncovered texels with nearest covered value via GPU Jump Flooding
|
"""Fill uncovered texels with nearest covered value via GPU Jump Flooding
|
||||||
(O(log n) passes; replaces cv2.inpaint). img01 [H,W,C] float, mask [H,W] bool."""
|
(O(log n) passes; replaces cv2.inpaint). img01 [H,W,C] float, mask [H,W] bool."""
|
||||||
@ -919,15 +1293,17 @@ class MeshTextureToImage(IO.ComfyNode):
|
|||||||
display_name="Mesh Texture to Image",
|
display_name="Mesh Texture to Image",
|
||||||
category="latent/3d",
|
category="latent/3d",
|
||||||
description=(
|
description=(
|
||||||
"Extracts a mesh's baked textures as IMAGE outputs: base_color and the packed "
|
"Extracts a mesh's baked textures as individual IMAGEs: base_color, metallic, "
|
||||||
"glTF MR map (G=roughness, B=metallic; black if no PBR texture)."
|
"roughness, occlusion and normal_map. Channels with nothing baked come back "
|
||||||
|
"neutral (occlusion white, normal flat)."
|
||||||
),
|
),
|
||||||
inputs=[IO.Mesh.Input("mesh")],
|
inputs=[IO.Mesh.Input("mesh")],
|
||||||
outputs=[
|
outputs=[
|
||||||
IO.Image.Output(display_name="base_color"),
|
IO.Image.Output(display_name="base_color"),
|
||||||
IO.Image.Output(display_name="metallic_roughness"),
|
|
||||||
IO.Image.Output(display_name="metallic"),
|
IO.Image.Output(display_name="metallic"),
|
||||||
IO.Image.Output(display_name="roughness"),
|
IO.Image.Output(display_name="roughness"),
|
||||||
|
IO.Image.Output(display_name="occlusion"),
|
||||||
|
IO.Image.Output(display_name="normal_map"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -944,6 +1320,7 @@ class MeshTextureToImage(IO.ComfyNode):
|
|||||||
|
|
||||||
base = _as_image(getattr(mesh, "texture", None))
|
base = _as_image(getattr(mesh, "texture", None))
|
||||||
mr = _as_image(getattr(mesh, "metallic_roughness", None))
|
mr = _as_image(getattr(mesh, "metallic_roughness", None))
|
||||||
|
normal_map = _as_image(getattr(mesh, "normal_map", None))
|
||||||
|
|
||||||
if base is None:
|
if base is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -952,10 +1329,18 @@ class MeshTextureToImage(IO.ComfyNode):
|
|||||||
)
|
)
|
||||||
if mr is None:
|
if mr is None:
|
||||||
mr = torch.zeros_like(base)
|
mr = torch.zeros_like(base)
|
||||||
# Split packed MR into grayscale previews (G=roughness, B=metallic), to 3ch.
|
if normal_map is None:
|
||||||
|
normal_map = torch.ones_like(base) * torch.tensor([0.5, 0.5, 1.0]) # neutral flat normal
|
||||||
|
# Unpack the ORM map (R=occlusion, G=roughness, B=metallic) to 3-channel grayscale.
|
||||||
metallic = mr[..., 2:3].expand(-1, -1, -1, 3).contiguous()
|
metallic = mr[..., 2:3].expand(-1, -1, -1, 3).contiguous()
|
||||||
roughness = mr[..., 1:2].expand(-1, -1, -1, 3).contiguous()
|
roughness = mr[..., 1:2].expand(-1, -1, -1, 3).contiguous()
|
||||||
return IO.NodeOutput(base, mr, metallic, roughness)
|
# R is real occlusion only if AO was baked; else it's the unused zero channel, which as
|
||||||
|
# "occlusion" would read fully-dark — so report white unless occlusion_in_mr is set.
|
||||||
|
if getattr(mesh, "occlusion_in_mr", False):
|
||||||
|
occlusion = mr[..., 0:1].expand(-1, -1, -1, 3).contiguous()
|
||||||
|
else:
|
||||||
|
occlusion = torch.ones_like(base)
|
||||||
|
return IO.NodeOutput(base, metallic, roughness, occlusion, normal_map)
|
||||||
|
|
||||||
|
|
||||||
class ApplyTextureToMesh(IO.ComfyNode):
|
class ApplyTextureToMesh(IO.ComfyNode):
|
||||||
@ -966,29 +1351,26 @@ class ApplyTextureToMesh(IO.ComfyNode):
|
|||||||
display_name="Apply Texture to Mesh",
|
display_name="Apply Texture to Mesh",
|
||||||
category="latent/3d",
|
category="latent/3d",
|
||||||
description=(
|
description=(
|
||||||
"Attaches baked texture IMAGEs to a mesh's existing UV layout for SaveGLB. "
|
"Attaches baked texture IMAGEs to a mesh's UV layout for SaveGLB. Feed the SAME mesh you baked"
|
||||||
"Pairs with BakeTextureFromVoxel: feed the SAME mesh and its base_color "
|
|
||||||
"(optionally metallic/roughness); don't re-unwrap in between. metallic/roughness "
|
|
||||||
"repack into the glTF MR map (G=roughness, B=metallic); missing metallic=0, "
|
|
||||||
"roughness=1."
|
|
||||||
),
|
),
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Mesh.Input("mesh"),
|
IO.Mesh.Input("mesh"),
|
||||||
IO.Image.Input("base_color"),
|
IO.Image.Input("base_color"),
|
||||||
IO.Image.Input("metallic", optional=True),
|
IO.Image.Input("metallic", optional=True),
|
||||||
IO.Image.Input("roughness", optional=True),
|
IO.Image.Input("roughness", optional=True),
|
||||||
|
IO.Image.Input("occlusion", optional=True),
|
||||||
|
IO.Image.Input("normal_map", optional=True),
|
||||||
],
|
],
|
||||||
outputs=[IO.Mesh.Output("mesh")],
|
outputs=[IO.Mesh.Output("mesh")],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, mesh, base_color, metallic=None, roughness=None):
|
def execute(cls, mesh, base_color, metallic=None, roughness=None, occlusion=None, normal_map=None):
|
||||||
mesh_uvs = getattr(mesh, "uvs", None)
|
mesh_uvs = getattr(mesh, "uvs", None)
|
||||||
if mesh_uvs is None:
|
if mesh_uvs is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"ApplyTextureToMesh: mesh has no UVs. Connect the same UV-unwrapped mesh "
|
"ApplyTextureToMesh: mesh has no UVs. Connect the same UV-unwrapped mesh "
|
||||||
"you fed to BakeTextureFromVoxel (this node attaches onto existing UVs and "
|
"you fed to BakeTextureFromVoxel.")
|
||||||
"never unwraps).")
|
|
||||||
|
|
||||||
# Re-derive the exact UVs the bake used (shared _normalize_uvs_to_unit), per item.
|
# Re-derive the exact UVs the bake used (shared _normalize_uvs_to_unit), per item.
|
||||||
if mesh_uvs.ndim == 3:
|
if mesh_uvs.ndim == 3:
|
||||||
@ -1005,15 +1387,264 @@ class ApplyTextureToMesh(IO.ComfyNode):
|
|||||||
out_mesh = copy.copy(mesh)
|
out_mesh = copy.copy(mesh)
|
||||||
out_mesh.uvs = new_uvs
|
out_mesh.uvs = new_uvs
|
||||||
out_mesh.texture = base_color.float().clamp(0.0, 1.0).cpu()
|
out_mesh.texture = base_color.float().clamp(0.0, 1.0).cpu()
|
||||||
if metallic is not None or roughness is not None:
|
if normal_map is not None:
|
||||||
# Repack glTF MR (G=roughness, B=metallic); missing channel → scalar (metal 0/rough 1).
|
# Recompute tangents (shared helper, same normalized UVs → same basis as the bake)
|
||||||
prov = (metallic if metallic is not None else roughness).float().clamp(0.0, 1.0).cpu()
|
# and export the smooth normals the TBN was built on — without a NORMAL attribute the
|
||||||
B, H, W, _ = prov.shape
|
# viewer shades flat and the tangent-space detail fights the faceting.
|
||||||
rough_ch = (roughness.float().clamp(0.0, 1.0).cpu()[..., 0:1]
|
dev = comfy.model_management.get_torch_device()
|
||||||
if roughness is not None else torch.ones((B, H, W, 1)))
|
low_n_attr = getattr(mesh, "normals", None)
|
||||||
metal_ch = (metallic.float().clamp(0.0, 1.0).cpu()[..., 0:1]
|
B = int(mesh.vertices.shape[0])
|
||||||
if metallic is not None else torch.zeros((B, H, W, 1)))
|
Nmax = int(mesh.vertices.shape[1]) if mesh.vertices.ndim == 3 else int(mesh.vertices.shape[0])
|
||||||
out_mesh.metallic_roughness = torch.cat([torch.zeros((B, H, W, 1)), rough_ch, metal_ch], dim=-1)
|
tangents_padded = torch.zeros((B, Nmax, 4), dtype=torch.float32)
|
||||||
|
normals_padded = torch.zeros((B, Nmax, 3), dtype=torch.float32)
|
||||||
|
for i in range(B):
|
||||||
|
v_i, f_i, _ = get_mesh_batch_item(mesh, i)
|
||||||
|
n = int(v_i.shape[0])
|
||||||
|
if f_i.numel() == 0:
|
||||||
|
continue
|
||||||
|
lv, lf = v_i.to(dev).float(), f_i.to(dev).long()
|
||||||
|
uv_i = new_uvs[i, :n] if new_uvs.ndim == 3 else new_uvs[:n]
|
||||||
|
n_attr_i = low_n_attr[i, :n] if low_n_attr is not None else None
|
||||||
|
low_n, tangents = _vertex_tangents_for_item(lv, lf, uv_i, n_attr_i, dev)
|
||||||
|
tangents_padded[i, :n] = tangents.cpu()
|
||||||
|
normals_padded[i, :n] = low_n.cpu()
|
||||||
|
out_mesh.normal_map = normal_map.float().clamp(0.0, 1.0).cpu()
|
||||||
|
out_mesh.tangents = tangents_padded
|
||||||
|
out_mesh.normals = normals_padded
|
||||||
|
if metallic is not None or roughness is not None or occlusion is not None:
|
||||||
|
# Pack glTF ORM (R=occlusion, G=roughness, B=metallic); missing → 1/1/0. Maps may
|
||||||
|
# arrive at different resolutions, so resize each channel to a common H×W first.
|
||||||
|
provided = [x for x in (metallic, roughness, occlusion) if x is not None]
|
||||||
|
B = int(provided[0].shape[0])
|
||||||
|
H = max(int(x.shape[1]) for x in provided)
|
||||||
|
W = max(int(x.shape[2]) for x in provided)
|
||||||
|
|
||||||
|
def _chan(img, default):
|
||||||
|
if img is None:
|
||||||
|
return torch.full((B, H, W, 1), float(default))
|
||||||
|
t = img.float().clamp(0.0, 1.0).cpu()[..., 0:1]
|
||||||
|
if int(t.shape[1]) != H or int(t.shape[2]) != W:
|
||||||
|
t = torch.nn.functional.interpolate(t.permute(0, 3, 1, 2), size=(H, W),
|
||||||
|
mode="bilinear", align_corners=False).permute(0, 2, 3, 1)
|
||||||
|
return t
|
||||||
|
|
||||||
|
out_mesh.metallic_roughness = torch.cat(
|
||||||
|
[_chan(occlusion, 1.0), _chan(roughness, 1.0), _chan(metallic, 0.0)], dim=-1)
|
||||||
|
if occlusion is not None:
|
||||||
|
# Tells SaveGLB to also point occlusionTexture at the MR image (R = AO).
|
||||||
|
out_mesh.occlusion_in_mr = True
|
||||||
|
return IO.NodeOutput(out_mesh)
|
||||||
|
|
||||||
|
|
||||||
|
class BakeNormalMapFromMesh(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="BakeNormalMapFromMesh",
|
||||||
|
display_name="Bake Normal Map from Mesh",
|
||||||
|
category="latent/3d",
|
||||||
|
description=(
|
||||||
|
"Bakes a tangent-space normal map (glTF/OpenGL +Y) from a high-poly mesh into a "
|
||||||
|
"low-poly's UV layout, capturing detail lost to decimation. Feed the UV-unwrapped "
|
||||||
|
"low_poly and the same-frame high_poly it was decimated from. Outputs an IMAGE for "
|
||||||
|
"ApplyTextureToMesh's normal_map input."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.Mesh.Input("low_poly"),
|
||||||
|
IO.Mesh.Input("high_poly"),
|
||||||
|
IO.Int.Input("resolution", default=1024, min=64, max=8192, step=64,
|
||||||
|
display_name="resolution"),
|
||||||
|
IO.Float.Input("cage_distance", default=0.05, min=0.001, max=0.5, step=0.001,
|
||||||
|
tooltip="Surface search band, as a fraction of the bbox diagonal. "
|
||||||
|
"Raise for wrong/missing patches under heavy decimation; "
|
||||||
|
"lower if it grabs across gaps."),
|
||||||
|
IO.Boolean.Input("ignore_backfaces", default=True,
|
||||||
|
tooltip="Skip high-poly surfaces facing away from the texel, so "
|
||||||
|
"crevices/enclosed spaces don't grab the opposite wall. "
|
||||||
|
"Disable only if the high-poly winding is inconsistent."),
|
||||||
|
],
|
||||||
|
outputs=[IO.Image.Output(display_name="normal_map")],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, low_poly, high_poly, resolution, cage_distance=0.05, ignore_backfaces=True):
|
||||||
|
low_uvs = getattr(low_poly, "uvs", None)
|
||||||
|
if low_uvs is None:
|
||||||
|
raise ValueError(
|
||||||
|
"BakeNormalMapFromMesh: low_poly has no UVs. Connect the UV-unwrapped "
|
||||||
|
"low-poly (the same one you fed to BakeTextureFromVoxel); this node bakes "
|
||||||
|
"onto existing UVs and never unwraps.")
|
||||||
|
dev = comfy.model_management.get_torch_device()
|
||||||
|
|
||||||
|
low_n_attr = getattr(low_poly, "normals", None)
|
||||||
|
high_n_attr = getattr(high_poly, "normals", None)
|
||||||
|
B = int(low_poly.vertices.shape[0])
|
||||||
|
h_batch = int(high_poly.vertices.shape[0])
|
||||||
|
|
||||||
|
imgs = []
|
||||||
|
for i in range(B):
|
||||||
|
v_i, f_i, _ = get_mesh_batch_item(low_poly, i)
|
||||||
|
n = int(v_i.shape[0])
|
||||||
|
if f_i.numel() == 0:
|
||||||
|
logging.warning(f"BakeNormalMapFromMesh: skipping batch {i} (empty mesh)")
|
||||||
|
imgs.append(torch.full((int(resolution), int(resolution), 3), 0.5))
|
||||||
|
continue
|
||||||
|
|
||||||
|
uv_i = low_uvs[i, :n] if low_uvs.ndim == 3 else low_uvs[:n]
|
||||||
|
uv_np = _normalize_uvs_to_unit(uv_i.detach().cpu().numpy(), log_prefix="[BakeNormalMapFromMesh] ")
|
||||||
|
|
||||||
|
lv = v_i.to(dev).float()
|
||||||
|
lf = f_i.to(dev).long()
|
||||||
|
# Tangents build the per-texel TBN; ApplyTextureToMesh recomputes the same basis on export.
|
||||||
|
n_attr_i = low_n_attr[i, :n] if low_n_attr is not None else None
|
||||||
|
low_n, tangents = _vertex_tangents_for_item(lv, lf, torch.from_numpy(uv_np).to(dev), n_attr_i, dev)
|
||||||
|
|
||||||
|
hv_i, hf_i, _ = get_mesh_batch_item(high_poly, i if h_batch > 1 else 0)
|
||||||
|
hv = hv_i.to(dev).float()
|
||||||
|
hf = hf_i.to(dev).long()
|
||||||
|
high_n = (high_n_attr[i, :hv.shape[0]].to(dev).float() if high_n_attr is not None
|
||||||
|
else _compute_vertex_normals(hv, hf))
|
||||||
|
|
||||||
|
img = _bake_normal_map(
|
||||||
|
hv, hf, high_n,
|
||||||
|
lv.detach().cpu().numpy(), lf.detach().cpu().numpy().astype(np.uint32), uv_np,
|
||||||
|
low_n, tangents, resolution, cage_distance=float(cage_distance),
|
||||||
|
ignore_backfaces=bool(ignore_backfaces),
|
||||||
|
)
|
||||||
|
imgs.append(torch.from_numpy(np.ascontiguousarray(img)).float())
|
||||||
|
|
||||||
|
normal_img = torch.stack([t.clamp(0.0, 1.0) for t in imgs], dim=0)
|
||||||
|
return IO.NodeOutput(normal_img)
|
||||||
|
|
||||||
|
|
||||||
|
class BakeAmbientOcclusion(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="BakeAmbientOcclusion",
|
||||||
|
display_name="Bake Ambient Occlusion",
|
||||||
|
category="latent/3d",
|
||||||
|
description=(
|
||||||
|
"Bakes an ambient-occlusion map from a high-poly mesh into a low-poly's UV "
|
||||||
|
"layout (white = open, dark = crevices). Feed the UV-unwrapped low_poly and the "
|
||||||
|
"high_poly it was decimated from. Outputs a grayscale IMAGE for "
|
||||||
|
"ApplyTextureToMesh's occlusion input (packed into the ORM map / occlusionTexture)."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.Mesh.Input("low_poly"),
|
||||||
|
IO.Mesh.Input("high_poly"),
|
||||||
|
IO.Int.Input("resolution", default=1024, min=64, max=8192, step=64),
|
||||||
|
IO.Int.Input("samples", default=64, min=4, max=1024, step=4,
|
||||||
|
tooltip="Rays per texel. More = smoother, slower. Raise if grainy."),
|
||||||
|
IO.Float.Input("max_distance", default=0.5, min=0.01, max=2.0, step=0.01,
|
||||||
|
tooltip="Ray length, as a fraction of the bbox diagonal. "
|
||||||
|
"Smaller = tighter, more local occlusion."),
|
||||||
|
IO.Float.Input("strength", default=1.0, min=0.0, max=2.0, step=0.05,
|
||||||
|
tooltip="Scales the occlusion. >1 darkens, <1 lightens."),
|
||||||
|
IO.Float.Input("bias", default=0.01, min=0.0001, max=0.2, step=0.0005,
|
||||||
|
tooltip="Ray origin lift off the surface, as a fraction of the bbox "
|
||||||
|
"diagonal. Raise if even surfaces show dark blotches/holes."),
|
||||||
|
],
|
||||||
|
outputs=[IO.Image.Output(display_name="occlusion")],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, low_poly, high_poly, resolution, samples, max_distance, strength, bias):
|
||||||
|
low_uvs = getattr(low_poly, "uvs", None)
|
||||||
|
if low_uvs is None:
|
||||||
|
raise ValueError(
|
||||||
|
"BakeAmbientOcclusion: low_poly has no UVs. Connect the UV-unwrapped low-poly "
|
||||||
|
"(the same one used for the other bakes); this node never unwraps.")
|
||||||
|
dev = comfy.model_management.get_torch_device()
|
||||||
|
low_n_attr = getattr(low_poly, "normals", None)
|
||||||
|
B = int(low_poly.vertices.shape[0])
|
||||||
|
h_batch = int(high_poly.vertices.shape[0])
|
||||||
|
|
||||||
|
pbar = comfy.utils.ProgressBar(max(1, B)) # one tick per batch item
|
||||||
|
imgs = []
|
||||||
|
for i in range(B):
|
||||||
|
v_i, f_i, _ = get_mesh_batch_item(low_poly, i)
|
||||||
|
n = int(v_i.shape[0])
|
||||||
|
if f_i.numel() == 0:
|
||||||
|
logging.warning(f"BakeAmbientOcclusion: skipping batch {i} (empty mesh)")
|
||||||
|
imgs.append(torch.ones((int(resolution), int(resolution), 3)))
|
||||||
|
pbar.update(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
uv_i = low_uvs[i, :n] if low_uvs.ndim == 3 else low_uvs[:n]
|
||||||
|
uv_np = _normalize_uvs_to_unit(uv_i.detach().cpu().numpy(), log_prefix="[BakeAmbientOcclusion] ")
|
||||||
|
lv = v_i.to(dev).float()
|
||||||
|
lf = f_i.to(dev).long()
|
||||||
|
low_n = (low_n_attr[i, :n].to(dev).float() if low_n_attr is not None
|
||||||
|
else _compute_vertex_normals(lv, lf))
|
||||||
|
|
||||||
|
hv_i, hf_i, _ = get_mesh_batch_item(high_poly, i if h_batch > 1 else 0)
|
||||||
|
img = _bake_ambient_occlusion(
|
||||||
|
hv_i.to(dev).float(), hf_i.to(dev).long(),
|
||||||
|
lv.detach().cpu().numpy(), lf.detach().cpu().numpy().astype(np.uint32), uv_np,
|
||||||
|
low_n, resolution, num_samples=int(samples),
|
||||||
|
max_distance=float(max_distance), strength=float(strength), bias=float(bias),
|
||||||
|
)
|
||||||
|
imgs.append(torch.from_numpy(np.ascontiguousarray(img)).float())
|
||||||
|
pbar.update(1)
|
||||||
|
|
||||||
|
ao_img = torch.stack([t.clamp(0.0, 1.0) for t in imgs], dim=0)
|
||||||
|
return IO.NodeOutput(ao_img)
|
||||||
|
|
||||||
|
|
||||||
|
class SetMeshMaterial(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="SetMeshMaterial",
|
||||||
|
display_name="Set Mesh Material",
|
||||||
|
category="latent/3d",
|
||||||
|
description=(
|
||||||
|
"Sets glTF material properties SaveGLB can't derive from textures: emissive "
|
||||||
|
"(color + strength + optional texture), baseColor tint, metallic/roughness "
|
||||||
|
"factors, normal scale, occlusion strength, double-sided. Place before SaveGLB."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.Mesh.Input("mesh"),
|
||||||
|
IO.Float.Input("emissive_r", default=0.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("emissive_g", default=0.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("emissive_b", default=0.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("emissive_strength", default=1.0, min=0.0, max=100.0, step=0.1,
|
||||||
|
tooltip=">1 for HDR glow (KHR_materials_emissive_strength)."),
|
||||||
|
IO.Image.Input("emissive_texture", optional=True),
|
||||||
|
IO.Float.Input("base_color_r", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("base_color_g", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("base_color_b", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Float.Input("metallic_factor", default=-1.0, min=-1.0, max=1.0, step=0.01,
|
||||||
|
tooltip="-1 = leave auto; 0..1 overrides."),
|
||||||
|
IO.Float.Input("roughness_factor", default=-1.0, min=-1.0, max=1.0, step=0.01,
|
||||||
|
tooltip="-1 = leave auto; 0..1 overrides."),
|
||||||
|
IO.Float.Input("normal_scale", default=1.0, min=0.0, max=10.0, step=0.05),
|
||||||
|
IO.Float.Input("occlusion_strength", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||||
|
IO.Boolean.Input("double_sided", default=True),
|
||||||
|
],
|
||||||
|
outputs=[IO.Mesh.Output("mesh")],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, mesh, emissive_r, emissive_g, emissive_b, emissive_strength,
|
||||||
|
base_color_r, base_color_g, base_color_b, metallic_factor, roughness_factor,
|
||||||
|
normal_scale, occlusion_strength, double_sided, emissive_texture=None):
|
||||||
|
out_mesh = copy.copy(mesh)
|
||||||
|
material = dict(getattr(mesh, "material", {}) or {}) # merge over any prior material
|
||||||
|
material.update({
|
||||||
|
"emissive_factor": [float(emissive_r), float(emissive_g), float(emissive_b)],
|
||||||
|
"emissive_strength": float(emissive_strength),
|
||||||
|
"base_color_factor": [float(base_color_r), float(base_color_g), float(base_color_b), 1.0],
|
||||||
|
"metallic_factor": float(metallic_factor), # <0 => leave auto
|
||||||
|
"roughness_factor": float(roughness_factor),
|
||||||
|
"normal_scale": float(normal_scale),
|
||||||
|
"occlusion_strength": float(occlusion_strength),
|
||||||
|
"double_sided": bool(double_sided),
|
||||||
|
})
|
||||||
|
out_mesh.material = material
|
||||||
|
if emissive_texture is not None:
|
||||||
|
out_mesh.emissive = emissive_texture.float().clamp(0.0, 1.0).cpu()
|
||||||
return IO.NodeOutput(out_mesh)
|
return IO.NodeOutput(out_mesh)
|
||||||
|
|
||||||
|
|
||||||
@ -2278,8 +2909,7 @@ class MergeMeshes(IO.ComfyNode):
|
|||||||
category="latent/3d",
|
category="latent/3d",
|
||||||
description=(
|
description=(
|
||||||
"Concatenate N meshes into one by offsetting face indices and stacking verts, "
|
"Concatenate N meshes into one by offsetting face indices and stacking verts, "
|
||||||
"faces, uvs, and colors. E.g. combine a Pixal3D object with a MoGe background "
|
"faces, uvs, and colors."
|
||||||
"into one GLB."
|
|
||||||
),
|
),
|
||||||
inputs=[
|
inputs=[
|
||||||
IO.Autogrow.Input("meshes", template=autogrow_template),
|
IO.Autogrow.Input("meshes", template=autogrow_template),
|
||||||
@ -2306,6 +2936,9 @@ class PostProcessMeshExtension(ComfyExtension):
|
|||||||
BakeTextureFromVoxel,
|
BakeTextureFromVoxel,
|
||||||
MeshTextureToImage,
|
MeshTextureToImage,
|
||||||
ApplyTextureToMesh,
|
ApplyTextureToMesh,
|
||||||
|
BakeNormalMapFromMesh,
|
||||||
|
BakeAmbientOcclusion,
|
||||||
|
SetMeshMaterial,
|
||||||
MergeMeshes,
|
MergeMeshes,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -20,11 +20,11 @@ from comfy_api.latest import ComfyExtension, IO, Types
|
|||||||
|
|
||||||
|
|
||||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, unlit=False,
|
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, unlit=False,
|
||||||
normals=None, metallic_roughness=None):
|
normals=None, metallic_roughness=None, tangents=None, normal_map=None,
|
||||||
# Pack lists of (Nᵢ, *) vertex/face/color/uv tensors into padded batched tensors,
|
occlusion_in_mr=False, material=None, emissive=None):
|
||||||
# stashing per-item lengths as runtime attrs so consumers can recover the real slice.
|
# Pack per-item tensors into padded batches, stashing per-item lengths as runtime attrs.
|
||||||
# colors and uvs are 1:1 with vertices, so they're padded to max_vertices and read with vertex_counts.
|
# colors/uvs/normals/tangents are 1:1 with vertices (padded to max_vertices); texture/
|
||||||
# texture is (B, H, W, 3) — passed through unchanged
|
# metallic_roughness/normal_map are (B,H,W,*) image stacks passed through unchanged.
|
||||||
batch_size = len(vertices)
|
batch_size = len(vertices)
|
||||||
max_vertices = max(v.shape[0] for v in vertices)
|
max_vertices = max(v.shape[0] for v in vertices)
|
||||||
max_faces = max(f.shape[0] for f in faces)
|
max_faces = max(f.shape[0] for f in faces)
|
||||||
@ -65,11 +65,31 @@ def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=Non
|
|||||||
)
|
)
|
||||||
packed_normals[i, :nrm.shape[0]] = nrm
|
packed_normals[i, :nrm.shape[0]] = nrm
|
||||||
|
|
||||||
return Types.MESH(packed_vertices, packed_faces,
|
packed_tangents = None
|
||||||
uvs=packed_uvs, vertex_colors=packed_colors, texture=texture,
|
if tangents is not None:
|
||||||
metallic_roughness=metallic_roughness,
|
packed_tangents = tangents[0].new_zeros((batch_size, max_vertices, tangents[0].shape[1]))
|
||||||
vertex_counts=vertex_counts, face_counts=face_counts, unlit=unlit,
|
for i, tn in enumerate(tangents):
|
||||||
normals=packed_normals)
|
assert tn.shape[0] == vertices[i].shape[0], (
|
||||||
|
f"tangents[{i}] has {tn.shape[0]} entries, expected {vertices[i].shape[0]} (1:1 with vertices)"
|
||||||
|
)
|
||||||
|
packed_tangents[i, :tn.shape[0]] = tn
|
||||||
|
|
||||||
|
out = Types.MESH(packed_vertices, packed_faces,
|
||||||
|
uvs=packed_uvs, vertex_colors=packed_colors, texture=texture,
|
||||||
|
metallic_roughness=metallic_roughness,
|
||||||
|
vertex_counts=vertex_counts, face_counts=face_counts, unlit=unlit,
|
||||||
|
normals=packed_normals)
|
||||||
|
if packed_tangents is not None:
|
||||||
|
out.tangents = packed_tangents
|
||||||
|
if normal_map is not None:
|
||||||
|
out.normal_map = normal_map
|
||||||
|
if occlusion_in_mr:
|
||||||
|
out.occlusion_in_mr = True
|
||||||
|
if material is not None:
|
||||||
|
out.material = material
|
||||||
|
if emissive is not None:
|
||||||
|
out.emissive = emissive
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_mesh_batch_item(mesh, index):
|
def get_mesh_batch_item(mesh, index):
|
||||||
@ -180,7 +200,8 @@ def _compute_vertex_normals(vertices_np, faces_np, crease_angle=None):
|
|||||||
def save_glb(vertices, faces, filepath, metadata=None,
|
def save_glb(vertices, faces, filepath, metadata=None,
|
||||||
uvs=None, vertex_colors=None, texture_image=None,
|
uvs=None, vertex_colors=None, texture_image=None,
|
||||||
metallic_roughness_image=None, unlit=False,
|
metallic_roughness_image=None, unlit=False,
|
||||||
normals=None):
|
normals=None, normal_map_image=None, tangents=None, occlusion_in_mr=False,
|
||||||
|
material=None, emissive_image=None):
|
||||||
"""
|
"""
|
||||||
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
|
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
|
||||||
|
|
||||||
@ -197,6 +218,16 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
normals: torch.Tensor of shape (N, 3) - Optional per-vertex normals, written as the
|
normals: torch.Tensor of shape (N, 3) - Optional per-vertex normals, written as the
|
||||||
glTF NORMAL attribute. When omitted, NO normals are written and viewers fall back
|
glTF NORMAL attribute. When omitted, NO normals are written and viewers fall back
|
||||||
to flat (per-face) shading — use the MeshSmoothNormals node to generate them.
|
to flat (per-face) shading — use the MeshSmoothNormals node to generate them.
|
||||||
|
normal_map_image: PIL.Image - Optional tangent-space normal map (glTF/OpenGL +Y),
|
||||||
|
written as the material normalTexture. Needs TEXCOORD_0.
|
||||||
|
tangents: torch.Tensor of shape (N, 4) - Optional per-vertex tangents (xyz + handedness w),
|
||||||
|
written as the glTF TANGENT attribute. Without it viewers derive tangents in-shader.
|
||||||
|
occlusion_in_mr: bool - When True, R of metallic_roughness_image holds AO (ORM packing) and
|
||||||
|
occlusionTexture is pointed at that same image.
|
||||||
|
material: dict - Optional scalar overrides from SetMeshMaterial (base_color_factor,
|
||||||
|
metallic/roughness_factor with <0 = auto, emissive_factor/strength, normal_scale,
|
||||||
|
occlusion_strength, double_sided).
|
||||||
|
emissive_image: PIL.Image - Optional emissive (glow) texture, written as emissiveTexture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Convert tensors to numpy arrays
|
# Convert tensors to numpy arrays
|
||||||
@ -231,6 +262,11 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"save_glb: normals has {normals_np.shape[0]} entries but vertex count is {n_verts}"
|
f"save_glb: normals has {normals_np.shape[0]} entries but vertex count is {n_verts}"
|
||||||
)
|
)
|
||||||
|
tangents_np = tangents.cpu().numpy().astype(np.float32) if tangents is not None else None
|
||||||
|
if tangents_np is not None and tangents_np.shape != (n_verts, 4):
|
||||||
|
raise ValueError(
|
||||||
|
f"save_glb: tangents must be (N, 4) with N={n_verts}, got {tuple(tangents_np.shape)}"
|
||||||
|
)
|
||||||
faces_np = faces_signed.astype(np.uint32)
|
faces_np = faces_signed.astype(np.uint32)
|
||||||
texture_png_bytes = None
|
texture_png_bytes = None
|
||||||
if texture_image is not None:
|
if texture_image is not None:
|
||||||
@ -242,46 +278,60 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
metallic_roughness_image.save(buf, format="PNG")
|
metallic_roughness_image.save(buf, format="PNG")
|
||||||
mr_png_bytes = buf.getvalue()
|
mr_png_bytes = buf.getvalue()
|
||||||
|
nm_png_bytes = None
|
||||||
|
if normal_map_image is not None:
|
||||||
|
buf = BytesIO()
|
||||||
|
normal_map_image.save(buf, format="PNG")
|
||||||
|
nm_png_bytes = buf.getvalue()
|
||||||
|
em_png_bytes = None
|
||||||
|
if emissive_image is not None:
|
||||||
|
buf = BytesIO()
|
||||||
|
emissive_image.save(buf, format="PNG")
|
||||||
|
em_png_bytes = buf.getvalue()
|
||||||
|
|
||||||
vertices_buffer = vertices_np.tobytes()
|
vertices_buffer = vertices_np.tobytes()
|
||||||
indices_buffer = faces_np.tobytes()
|
indices_buffer = faces_np.tobytes()
|
||||||
uvs_buffer = uvs_np.tobytes() if uvs_np is not None else b""
|
uvs_buffer = uvs_np.tobytes() if uvs_np is not None else b""
|
||||||
colors_buffer = colors_np.tobytes() if colors_np is not None else b""
|
colors_buffer = colors_np.tobytes() if colors_np is not None else b""
|
||||||
normals_buffer = normals_np.tobytes() if normals_np is not None else b""
|
normals_buffer = normals_np.tobytes() if normals_np is not None else b""
|
||||||
|
tangents_buffer = tangents_np.tobytes() if tangents_np is not None else b""
|
||||||
texture_buffer = texture_png_bytes if texture_png_bytes is not None else b""
|
texture_buffer = texture_png_bytes if texture_png_bytes is not None else b""
|
||||||
mr_buffer = mr_png_bytes if mr_png_bytes is not None else b""
|
mr_buffer = mr_png_bytes if mr_png_bytes is not None else b""
|
||||||
|
nm_buffer = nm_png_bytes if nm_png_bytes is not None else b""
|
||||||
|
em_buffer = em_png_bytes if em_png_bytes is not None else b""
|
||||||
|
|
||||||
def pad_to_4_bytes(buffer):
|
def pad_to_4_bytes(buffer):
|
||||||
padding_length = (4 - (len(buffer) % 4)) % 4
|
padding_length = (4 - (len(buffer) % 4)) % 4
|
||||||
return buffer + b'\x00' * padding_length
|
return buffer + b'\x00' * padding_length
|
||||||
|
|
||||||
vertices_buffer_padded = pad_to_4_bytes(vertices_buffer)
|
# Blob order in one place; offsets accumulated in a pass so adding a buffer is one entry.
|
||||||
indices_buffer_padded = pad_to_4_bytes(indices_buffer)
|
_blobs = [
|
||||||
uvs_buffer_padded = pad_to_4_bytes(uvs_buffer)
|
("vertices", vertices_buffer), ("indices", indices_buffer), ("uvs", uvs_buffer),
|
||||||
colors_buffer_padded = pad_to_4_bytes(colors_buffer)
|
("colors", colors_buffer), ("normals", normals_buffer), ("tangents", tangents_buffer),
|
||||||
normals_buffer_padded = pad_to_4_bytes(normals_buffer)
|
("texture", texture_buffer), ("mr", mr_buffer), ("nm", nm_buffer), ("em", em_buffer),
|
||||||
texture_buffer_padded = pad_to_4_bytes(texture_buffer)
|
]
|
||||||
mr_buffer_padded = pad_to_4_bytes(mr_buffer)
|
byte_offset = {}
|
||||||
|
acc = 0
|
||||||
buffer_data = b"".join([
|
parts = []
|
||||||
vertices_buffer_padded,
|
for name, b in _blobs:
|
||||||
indices_buffer_padded,
|
padded = pad_to_4_bytes(b)
|
||||||
uvs_buffer_padded,
|
byte_offset[name] = acc
|
||||||
colors_buffer_padded,
|
acc += len(padded)
|
||||||
normals_buffer_padded,
|
parts.append(padded)
|
||||||
texture_buffer_padded,
|
buffer_data = b"".join(parts)
|
||||||
mr_buffer_padded,
|
|
||||||
])
|
|
||||||
|
|
||||||
vertices_byte_length = len(vertices_buffer)
|
vertices_byte_length = len(vertices_buffer)
|
||||||
vertices_byte_offset = 0
|
|
||||||
indices_byte_length = len(indices_buffer)
|
indices_byte_length = len(indices_buffer)
|
||||||
indices_byte_offset = len(vertices_buffer_padded)
|
vertices_byte_offset = byte_offset["vertices"]
|
||||||
uvs_byte_offset = indices_byte_offset + len(indices_buffer_padded)
|
indices_byte_offset = byte_offset["indices"]
|
||||||
colors_byte_offset = uvs_byte_offset + len(uvs_buffer_padded)
|
uvs_byte_offset = byte_offset["uvs"]
|
||||||
normals_byte_offset = colors_byte_offset + len(colors_buffer_padded)
|
colors_byte_offset = byte_offset["colors"]
|
||||||
texture_byte_offset = normals_byte_offset + len(normals_buffer_padded)
|
normals_byte_offset = byte_offset["normals"]
|
||||||
mr_byte_offset = texture_byte_offset + len(texture_buffer_padded)
|
tangents_byte_offset = byte_offset["tangents"]
|
||||||
|
texture_byte_offset = byte_offset["texture"]
|
||||||
|
mr_byte_offset = byte_offset["mr"]
|
||||||
|
nm_byte_offset = byte_offset["nm"]
|
||||||
|
em_byte_offset = byte_offset["em"]
|
||||||
|
|
||||||
buffer_views = [
|
buffer_views = [
|
||||||
{
|
{
|
||||||
@ -368,6 +418,23 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
})
|
})
|
||||||
primitive_attributes["NORMAL"] = accessor_idx
|
primitive_attributes["NORMAL"] = accessor_idx
|
||||||
|
|
||||||
|
if tangents_np is not None and len(tangents_np) > 0:
|
||||||
|
buffer_views.append({
|
||||||
|
"buffer": 0,
|
||||||
|
"byteOffset": tangents_byte_offset,
|
||||||
|
"byteLength": len(tangents_buffer),
|
||||||
|
"target": 34962
|
||||||
|
})
|
||||||
|
accessor_idx = len(accessors)
|
||||||
|
accessors.append({
|
||||||
|
"bufferView": len(buffer_views) - 1,
|
||||||
|
"byteOffset": 0,
|
||||||
|
"componentType": 5126, # FLOAT
|
||||||
|
"count": len(tangents_np),
|
||||||
|
"type": "VEC4", # xyz tangent + w handedness (glTF TANGENT)
|
||||||
|
})
|
||||||
|
primitive_attributes["TANGENT"] = accessor_idx
|
||||||
|
|
||||||
primitive = {
|
primitive = {
|
||||||
"attributes": primitive_attributes,
|
"attributes": primitive_attributes,
|
||||||
"indices": 1,
|
"indices": 1,
|
||||||
@ -379,9 +446,24 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
samplers = []
|
samplers = []
|
||||||
materials = []
|
materials = []
|
||||||
extensions_used = []
|
extensions_used = []
|
||||||
|
|
||||||
|
def add_image_texture(png_byte_offset, png_byte_length):
|
||||||
|
"""Append an embedded PNG image + a texture referencing it; return the texture index."""
|
||||||
|
buffer_views.append({"buffer": 0, "byteOffset": png_byte_offset, "byteLength": png_byte_length})
|
||||||
|
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
||||||
|
if not samplers:
|
||||||
|
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
||||||
|
textures.append({"source": len(images) - 1, "sampler": 0})
|
||||||
|
return len(textures) - 1
|
||||||
|
|
||||||
|
has_uv = "TEXCOORD_0" in primitive_attributes
|
||||||
if unlit and texture_png_bytes is None:
|
if unlit and texture_png_bytes is None:
|
||||||
# Flat, light-independent shading (KHR_materials_unlit): COLOR_0 is shown as-is, matching how a
|
# Flat, light-independent shading (KHR_materials_unlit): COLOR_0 is shown as-is, matching how a
|
||||||
# gaussian splat renders (emissive). Without this the viewer lights the mesh and washes the colours.
|
# gaussian splat renders (emissive). Without this the viewer lights the mesh and washes the colours.
|
||||||
|
if nm_png_bytes is not None or em_png_bytes is not None or occlusion_in_mr or material is not None:
|
||||||
|
logging.warning(
|
||||||
|
"save_glb: unlit material ignores normal/occlusion/emissive maps and SetMeshMaterial "
|
||||||
|
"overrides — those are PBR-lit features. Disable unlit to export them.")
|
||||||
materials.append({
|
materials.append({
|
||||||
"pbrMetallicRoughness": {"baseColorFactor": [1.0, 1.0, 1.0, 1.0], "metallicFactor": 0.0, "roughnessFactor": 1.0},
|
"pbrMetallicRoughness": {"baseColorFactor": [1.0, 1.0, 1.0, 1.0], "metallicFactor": 0.0, "roughnessFactor": 1.0},
|
||||||
"extensions": {"KHR_materials_unlit": {}},
|
"extensions": {"KHR_materials_unlit": {}},
|
||||||
@ -395,37 +477,57 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
|||||||
"roughnessFactor": 0.5,
|
"roughnessFactor": 0.5,
|
||||||
"baseColorFactor": [0.22, 0.22, 0.22, 1.0],
|
"baseColorFactor": [0.22, 0.22, 0.22, 1.0],
|
||||||
}
|
}
|
||||||
if texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes:
|
if texture_png_bytes is not None and has_uv:
|
||||||
buffer_views.append({
|
pbr["baseColorTexture"] = {"index": add_image_texture(texture_byte_offset, len(texture_buffer)), "texCoord": 0}
|
||||||
"buffer": 0,
|
|
||||||
"byteOffset": texture_byte_offset,
|
|
||||||
"byteLength": len(texture_buffer),
|
|
||||||
})
|
|
||||||
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
|
||||||
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
|
||||||
textures.append({"source": len(images) - 1, "sampler": 0})
|
|
||||||
pbr["baseColorTexture"] = {"index": len(textures) - 1, "texCoord": 0}
|
|
||||||
|
|
||||||
if mr_png_bytes is not None and "TEXCOORD_0" in primitive_attributes:
|
if mr_png_bytes is not None and has_uv:
|
||||||
buffer_views.append({
|
mr_texture_index = add_image_texture(mr_byte_offset, len(mr_buffer))
|
||||||
"buffer": 0,
|
pbr["metallicRoughnessTexture"] = {"index": mr_texture_index, "texCoord": 0}
|
||||||
"byteOffset": mr_byte_offset,
|
|
||||||
"byteLength": len(mr_buffer),
|
|
||||||
})
|
|
||||||
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
|
||||||
if not samplers:
|
|
||||||
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
|
||||||
textures.append({"source": len(images) - 1, "sampler": 0})
|
|
||||||
pbr["metallicRoughnessTexture"] = {"index": len(textures) - 1, "texCoord": 0}
|
|
||||||
# When a metallicRoughness texture is present, the factors scale it; use 1.0
|
# When a metallicRoughness texture is present, the factors scale it; use 1.0
|
||||||
# so the texture values pass through unchanged (glTF convention).
|
# so the texture values pass through unchanged (glTF convention).
|
||||||
pbr["metallicFactor"] = 1.0
|
pbr["metallicFactor"] = 1.0
|
||||||
pbr["roughnessFactor"] = 1.0
|
pbr["roughnessFactor"] = 1.0
|
||||||
|
|
||||||
materials.append({
|
mat = material if isinstance(material, dict) else {}
|
||||||
|
# Scalar overrides from SetMeshMaterial (factor < 0 means "leave auto").
|
||||||
|
if mat.get("base_color_factor") is not None:
|
||||||
|
pbr["baseColorFactor"] = [float(x) for x in mat["base_color_factor"]]
|
||||||
|
if mat.get("metallic_factor", -1.0) >= 0.0:
|
||||||
|
pbr["metallicFactor"] = float(mat["metallic_factor"])
|
||||||
|
if mat.get("roughness_factor", -1.0) >= 0.0:
|
||||||
|
pbr["roughnessFactor"] = float(mat["roughness_factor"])
|
||||||
|
|
||||||
|
material = {
|
||||||
"pbrMetallicRoughness": pbr,
|
"pbrMetallicRoughness": pbr,
|
||||||
"doubleSided": True,
|
"doubleSided": bool(mat.get("double_sided", True)),
|
||||||
})
|
}
|
||||||
|
if occlusion_in_mr and mr_png_bytes is not None and has_uv:
|
||||||
|
# ORM packing: occlusionTexture reuses the MR image (glTF reads its R channel).
|
||||||
|
material["occlusionTexture"] = {"index": mr_texture_index, "texCoord": 0,
|
||||||
|
"strength": float(mat.get("occlusion_strength", 1.0))}
|
||||||
|
if nm_png_bytes is not None and has_uv:
|
||||||
|
material["normalTexture"] = {"index": add_image_texture(nm_byte_offset, len(nm_buffer)),
|
||||||
|
"texCoord": 0, "scale": float(mat.get("normal_scale", 1.0))}
|
||||||
|
|
||||||
|
emissive_factor = [float(x) for x in mat.get("emissive_factor", [0.0, 0.0, 0.0])]
|
||||||
|
emissive_strength = float(mat.get("emissive_strength", 1.0))
|
||||||
|
has_em_tex = em_png_bytes is not None and has_uv
|
||||||
|
if any(c > 0.0 for c in emissive_factor) or has_em_tex:
|
||||||
|
# glTF multiplies emissiveFactor × texture, so a texture with no color would go black;
|
||||||
|
# default the factor to white in that case.
|
||||||
|
if has_em_tex and not any(c > 0.0 for c in emissive_factor):
|
||||||
|
emissive_factor = [1.0, 1.0, 1.0]
|
||||||
|
material["emissiveFactor"] = [min(1.0, c) for c in emissive_factor]
|
||||||
|
if has_em_tex:
|
||||||
|
material["emissiveTexture"] = {"index": add_image_texture(em_byte_offset, len(em_buffer)),
|
||||||
|
"texCoord": 0}
|
||||||
|
if emissive_strength != 1.0:
|
||||||
|
material.setdefault("extensions", {})["KHR_materials_emissive_strength"] = {
|
||||||
|
"emissiveStrength": emissive_strength}
|
||||||
|
if "KHR_materials_emissive_strength" not in extensions_used:
|
||||||
|
extensions_used.append("KHR_materials_emissive_strength")
|
||||||
|
|
||||||
|
materials.append(material)
|
||||||
primitive["material"] = 0
|
primitive["material"] = 0
|
||||||
|
|
||||||
gltf = {
|
gltf = {
|
||||||
@ -556,6 +658,22 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
assert mr_np.ndim == 4 and mr_np.shape[-1] == 3, (
|
assert mr_np.ndim == 4 and mr_np.shape[-1] == 3, (
|
||||||
f"metallic_roughness must be (B, H, W, 3), got shape {tuple(mr_np.shape)}"
|
f"metallic_roughness must be (B, H, W, 3), got shape {tuple(mr_np.shape)}"
|
||||||
)
|
)
|
||||||
|
nm_b = getattr(mesh, "normal_map", None)
|
||||||
|
nm_np = None
|
||||||
|
if nm_b is not None:
|
||||||
|
nm_np = (nm_b.clamp(0.0, 1.0).cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
assert nm_np.ndim == 4 and nm_np.shape[-1] == 3, (
|
||||||
|
f"normal_map must be (B, H, W, 3), got shape {tuple(nm_np.shape)}"
|
||||||
|
)
|
||||||
|
em_b = getattr(mesh, "emissive", None)
|
||||||
|
em_np = None
|
||||||
|
if em_b is not None:
|
||||||
|
em_np = (em_b.clamp(0.0, 1.0).cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
assert em_np.ndim == 4 and em_np.shape[-1] == 3, (
|
||||||
|
f"emissive must be (B, H, W, 3), got shape {tuple(em_np.shape)}"
|
||||||
|
)
|
||||||
|
tangents_b = getattr(mesh, "tangents", None)
|
||||||
|
material = getattr(mesh, "material", None)
|
||||||
for i in range(mesh.vertices.shape[0]):
|
for i in range(mesh.vertices.shape[0]):
|
||||||
vertices_i, faces_i, v_colors, uvs_i, normals_i = get_mesh_batch_item(mesh, i)
|
vertices_i, faces_i, v_colors, uvs_i, normals_i = get_mesh_batch_item(mesh, i)
|
||||||
if vertices_i.shape[0] == 0 or faces_i.shape[0] == 0:
|
if vertices_i.shape[0] == 0 or faces_i.shape[0] == 0:
|
||||||
@ -563,6 +681,9 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
continue
|
continue
|
||||||
tex_img = Image.fromarray(texture_np[i], mode="RGB") if texture_np is not None else None
|
tex_img = Image.fromarray(texture_np[i], mode="RGB") if texture_np is not None else None
|
||||||
mr_img = Image.fromarray(mr_np[i], mode="RGB") if mr_np is not None else None
|
mr_img = Image.fromarray(mr_np[i], mode="RGB") if mr_np is not None else None
|
||||||
|
nm_img = Image.fromarray(nm_np[i], mode="RGB") if nm_np is not None else None
|
||||||
|
em_img = Image.fromarray(em_np[i], mode="RGB") if em_np is not None else None
|
||||||
|
tangents_i = tangents_b[i, :vertices_i.shape[0]] if tangents_b is not None else None
|
||||||
f = f"{filename}_{counter:05}_.glb"
|
f = f"{filename}_{counter:05}_.glb"
|
||||||
save_glb(
|
save_glb(
|
||||||
vertices_i, faces_i,
|
vertices_i, faces_i,
|
||||||
@ -574,6 +695,11 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
metallic_roughness_image=mr_img,
|
metallic_roughness_image=mr_img,
|
||||||
unlit=getattr(mesh, "unlit", False),
|
unlit=getattr(mesh, "unlit", False),
|
||||||
normals=normals_i,
|
normals=normals_i,
|
||||||
|
normal_map_image=nm_img,
|
||||||
|
tangents=tangents_i,
|
||||||
|
occlusion_in_mr=getattr(mesh, "occlusion_in_mr", False),
|
||||||
|
material=material,
|
||||||
|
emissive_image=em_img,
|
||||||
)
|
)
|
||||||
results.append({
|
results.append({
|
||||||
"filename": f,
|
"filename": f,
|
||||||
@ -723,9 +849,11 @@ class MeshSmoothNormals(IO.ComfyNode):
|
|||||||
return IO.NodeOutput(out)
|
return IO.NodeOutput(out)
|
||||||
|
|
||||||
# Crease split changes per-item vertex counts -> rebuild as a variable-size batch.
|
# Crease split changes per-item vertex counts -> rebuild as a variable-size batch.
|
||||||
|
tangents_b = getattr(mesh, "tangents", None)
|
||||||
v_list, f_list, n_list = [], [], []
|
v_list, f_list, n_list = [], [], []
|
||||||
c_list = [] if mesh.vertex_colors is not None else None
|
c_list = [] if mesh.vertex_colors is not None else None
|
||||||
u_list = [] if mesh.uvs is not None else None
|
u_list = [] if mesh.uvs is not None else None
|
||||||
|
t_list = [] if tangents_b is not None else None
|
||||||
for i in range(batch_size):
|
for i in range(batch_size):
|
||||||
v_i, f_i, c_i, u_i, _ = get_mesh_batch_item(mesh, i)
|
v_i, f_i, c_i, u_i, _ = get_mesh_batch_item(mesh, i)
|
||||||
if v_i.shape[0] == 0 or f_i.shape[0] == 0:
|
if v_i.shape[0] == 0 or f_i.shape[0] == 0:
|
||||||
@ -742,12 +870,19 @@ class MeshSmoothNormals(IO.ComfyNode):
|
|||||||
c_list.append(c_i[remap_t.to(c_i.device)])
|
c_list.append(c_i[remap_t.to(c_i.device)])
|
||||||
if u_list is not None:
|
if u_list is not None:
|
||||||
u_list.append(u_i[remap_t.to(u_i.device)])
|
u_list.append(u_i[remap_t.to(u_i.device)])
|
||||||
|
if t_list is not None:
|
||||||
|
# Remap (not recompute) so TANGENT keeps the baked basis; split verts copy theirs.
|
||||||
|
t_i = tangents_b[i, :v_i.shape[0]]
|
||||||
|
t_list.append(t_i[remap_t.to(t_i.device)])
|
||||||
if not v_list:
|
if not v_list:
|
||||||
return IO.NodeOutput(mesh)
|
return IO.NodeOutput(mesh)
|
||||||
out = pack_variable_mesh_batch(
|
out = pack_variable_mesh_batch(
|
||||||
v_list, f_list, colors=c_list, uvs=u_list,
|
v_list, f_list, colors=c_list, uvs=u_list,
|
||||||
texture=mesh.texture, unlit=getattr(mesh, "unlit", False),
|
texture=mesh.texture, unlit=getattr(mesh, "unlit", False),
|
||||||
normals=n_list, metallic_roughness=getattr(mesh, "metallic_roughness", None))
|
normals=n_list, metallic_roughness=getattr(mesh, "metallic_roughness", None),
|
||||||
|
tangents=t_list, normal_map=getattr(mesh, "normal_map", None),
|
||||||
|
occlusion_in_mr=getattr(mesh, "occlusion_in_mr", False),
|
||||||
|
material=getattr(mesh, "material", None), emissive=getattr(mesh, "emissive", None))
|
||||||
return IO.NodeOutput(out)
|
return IO.NodeOutput(out)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from comfy.ldm.trellis2.model import build_proj_transform_matrix, _project_point
|
|||||||
from comfy.ldm.trellis2.naf.model import build_naf_from_state_dict
|
from comfy.ldm.trellis2.naf.model import build_naf_from_state_dict
|
||||||
|
|
||||||
from comfy_extras.nodes_mesh_postprocess import pack_variable_mesh_batch
|
from comfy_extras.nodes_mesh_postprocess import pack_variable_mesh_batch
|
||||||
|
from server import PromptServer
|
||||||
import comfy.latent_formats
|
import comfy.latent_formats
|
||||||
import comfy.model_management
|
import comfy.model_management
|
||||||
import comfy.utils
|
import comfy.utils
|
||||||
@ -414,38 +415,44 @@ class Trellis2UpsampleStage(IO.ComfyNode):
|
|||||||
"y_up" if proj_pack is not None else "z_up")}
|
"y_up" if proj_pack is not None else "z_up")}
|
||||||
return IO.NodeOutput(positive_out, negative_out, out_latent)
|
return IO.NodeOutput(positive_out, negative_out, out_latent)
|
||||||
|
|
||||||
dino_mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
|
def _dinov3_encode(model, image_bchw, image_size, want_patches=False):
|
||||||
dino_std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
|
"""Run DINOv3 once at the requested resolution.
|
||||||
|
|
||||||
def run_conditioning(model, cropped_img_tensor, include_1024=True):
|
image_bchw: [B, 3, H, W] float in [0, 1] (any source resolution; resized here).
|
||||||
|
Returns the full sequence tensor (Trellis2 path) or a dict with the global
|
||||||
|
tokens split out + a 2D patch grid (Pixal3D path) when `want_patches=True`.
|
||||||
|
"""
|
||||||
model_internal = model.model
|
model_internal = model.model
|
||||||
|
device = comfy.model_management.get_torch_device()
|
||||||
|
img_t = comfy.utils.common_upscale(image_bchw, image_size, image_size, "lanczos", "disabled").to(device)
|
||||||
|
mean = torch.tensor(model.image_mean or [0.485, 0.456, 0.406], device=device).view(1, 3, 1, 1)
|
||||||
|
std = torch.tensor(model.image_std or [0.229, 0.224, 0.225], device=device).view(1, 3, 1, 1)
|
||||||
|
img_t = (img_t - mean) / std
|
||||||
|
model_internal.image_size = image_size
|
||||||
|
tokens = model_internal(img_t, skip_norm_elementwise=True)[0]
|
||||||
|
if not want_patches:
|
||||||
|
return tokens
|
||||||
|
h_p = w_p = image_size // 16
|
||||||
|
n_reg = tokens.shape[1] - 1 - h_p * w_p
|
||||||
|
return {"tokens": tokens[:, :1 + n_reg], "patches_2d": _dinov3_patches_to_2d(tokens, image_size)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_conditioning(model, cropped_pil_img, include_1024=True):
|
||||||
device = comfy.model_management.intermediate_device()
|
device = comfy.model_management.intermediate_device()
|
||||||
torch_device = comfy.model_management.get_torch_device()
|
|
||||||
|
|
||||||
def prepare_tensor(pil_img, size):
|
img_np = np.array(cropped_pil_img).astype(np.float32) / 255.0
|
||||||
resized_pil = pil_img.resize((size, size), Image.Resampling.LANCZOS)
|
image_bchw = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).contiguous()
|
||||||
img_np = np.array(resized_pil).astype(np.float32) / 255.0
|
|
||||||
img_t = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(torch_device)
|
|
||||||
return (img_t - dino_mean.to(torch_device)) / dino_std.to(torch_device)
|
|
||||||
|
|
||||||
model_internal.image_size = 512
|
|
||||||
input_512 = prepare_tensor(cropped_img_tensor, 512)
|
|
||||||
cond_512 = model_internal(input_512, skip_norm_elementwise=True)[0]
|
|
||||||
|
|
||||||
cond_1024 = None
|
|
||||||
if include_1024:
|
|
||||||
model_internal.image_size = 1024
|
|
||||||
input_1024 = prepare_tensor(cropped_img_tensor, 1024)
|
|
||||||
cond_1024 = model_internal(input_1024, skip_norm_elementwise=True)[0]
|
|
||||||
|
|
||||||
|
cond_512 = _dinov3_encode(model, image_bchw, 512)
|
||||||
conditioning = {
|
conditioning = {
|
||||||
'cond_512': cond_512.to(device),
|
"cond_512": cond_512.to(device),
|
||||||
'neg_cond': torch.zeros_like(cond_512).to(device),
|
"neg_cond": torch.zeros_like(cond_512).to(device),
|
||||||
}
|
}
|
||||||
if cond_1024 is not None:
|
if include_1024:
|
||||||
conditioning['cond_1024'] = cond_1024.to(device)
|
cond_1024 = _dinov3_encode(model, image_bchw, 1024)
|
||||||
|
conditioning["cond_1024"] = cond_1024.to(device)
|
||||||
return conditioning
|
return conditioning
|
||||||
|
|
||||||
class Trellis2Conditioning(IO.ComfyNode):
|
class Trellis2Conditioning(IO.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
@ -780,21 +787,6 @@ def _dinov3_patches_to_2d(tokens, image_size, patch_size=16):
|
|||||||
return patches.transpose(1, 2).reshape(tokens.shape[0], -1, h_p, w_p).contiguous()
|
return patches.transpose(1, 2).reshape(tokens.shape[0], -1, h_p, w_p).contiguous()
|
||||||
|
|
||||||
|
|
||||||
def _run_dinov3_with_patches(model, composite, image_size):
|
|
||||||
model_internal = model.model
|
|
||||||
torch_device = comfy.model_management.get_torch_device()
|
|
||||||
img_t = comfy.utils.common_upscale(composite, image_size, image_size, "lanczos", "disabled")
|
|
||||||
img_t = img_t.to(torch_device)
|
|
||||||
img_t = (img_t - dino_mean.to(torch_device)) / dino_std.to(torch_device)
|
|
||||||
model_internal.image_size = image_size
|
|
||||||
tokens = model_internal(img_t, skip_norm_elementwise=True)[0]
|
|
||||||
patches = _dinov3_patches_to_2d(tokens, image_size)
|
|
||||||
h_p = w_p = image_size // 16
|
|
||||||
n_reg = tokens.shape[1] - 1 - h_p * w_p
|
|
||||||
global_tokens = tokens[:, :1 + n_reg]
|
|
||||||
return {"tokens": global_tokens, "patches_2d": patches}
|
|
||||||
|
|
||||||
|
|
||||||
def _crop_image_with_mask(item_image, item_mask, max_image_size=1024):
|
def _crop_image_with_mask(item_image, item_mask, max_image_size=1024):
|
||||||
img = item_image.permute(2, 0, 1).unsqueeze(0).cpu().float()
|
img = item_image.permute(2, 0, 1).unsqueeze(0).cpu().float()
|
||||||
mask = item_mask.unsqueeze(0).unsqueeze(0).cpu().float()
|
mask = item_mask.unsqueeze(0).unsqueeze(0).cpu().float()
|
||||||
@ -802,6 +794,12 @@ def _crop_image_with_mask(item_image, item_mask, max_image_size=1024):
|
|||||||
img = (img.clamp(0, 1) * 255.0).to(torch.uint8).float() / 255.0
|
img = (img.clamp(0, 1) * 255.0).to(torch.uint8).float() / 255.0
|
||||||
mask = (mask.clamp(0, 1) * 255.0).to(torch.uint8).float() / 255.0
|
mask = (mask.clamp(0, 1) * 255.0).to(torch.uint8).float() / 255.0
|
||||||
|
|
||||||
|
# Detect & correct an inverted mask
|
||||||
|
m2d = mask[0, 0]
|
||||||
|
border = torch.cat([m2d[0, :], m2d[-1, :], m2d[:, 0], m2d[:, -1]])
|
||||||
|
if float(border.mean()) > 0.5:
|
||||||
|
mask = 1.0 - mask
|
||||||
|
|
||||||
H, W = img.shape[-2:]
|
H, W = img.shape[-2:]
|
||||||
if max(H, W) > max_image_size:
|
if max(H, W) > max_image_size:
|
||||||
scale = max_image_size / max(H, W)
|
scale = max_image_size / max(H, W)
|
||||||
@ -923,8 +921,8 @@ class Pixal3DConditioning(IO.ComfyNode):
|
|||||||
scene_size_list.append(scene_size)
|
scene_size_list.append(scene_size)
|
||||||
composite_list.append(composite)
|
composite_list.append(composite)
|
||||||
|
|
||||||
cond_512 = _run_dinov3_with_patches(clip_vision_model, composite, 512)
|
cond_512 = _dinov3_encode(clip_vision_model, composite, 512, want_patches=True)
|
||||||
cond_1024 = _run_dinov3_with_patches(clip_vision_model, composite, 1024)
|
cond_1024 = _dinov3_encode(clip_vision_model, composite, 1024, want_patches=True)
|
||||||
cond_512_list.append(cond_512["tokens"].to(device))
|
cond_512_list.append(cond_512["tokens"].to(device))
|
||||||
cond_1024_list.append(cond_1024["tokens"].to(device))
|
cond_1024_list.append(cond_1024["tokens"].to(device))
|
||||||
patches_512_list.append(cond_512["patches_2d"].to(device))
|
patches_512_list.append(cond_512["patches_2d"].to(device))
|
||||||
@ -1104,8 +1102,7 @@ class Pixal3DAlignObject(IO.ComfyNode):
|
|||||||
moge_per_vertex = moge_points[batch_index, sy, sx]
|
moge_per_vertex = moge_points[batch_index, sy, sx]
|
||||||
# MoGe's perspective output is (X right, Y down, Z forward). Convert to glTF
|
# MoGe's perspective output is (X right, Y down, Z forward). Convert to glTF
|
||||||
# Y-up (X right, Y up, Z back) so the scale/translate fit runs in the same
|
# Y-up (X right, Y up, Z back) so the scale/translate fit runs in the same
|
||||||
# frame as vertices_one (Pixal3D model frame = glTF Y-up). Mirrors the
|
# frame as vertices_one (Pixal3D model frame = glTF Y-up).
|
||||||
# `verts * [1, -1, -1]` step in MoGePointMapToMesh.
|
|
||||||
moge_per_vertex = moge_per_vertex * torch.tensor(
|
moge_per_vertex = moge_per_vertex * torch.tensor(
|
||||||
[1.0, -1.0, -1.0], dtype=moge_per_vertex.dtype, device=moge_per_vertex.device
|
[1.0, -1.0, -1.0], dtype=moge_per_vertex.dtype, device=moge_per_vertex.device
|
||||||
)
|
)
|
||||||
@ -1188,6 +1185,7 @@ class GetMeshInfo(IO.ComfyNode):
|
|||||||
IO.Mesh.Output(display_name="mesh"),
|
IO.Mesh.Output(display_name="mesh"),
|
||||||
IO.String.Output(display_name="info"),
|
IO.String.Output(display_name="info"),
|
||||||
],
|
],
|
||||||
|
hidden=[IO.Hidden.unique_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -1212,10 +1210,10 @@ class GetMeshInfo(IO.ComfyNode):
|
|||||||
f_counts = [int(mesh.faces.shape[1])] * B
|
f_counts = [int(mesh.faces.shape[1])] * B
|
||||||
|
|
||||||
attrs = []
|
attrs = []
|
||||||
for name in ("uvs", "vertex_colors", "normals", "texture", "metallic_roughness"):
|
for name in ("uvs", "vertex_colors", "normals", "tangents", "texture", "metallic_roughness", "normal_map"):
|
||||||
t = getattr(mesh, name, None)
|
t = getattr(mesh, name, None)
|
||||||
if t is not None:
|
if t is not None:
|
||||||
if name in ("texture", "metallic_roughness"):
|
if name in ("texture", "metallic_roughness", "normal_map"):
|
||||||
attrs.append(f"{name} {int(t.shape[-3])}×{int(t.shape[-2])}") # H×W
|
attrs.append(f"{name} {int(t.shape[-3])}×{int(t.shape[-2])}") # H×W
|
||||||
else:
|
else:
|
||||||
attrs.append(name)
|
attrs.append(name)
|
||||||
@ -1234,6 +1232,9 @@ class GetMeshInfo(IO.ComfyNode):
|
|||||||
|
|
||||||
info = "\n".join(lines)
|
info = "\n".join(lines)
|
||||||
logging.info("[GetMeshInfo]\n%s", info)
|
logging.info("[GetMeshInfo]\n%s", info)
|
||||||
|
|
||||||
|
if cls.hidden.unique_id:
|
||||||
|
PromptServer.instance.send_progress_text(info, cls.hidden.unique_id)
|
||||||
return IO.NodeOutput(mesh, info, ui=UI.PreviewText(info))
|
return IO.NodeOutput(mesh, info, ui=UI.PreviewText(info))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user