Files
tattoo-test/masks.gd
T
Can Narin ce4c4e7d13 Tattoo test bed: Quaternius v3-v6 bakes + Lena pipeline (bake, de-mirror, re-UV, viewers)
Session 2026-07-23/24 additions on top of the existing Quaternius bed:
- bake_lena.gd: 6-piece bake on QuatSkin Lena (chiefs-mark/iron-spine arms,
  voyagers/storm-bearer legs, star-eye chest, hearth-warmth back); global-axis
  wrap frame + 3cm junction blend kill the elbow split line
- demirror_lena.gd: tri-level split of mirror-shared limb UVs (arms 100%->0%,
  legs 99%->5% crotch-only residual)
- reuv_lena.gd: Quaternius-style semantic re-UV (16 islands, cylinder limbs +
  cylinder head/torso with planar caps) + texture transfer; fixes armpit/chin/
  cheek planar smears; single 4096 atlas, per-side tattoos native
- main_lena / anim_lena viewers (18 UAL clips, U light toggle) + probes + shots

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:47:12 +03:00

321 lines
11 KiB
GDScript

# Cuts per-body-part UV island masks for the Quaternius Regular Male:
# mask_arm_r / mask_arm_l — full sleeve islands (shoulder→wrist)
# mask_leg_r / mask_leg_l — leg islands, visible skin only (shorts excluded)
# mask_chest / mask_back — torso island split by rest-pose normal Z sign
# White = tattooable skin, black = everything else. 2048², same UV space as
# T_Regular_Male_Dark_BaseColor. Also prints each mask's ink bbox (px) for the
# gen/composite scripts.
# Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://masks.gd
extends SceneTree
const GLTF_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/Regular_Male_FullBody.gltf"
const SKIN_TEX_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Regular_Male_Dark_BaseColor_png.png"
const OUT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/masks/"
const SIZE := 2048
const LIMB_MASKS := [
{"name": "arm_r", "bones": ["upperarm_r", "lowerarm_r"], "skip_shorts": false},
{"name": "arm_l", "bones": ["upperarm_l", "lowerarm_l"], "skip_shorts": false},
{"name": "leg_r", "bones": ["thigh_r", "calf_r"], "skip_shorts": true},
{"name": "leg_l", "bones": ["thigh_l", "calf_l"], "skip_shorts": true},
]
const TORSO_BONES := ["spine_01", "spine_02", "spine_03", "clavicle_l", "clavicle_r", "pelvis"]
const ARM_BONES := ["upperarm_l", "lowerarm_l", "hand_l", "upperarm_r", "lowerarm_r", "hand_r"]
const NECK_BONES := ["neck_01", "Head"]
var skel: Skeleton3D
var verts: PackedVector3Array
var normals: PackedVector3Array
var uvs: PackedVector2Array
var indices: PackedInt32Array
var skin_img: Image
var tw: int
var th: int
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLTF_PATH, state) != OK:
push_error("GLTF load failed")
quit(1)
return
var scene := doc.generate_scene(state)
var body: Node = null
var stack: Array = [scene]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
if (n is MeshInstance3D or n.get_class() == "ImporterMeshInstance3D") \
and String(n.name).to_lower().contains("regularmale"):
body = n
for c in n.get_children():
stack.push_back(c)
if skel == null or body == null:
push_error("skeleton or body mesh not found")
quit(1)
return
var mesh = body.mesh
if mesh is ImporterMesh:
mesh = mesh.get_mesh()
var arrays: Array = mesh.surface_get_arrays(0)
verts = arrays[Mesh.ARRAY_VERTEX]
normals = arrays[Mesh.ARRAY_NORMAL]
uvs = arrays[Mesh.ARRAY_TEX_UV]
var vbones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var vweights = arrays[Mesh.ARRAY_WEIGHTS]
indices = arrays[Mesh.ARRAY_INDEX]
var nverts := verts.size()
var influences := int(float(vbones.size()) / float(nverts))
var skin: Skin = body.skin
var bind_bone := PackedInt32Array()
for i in skin.get_bind_count():
var b := skin.get_bind_bone(i)
if b < 0:
b = skel.find_bone(skin.get_bind_name(i))
bind_bone.append(b)
skin_img = Image.load_from_file(SKIN_TEX_PATH)
skin_img.convert(Image.FORMAT_RGBA8)
tw = skin_img.get_width()
th = skin_img.get_height()
DirAccess.make_dir_recursive_absolute(OUT_DIR)
# per-vertex weight sums per group
var groups := {}
for lm in LIMB_MASKS:
groups[lm["name"]] = _weight_sum(bind_bone, vbones, vweights, influences, nverts, lm["bones"])
groups["torso"] = _weight_sum(bind_bone, vbones, vweights, influences, nverts, TORSO_BONES)
groups["arm_any"] = _weight_sum(bind_bone, vbones, vweights, influences, nverts, ARM_BONES)
groups["neck"] = _weight_sum(bind_bone, vbones, vweights, influences, nverts, NECK_BONES)
# limb masks are ISLAND-based, not weight-based: the whole connected UV island
# whose vertices carry the most limb weight. The arm island IS shoulder→wrist
# skin (hand is a separate island), so this leaves no bare rim at the
# weight-blend zones — full-sleeve coverage by construction.
var comp := _uv_components(nverts)
for lm in LIMB_MASKS:
var w: PackedFloat32Array = groups[lm["name"]]
var best := _dominant_component(comp, w, nverts)
_bake_island_mask(lm["name"], lm["skip_shorts"], comp, best)
# torso split: chest = rest normal z > 0.15, back = z < -0.15
var tor: PackedFloat32Array = groups["torso"]
var armw: PackedFloat32Array = groups["arm_any"]
var neckw: PackedFloat32Array = groups["neck"]
_bake_torso_mask("chest", 1.0, tor, armw, neckw)
_bake_torso_mask("back", -1.0, tor, armw, neckw)
quit()
func _weight_sum(bind_bone: PackedInt32Array, vbones: PackedInt32Array, vweights,
influences: int, nverts: int, names: Array) -> PackedFloat32Array:
var bone_ids := {}
for bn in names:
var idx := skel.find_bone(bn)
if idx < 0:
push_error("bone not found: " + str(bn))
bone_ids[idx] = true
var binds := {}
for i in bind_bone.size():
if bone_ids.has(bind_bone[i]):
binds[i] = true
var w := PackedFloat32Array()
w.resize(nverts)
for v in nverts:
var acc := 0.0
for k in influences:
if binds.has(vbones[v * influences + k]):
acc += vweights[v * influences + k]
w[v] = acc
return w
# union-find over shared vertex indices: connected components = UV islands
# (vertices are split at UV seams in the GLTF, so indices only connect within an island)
func _uv_components(nverts: int) -> PackedInt32Array:
var parent := PackedInt32Array()
parent.resize(nverts)
for i in nverts:
parent[i] = i
var ntris := indices.size() / 3
for t in ntris:
_union(parent, indices[t * 3], indices[t * 3 + 1])
_union(parent, indices[t * 3 + 1], indices[t * 3 + 2])
for i in nverts:
parent[i] = _find(parent, i)
return parent
func _find(parent: PackedInt32Array, i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
func _union(parent: PackedInt32Array, a: int, b: int) -> void:
var ra := _find(parent, a)
var rb := _find(parent, b)
if ra != rb:
parent[ra] = rb
# component root whose vertices carry the highest total group weight
func _dominant_component(comp: PackedInt32Array, w: PackedFloat32Array, nverts: int) -> int:
var sums := {}
for v in nverts:
sums[comp[v]] = sums.get(comp[v], 0.0) + w[v]
var best := -1
var best_w := 0.0
for c in sums:
if sums[c] > best_w:
best_w = sums[c]
best = c
return best
# rasterize every triangle of one UV island (optionally minus shorts texels)
func _bake_island_mask(mname: String, skip_shorts: bool, comp: PackedInt32Array, root: int) -> void:
var img := Image.create(SIZE, SIZE, false, Image.FORMAT_L8)
img.fill(Color(0, 0, 0))
var ntris := indices.size() / 3
for t in ntris:
var i0 := indices[t * 3]
if comp[i0] != root:
continue
_raster_mask(img, i0, indices[t * 3 + 1], indices[t * 3 + 2], skip_shorts,
func(_b0: float, _b1: float, _b2: float, _pos: Vector3, _nrm: Vector3) -> bool:
return true)
_save_mask(img, mname)
func _is_shorts(px: int, py: int) -> bool:
var c := skin_img.get_pixel(px, py)
return (c.r + c.g + c.b) / 3.0 < 0.30
func _bake_mask(mname: String, skip_shorts: bool, accept: Callable, w: PackedFloat32Array) -> void:
var img := Image.create(SIZE, SIZE, false, Image.FORMAT_L8)
img.fill(Color(0, 0, 0))
var ntris := indices.size() / 3
for t in ntris:
var i0 := indices[t * 3]
var i1 := indices[t * 3 + 1]
var i2 := indices[t * 3 + 2]
if max(w[i0], max(w[i1], w[i2])) < 0.3:
continue
_raster_mask(img, i0, i1, i2, skip_shorts,
func(b0: float, b1: float, b2: float, pos: Vector3, nrm: Vector3) -> bool:
return accept.call(b0 * w[i0] + b1 * w[i1] + b2 * w[i2], pos, nrm))
_save_mask(img, mname)
func _bake_torso_mask(mname: String, facing: float, tor: PackedFloat32Array,
armw: PackedFloat32Array, neckw: PackedFloat32Array) -> void:
var img := Image.create(SIZE, SIZE, false, Image.FORMAT_L8)
img.fill(Color(0, 0, 0))
var ntris := indices.size() / 3
for t in ntris:
var i0 := indices[t * 3]
var i1 := indices[t * 3 + 1]
var i2 := indices[t * 3 + 2]
if max(tor[i0], max(tor[i1], tor[i2])) < 0.3:
continue
_raster_mask(img, i0, i1, i2, true,
func(b0: float, b1: float, b2: float, _pos: Vector3, nrm: Vector3) -> bool:
var tw_ := b0 * tor[i0] + b1 * tor[i1] + b2 * tor[i2]
var aw := b0 * armw[i0] + b1 * armw[i1] + b2 * armw[i2]
var nw := b0 * neckw[i0] + b1 * neckw[i1] + b2 * neckw[i2]
return tw_ > 0.5 and aw < 0.35 and nw < 0.35 and nrm.z * facing > 0.10)
_save_mask(img, mname)
func _raster_mask(img: Image, i0: int, i1: int, i2: int, skip_shorts: bool, accept: Callable) -> void:
var p0 := Vector2(uvs[i0].x * SIZE, uvs[i0].y * SIZE)
var p1 := Vector2(uvs[i1].x * SIZE, uvs[i1].y * SIZE)
var p2 := Vector2(uvs[i2].x * SIZE, uvs[i2].y * SIZE)
var denom := (p1.y - p2.y) * (p0.x - p2.x) + (p2.x - p1.x) * (p0.y - p2.y)
if absf(denom) < 1e-9:
return
var minx := clampi(int(floor(min(p0.x, min(p1.x, p2.x)))), 0, SIZE - 1)
var maxx := clampi(int(ceil(max(p0.x, max(p1.x, p2.x)))), 0, SIZE - 1)
var miny := clampi(int(floor(min(p0.y, min(p1.y, p2.y)))), 0, SIZE - 1)
var maxy := clampi(int(ceil(max(p0.y, max(p1.y, p2.y)))), 0, SIZE - 1)
for py in range(miny, maxy + 1):
for px in range(minx, maxx + 1):
var fx := px + 0.5
var fy := py + 0.5
var b0 := ((p1.y - p2.y) * (fx - p2.x) + (p2.x - p1.x) * (fy - p2.y)) / denom
var b1 := ((p2.y - p0.y) * (fx - p2.x) + (p0.x - p2.x) * (fy - p2.y)) / denom
var b2 := 1.0 - b0 - b1
if b0 < -0.001 or b1 < -0.001 or b2 < -0.001:
continue
if skip_shorts:
var sx := clampi(int(float(px) / SIZE * tw), 0, tw - 1)
var sy := clampi(int(float(py) / SIZE * th), 0, th - 1)
if _is_shorts(sx, sy):
continue
var pos: Vector3 = verts[i0] * b0 + verts[i1] * b1 + verts[i2] * b2
var nrm: Vector3 = (normals[i0] * b0 + normals[i1] * b1 + normals[i2] * b2).normalized()
if accept.call(b0, b1, b2, pos, nrm):
img.set_pixel(px, py, Color(1, 1, 1))
# keeps only the largest 4-connected white component (kills stray slivers from
# shoulder-cap / wrist texels that bleed into neighbouring islands)
func _largest_component(img: Image) -> void:
var lbl := PackedInt32Array()
lbl.resize(SIZE * SIZE)
var sizes := [0] # component 0 = background
var next_lbl := 1
var queue := PackedInt32Array()
for start in SIZE * SIZE:
if lbl[start] != 0 or img.get_pixel(start % SIZE, start / SIZE).r < 0.5:
continue
var comp := next_lbl
next_lbl += 1
var count := 0
queue.clear()
queue.append(start)
lbl[start] = comp
var head := 0
while head < queue.size():
var p := queue[head]
head += 1
count += 1
var x := p % SIZE
var y := p / SIZE
for d in [[1, 0], [-1, 0], [0, 1], [0, -1]]:
var nx: int = x + d[0]
var ny: int = y + d[1]
if nx < 0 or nx >= SIZE or ny < 0 or ny >= SIZE:
continue
var np := ny * SIZE + nx
if lbl[np] == 0 and img.get_pixel(nx, ny).r > 0.5:
lbl[np] = comp
queue.append(np)
sizes.append(count)
var best := 1
for c in range(2, sizes.size()):
if sizes[c] > sizes[best]:
best = c
for p in SIZE * SIZE:
if lbl[p] != 0 and lbl[p] != best:
img.set_pixel(p % SIZE, p / SIZE, Color(0, 0, 0))
func _save_mask(img: Image, mname: String) -> void:
_largest_component(img)
# bbox of white pixels
var minx := SIZE
var maxx := -1
var miny := SIZE
var maxy := -1
var count := 0
for y in SIZE:
for x in SIZE:
if img.get_pixel(x, y).r > 0.5:
minx = mini(minx, x)
maxx = maxi(maxx, x)
miny = mini(miny, y)
maxy = maxi(maxy, y)
count += 1
img.convert(Image.FORMAT_RGBA8)
var path := OUT_DIR + "mask_" + mname + ".png"
img.save_png(path)
print("MASK %s px=%d bbox=%d,%d,%d,%d saved=%s" % [mname, count, minx, miny, maxx - minx + 1, maxy - miny + 1, path])