# Stage 42: add muscle definition — abs, obliques, quads, calves, delts, biceps/triceps — into the # MAPS, not the mesh. Geometry is untouched, so this is fully reversible and costs no vertices. # # blender --background --python 42_musculature.py -- [out.glb] [strength] # # WHY THE NORMAL MAP AND NOT PAINTED SHADING. Darkening the albedo to suggest a muscle bakes one # lighting direction into the skin: it reads as dirt the moment the key light moves, and it is # wrong in every pose. A normal-map perturbation is relief — it lights correctly from any angle. # The albedo gets only a faint cavity term (valleys slightly darker), which is what subsurface # actually does and which survives relighting. # # WHY THIS WORKS CLEANLY IN THE NEW ATLAS. A tangent-space normal is the surface gradient # expressed in (u,v). Take the gradient of a height field in atlas space and you have exactly # that — but only if texel density is uniform, otherwise the same slope means different things in # different charts. The 14-chart atlas measures 1.00x spread, so one global scale is correct # everywhere. On Tripo's 1.8x-spread soup this would have needed per-chart correction. # # The anatomy is built as a per-vertex height field in BODY-FRAME coordinates (height fraction, # left-right, front-back), so it lands on the right muscles regardless of mesh density, and is # rasterised through the same barycentric path every other stage here uses. # # EXCLUDED ON PURPOSE: the breast region and the crotch. Those were re-authored featureless to a # brief; muscle relief must not put detail back into them. import bpy, sys, os, time import numpy as np argv = sys.argv[sys.argv.index("--") + 1:] BLEND, OUT = argv[0], argv[1] GLB = next((a for a in argv[2:] if a.lower().endswith(".glb")), "") STRENGTH = float(next((a for a in argv[2:] if not a.lower().endswith(".glb")), "1.0")) t0 = time.time() SPINE = float(os.environ.get('SPINE', '0.34')) V08_EXTRAS = False # v08's added anatomy was rejected; v07's set is the base AMP_MM = 3.2 # peak relief of the muscle field, in millimetres CAVITY = 0.055 # how much the albedo darkens in the valleys (0 = none) def log(m): print(f"[mus {time.time()-t0:6.1f}s] {m}", flush=True) bpy.ops.wm.open_mainfile(filepath=BLEND) ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) me = ob.data n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons) co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3) lo, hi = co.min(axis=0), co.max(axis=0) span = hi - lo UNITM = 1.777 / span[2] MM = UNITM * 1000.0 u = (co[:, 2] - lo[2]) / span[2] # 0 feet .. 1 head x = co[:, 0] - 0.5 * (lo[0] + hi[0]) # left(+) / right(-) y = co[:, 1] - 0.5 * (lo[1] + hi[1]) # front is NEGATIVE HALF = 0.5 * span[0] sn = x / HALF front = y < 0 log(f"{n_v}v 1 unit = {MM:.1f} mm half-span {HALF:.4f}") def bump(v, c, s): return np.exp(-0.5 * ((v - c) / s) ** 2) def sstep(t): t = np.clip(t, 0.0, 1.0) return t * t * (3.0 - 2.0 * t) def band(v, a, b, soft): """Smoothstep window, NOT a linear ramp. A normal map is built from the DERIVATIVE of the height field, so any kink in the field draws a visible line. A linear clip ramp has a derivative that jumps at both ends of the window, and that is exactly what put contour lines down her thighs and shins in the first pass. Smoothstep reaches each end with zero slope, so windows fade out invisibly. """ return sstep((v - a) / soft) * sstep((b - v) / soft) def ridge(v, c, s): """a soft-shouldered ridge/groove profile, also C1-continuous""" return np.exp(-0.5 * ((v - c) / s) ** 2) # The field is evaluated PER TEXEL, not per vertex. Building it on vertices and interpolating # barycentrically was the first attempt and it produced crumpled-paper noise: the mesh has ~10 mm # edges, an ab block is 2-3 vertices across, and interpolating a per-vertex field makes its # gradient piecewise-constant per triangle — so the normal map followed the TRIANGULATION rather # than the anatomy. Rasterising position and evaluating the anatomy at texel resolution fixes it # at the source. def muscle_field(u, x, y, sn): front = y < 0.0 # Smooth front/back blend, NOT a boolean. `y < 0` terminates every front- or back-only field # abruptly at the silhouette, and since the normal map is the derivative of the field, that # step drew a line straight down the side of each limb — the sickle marks on her shins. fr = sstep((0.024 - y) / 0.048) h = np.zeros_like(u) # rectus abdominis — heights measured off this mesh's front-midline profile NAVEL = 0.560 torso_f = fr * band(np.abs(sn), -1.0, 0.35, 0.07) ab_win = torso_f * band(u, 0.470, 0.668, 0.045) * band(np.abs(x), 0.0, 0.064, 0.022) col = bump(np.abs(x), 0.027, 0.015) linea = -1.30 * bump(x, 0.0, 0.0055) rows = np.ones_like(u) for uc in (0.505, 0.600, 0.634): rows -= 1.15 * bump(u, uc, 0.0065) rows -= 1.35 * bump(u, NAVEL, 0.0085) h += 1.00 * ab_win * (col * np.clip(rows, -1.5, 1.0) + linea) # obliques ob_win = torso_f * band(u, 0.480, 0.648, 0.050) * band(np.abs(x), 0.056, 0.100, 0.020) h += 0.60 * ob_win * bump(np.abs(x) - 0.18 * (u - 0.48), 0.072, 0.018) # serratus se_win = torso_f * band(u, 0.628, 0.700, 0.035) * band(np.abs(x), 0.050, 0.095, 0.020) h += 0.32 * se_win * np.cos((np.abs(x) * 26.0 + u * 34.0) * np.pi) # leg centre line, MEASURED (24_seams.py) — used by quads, hamstrings, calves and knee # quadriceps — centred on the leg's MEASURED centre line, not a guess. Her stance has the # feet apart, so each leg's centre moves OUTWARD going down (x 0.037 at the hip to 0.106 at # the ankle). An earlier version had it drifting the other way, which laid the muscle bellies # along the edges of the thigh and left contour lines down the leg. legc = np.interp(u, [0.08, 0.17, 0.25, 0.33, 0.41], [0.1064, 0.0921, 0.0911, 0.0671, 0.0375]) ctr = np.abs(x) - legc q_win = fr * band(u, 0.185, 0.400, 0.075) h += 0.62 * q_win * (bump(ctr, -0.030, 0.022) + 0.85 * bump(ctr, 0.028, 0.022) - 0.70 * bump(ctr, 0.0, 0.014)) # calves, on the back of the lower leg c_win = (1.0 - fr) * band(u, 0.095, 0.225, 0.055) h += 0.55 * c_win * (bump(ctr, -0.024, 0.019) + 0.80 * bump(ctr, 0.022, 0.019)) # ---- back: without these the whole rear reads as a blank, which is what "smooth mannequin" # looked like. Spinal channel, lats sweeping down to the waist, trapezius over the shoulders. bk = (1.0 - fr) * band(np.abs(sn), -1.0, 0.35, 0.07) # a real spinal furrow fades out at the sacrum; running it to 0.470 pushed it down # into the glute cleft, where it met the crease already in the mesh and read ragged h -= SPINE * bk * band(u, 0.520, 0.795, 0.075) * bump(x, 0.0, 0.019) # spinal furrow lat = bk * band(u, 0.560, 0.760, 0.055) * band(np.abs(x), 0.030, 0.105, 0.022) h += 0.52 * lat * bump(np.abs(x) - 0.10 * (0.76 - u), 0.062, 0.024) # latissimus h += 0.40 * bk * band(u, 0.720, 0.815, 0.035) * band(np.abs(x), 0.0, 0.090, 0.030) # traps # deltoids h += 0.50 * band(np.abs(sn), 0.30, 0.46, 0.06) * band(u, 0.695, 0.805, 0.040) # biceps / triceps h += 0.42 * band(np.abs(sn), 0.40, 0.62, 0.08) * np.where(front, 1.0, 0.85) \ * bump(u, 0.715, 0.026) if V08_EXTRAS: # ---- clavicles: two ridges sweeping out from the sternal notch -------------------- cl = fr * band(u, 0.780, 0.822, 0.016) * band(np.abs(x), 0.008, 0.105, 0.022) h += 0.55 * cl * ridge(u - 0.797 - 0.055 * np.abs(x), 0.0, 0.0075) # ---- lower rib arch: the inverted V under the sternum ----------------------------- ra = fr * band(u, 0.630, 0.690, 0.022) * band(np.abs(x), 0.010, 0.075, 0.020) h -= 0.40 * ra * ridge(u - 0.688 + 0.72 * np.abs(x), 0.0, 0.0070) # ---- inguinal furrow: the "V" from the hip points down toward the pubis ------------ ing = fr * band(u, 0.425, 0.520, 0.030) * band(np.abs(x), 0.012, 0.090, 0.022) h -= 0.85 * ing * ridge(u - 0.428 - 0.92 * np.abs(x), 0.0, 0.0085) # ---- glutes: two masses, a central cleft, and the fold under them ------------------ gl = (1.0 - fr) * band(u, 0.405, 0.530, 0.035) * band(np.abs(x), 0.0, 0.125, 0.030) h += 0.60 * gl * ridge(np.abs(x), 0.062, 0.038) h -= 0.55 * (1.0 - fr) * band(u, 0.400, 0.545, 0.040) * ridge(x, 0.0, 0.011) h -= 0.45 * (1.0 - fr) * band(np.abs(x), 0.020, 0.110, 0.030) * ridge(u, 0.408, 0.0090) # ---- hamstrings: two bellies down the back of the thigh --------------------------- hm = (1.0 - fr) * band(u, 0.245, 0.395, 0.045) h += 0.45 * hm * (ridge(ctr, -0.026, 0.020) + ridge(ctr, 0.026, 0.020) - 0.60 * ridge(ctr, 0.0, 0.013)) # ---- patella, tibialis, achilles -------------------------------------------------- h += 0.38 * fr * band(u, 0.196, 0.238, 0.020) * ridge(ctr, 0.0, 0.026) h += 0.30 * fr * band(u, 0.100, 0.195, 0.038) * ridge(ctr, -0.014, 0.017) h -= 0.35 * (1.0 - fr) * band(u, 0.078, 0.120, 0.024) * ridge(ctr, 0.0, 0.014) # ---- forearm flexor mass ---------------------------------------------------------- h += 0.30 * band(np.abs(sn), 0.615, 0.735, 0.055) * ridge(u, 0.700, 0.030) # never put detail back into what was deliberately made featureless breast = fr * band(u, 0.632, 0.782, 0.028) * band(np.abs(x), 0.0, 0.118, 0.028) crotch = band(np.abs(x), -1.0, 0.085, 0.022) * band(u, 0.330, 0.480, 0.030) return h * np.clip(1.0 - np.maximum(breast, crotch), 0.0, 1.0) # ---- rasterise the height field into the atlas ------------------------------------------- src = {} for slot in ob.material_slots: if not slot.material or not slot.material.node_tree: continue for node in slot.material.node_tree.nodes: if node.type != 'BSDF_PRINCIPLED': continue for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")): if sock not in node.inputs or not node.inputs[sock].links: continue nd = node.inputs[sock].links[0].from_node seen = set() while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen: seen.add(id(nd)) nxt = None for i in nd.inputs: if i.links: nxt = i.links[0].from_node break nd = nxt if nd and nd.type == 'TEX_IMAGE' and nd.image: src[key] = nd.image W, H = src["base"].size loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v) uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2) ls = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", ls) lt = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", lt) li = ls[lt == 3] IDX = np.stack([li, li + 1, li + 2], axis=1) V = loops_v[IDX] P = np.stack([np.clip(uv[IDX][:, :, 0], 0, 1) * (W - 1), np.clip(uv[IDX][:, :, 1], 0, 1) * (H - 1)], axis=2) # Per-face tangent frames. The normal has to be built from a 3D gradient projected into these, # not from a gradient taken in atlas space — see the note at the gradient below. E1 = co[V[:, 1]] - co[V[:, 0]] E2 = co[V[:, 2]] - co[V[:, 0]] UVt = uv[IDX] D1 = UVt[:, 1] - UVt[:, 0] D2 = UVt[:, 2] - UVt[:, 0] detf = D1[:, 0] * D2[:, 1] - D2[:, 0] * D1[:, 1] safef = np.where(np.abs(detf) < 1e-20, 1.0, detf) Tf = (E1 * D2[:, 1:2] - E2 * D1[:, 1:2]) / safef[:, None] Bf = (E2 * D1[:, 0:1] - E1 * D2[:, 0:1]) / safef[:, None] Nf = np.cross(E1, E2) Nf /= np.maximum(np.linalg.norm(Nf, axis=1, keepdims=True), 1e-20) Tf = Tf - Nf * (Nf * Tf).sum(axis=1, keepdims=True) Tf /= np.maximum(np.linalg.norm(Tf, axis=1, keepdims=True), 1e-20) Bx = np.cross(Nf, Tf) Bf = Bx * np.sign((Bx * Bf).sum(axis=1))[:, None] Pmap = np.zeros((H, W, 3)) Tmap = np.zeros((H, W, 3)) Bmap = np.zeros((H, W, 3)) cov = np.zeros((H, W), dtype=bool) for f in range(len(P)): p3 = P[f] x0, x1 = int(p3[:, 0].min()), int(np.ceil(p3[:, 0].max())) y0, y1 = int(p3[:, 1].min()), int(np.ceil(p3[:, 1].max())) if x1 < x0 or y1 < y0 or x1 - x0 > 512 or y1 - y0 > 512: continue det = ((p3[1, 1] - p3[2, 1]) * (p3[0, 0] - p3[2, 0]) + (p3[2, 0] - p3[1, 0]) * (p3[0, 1] - p3[2, 1])) if abs(det) < 1e-12: continue gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1), np.arange(max(y0, 0), min(y1, H - 1) + 1)) if gx.size == 0: continue a = ((p3[1, 1] - p3[2, 1]) * (gx - p3[2, 0]) + (p3[2, 0] - p3[1, 0]) * (gy - p3[2, 1])) / det b = ((p3[2, 1] - p3[0, 1]) * (gx - p3[2, 0]) + (p3[0, 0] - p3[2, 0]) * (gy - p3[2, 1])) / det c = 1.0 - a - b ins = (a >= -0.02) & (b >= -0.02) & (c >= -0.02) if not ins.any(): continue Pmap[gy[ins], gx[ins]] = (a[ins, None] * co[V[f, 0]] + b[ins, None] * co[V[f, 1]] + c[ins, None] * co[V[f, 2]]) Tmap[gy[ins], gx[ins]] = Tf[f] Bmap[gy[ins], gx[ins]] = Bf[f] cov[gy[ins], gx[ins]] = True log(f"rasterised position into {int(cov.sum())} texels") # pad position outward first, so the field is defined a little past every chart border and the # gradient near a seam differentiates against real anatomy rather than against zero havep = cov.copy() for _ in range(8): Wf = havep.astype(np.float64) acc = np.zeros_like(Pmap); wac = np.zeros((H, W)) for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): acc += np.roll(Pmap * Wf[:, :, None], (dy, dx), axis=(0, 1)) wac += np.roll(Wf, (dy, dx), axis=(0, 1)) new = (~havep) & (wac > 0) if not new.any(): break Pmap[new] = acc[new] / wac[new, None] for M_ in (Tmap, Bmap): accm = np.zeros_like(M_) for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): accm += np.roll(M_ * Wf[:, :, None], (dy, dx), axis=(0, 1)) M_[new] = accm[new] / wac[new, None] havep |= new # evaluate the anatomy at texel resolution CX = 0.5 * (lo[0] + hi[0]) CY = 0.5 * (lo[1] + hi[1]) def h_at(P): xx = P[..., 0] - CX return muscle_field((P[..., 2] - lo[2]) / span[2], xx, P[..., 1] - CY, xx / HALF) Hraw = h_at(Pmap) peak = np.abs(Hraw[cov]).max() scale = (AMP_MM * STRENGTH) / peak if peak > 1e-9 else 0.0 Himg = Hraw * scale log(f"height field per texel: {Himg[cov].min():+.3f} .. {Himg[cov].max():+.3f} mm, " f"{int((np.abs(Himg) > 0.1)[cov].sum())} texels with relief") # ---- height -> tangent-space normal, via a THREE-DIMENSIONAL gradient -------------------- # The previous version differentiated the height map in ATLAS space. That is valid inside a # chart, but the spinal groove sits at x = 0, which is exactly where 24_seams.py cuts the back # midline — so the two sides of the spine live on opposite EDGES of the torso chart. At those # edges the atlas-space difference reaches into padding rather than across the body, and the # groove came out as a hard ragged line down her whole back. # # Differentiating the anatomy in 3D and projecting into each texel's own tangent frame never # asks the atlas who a texel's neighbour is, so it is seam-correct by construction — for the # spine and equally for the inner-leg and arm seams. for _ in range(3): for M_ in (Tmap, Bmap): sm = np.zeros_like(M_) for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1), (0, 0)): sm += np.roll(M_, (dy, dx), axis=(0, 1)) M_[havep] = (sm / 5.0)[havep] for M_ in (Tmap, Bmap): M_ /= np.maximum(np.linalg.norm(M_, axis=2, keepdims=True), 1e-20) sel = np.nonzero(havep) Pm = Pmap[sel] EPS = 0.0009 # ~1.6 mm, well inside the narrowest groove grad = np.empty((len(Pm), 3)) for ax in range(3): dp = np.zeros(3) dp[ax] = EPS grad[:, ax] = (h_at(Pm + dp) - h_at(Pm - dp)) / (2.0 * EPS) grad *= scale / MM # mm of relief per mm of surface = a true slope Tm = Tmap[sel] Bm = Bmap[sel] Tm /= np.maximum(np.linalg.norm(Tm, axis=1, keepdims=True), 1e-20) Bm /= np.maximum(np.linalg.norm(Bm, axis=1, keepdims=True), 1e-20) sT = (grad * Tm).sum(axis=1) sB = (grad * Bm).sum(axis=1) nx = np.zeros((H, W)); ny = np.zeros((H, W)); nz = np.ones((H, W)) ln = np.sqrt(sT * sT + sB * sB + 1.0) nx[sel] = -sT / ln ny[sel] = -sB / ln nz[sel] = 1.0 / ln log(f"slope: p99 {np.percentile(np.abs(sT), 99):.4f}, " f"max normal tilt {np.degrees(np.arccos(nz[cov].min())):.1f} deg") im = src["normal"] b2 = np.empty(W * H * 4, dtype=np.float32); im.pixels.foreach_get(b2) arr = b2.reshape(H, W, 4) base_n = arr[:, :, :3].astype(np.float64) * 2.0 - 1.0 # combine: add the muscle slope to whatever detail is already there, then renormalise cx = base_n[:, :, 0] + nx cy = base_n[:, :, 1] + ny cz = np.maximum(base_n[:, :, 2], 0.05) cl = np.sqrt(cx * cx + cy * cy + cz * cz) out_n = np.stack([cx / cl, cy / cl, cz / cl], axis=2) * 0.5 + 0.5 arr[:, :, :3] = np.where(cov[:, :, None], out_n, arr[:, :, :3]).astype(np.float32) im.pixels.foreach_set(arr.reshape(-1)); im.pack() log("normal map updated") # ---- faint cavity darkening in the albedo (relightable, unlike painted shading) ----------- # pointwise, so it cannot pick up a seam either: the valleys ARE the negative # part of the height field cav = np.clip(-Himg, 0.0, None) if cav[cov].max() > 1e-9: cav = cav / cav[cov].max() cav = np.clip(cav, 0, 1) bi = src["base"] b3 = np.empty(W * H * 4, dtype=np.float32); bi.pixels.foreach_get(b3) brgb = b3.reshape(H, W, 4) alb = brgb[:, :, :3].astype(np.float64) alb = np.where(cov[:, :, None], alb * (1.0 - CAVITY * STRENGTH * cav[:, :, None]), alb) brgb[:, :, :3] = np.clip(alb, 0, 1).astype(np.float32) bi.pixels.foreach_set(brgb.reshape(-1)); bi.pack() log(f"albedo cavity applied (max darkening {100*CAVITY*STRENGTH:.1f}%)") for k, img in src.items(): stem = os.path.splitext(os.path.basename(OUT))[0] p = os.path.join(os.path.dirname(os.path.abspath(OUT)), f"{stem}_{k}.jpg") img.file_format = 'JPEG' img.filepath_raw = p img.save(filepath=p) bpy.ops.wm.save_as_mainfile(filepath=OUT) if GLB: for o in bpy.data.objects: o.select_set(o is ob) bpy.context.view_layer.objects.active = ob bpy.ops.export_scene.gltf(filepath=os.path.abspath(GLB), export_format='GLB', use_selection=True, export_image_format='AUTO', export_jpeg_quality=95, export_yup=True, export_apply=False) log(f"EXPORTED {GLB}") print("MUSCLE_DONE")