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>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
# 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])
|
||||
Reference in New Issue
Block a user