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,257 @@
|
||||
# De-mirrors Lena's limb UVs (v2 — triangle-level split).
|
||||
# Her left arm/leg share the right side's UV islands (arms 100%, legs 99%), so
|
||||
# any baked tattoo prints on both sides. v1 moved whole islands and left 4%/29%
|
||||
# residual overlap (mixed torso+limb islands stayed put). v2 moves exactly the
|
||||
# triangles a left-limb bake would paint (max left-chain weight ≥ 0.28), splits
|
||||
# mixed islands by duplicating boundary vertices (seam is invisible: positions/
|
||||
# normals identical, texels copied 1:1), widens the atlas 4096→8192 and packs
|
||||
# the moved pieces into the new right half. Exports a modified GLB.
|
||||
# Run headless:
|
||||
# tinqs.console.exe --headless --path tattoo-test -s res://demirror_lena.gd
|
||||
extends SceneTree
|
||||
|
||||
const GLB_IN := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb"
|
||||
const GLB_OUT := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_demirrored.glb"
|
||||
const MOVE_W := 0.28 # move tris whose max left-chain weight ≥ this (bake gate is 0.30)
|
||||
const PAD := 16 # texel padding around moved pieces
|
||||
|
||||
var skel: Skeleton3D
|
||||
|
||||
func _init() -> void:
|
||||
var doc := GLTFDocument.new()
|
||||
var state := GLTFState.new()
|
||||
if doc.append_from_file(GLB_IN, state) != OK:
|
||||
push_error("GLB load failed")
|
||||
quit(1)
|
||||
return
|
||||
var scene := doc.generate_scene(state)
|
||||
|
||||
var body: MeshInstance3D = 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 and String(n.name).to_lower().contains("lena"):
|
||||
body = n
|
||||
for c in n.get_children():
|
||||
stack.push_back(c)
|
||||
if skel == null or body == null:
|
||||
push_error("skeleton or Lena mesh not found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var mesh: ArrayMesh = body.mesh
|
||||
var arrays: Array = mesh.surface_get_arrays(0)
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
|
||||
var tangents: PackedFloat32Array = arrays[Mesh.ARRAY_TANGENT] if arrays[Mesh.ARRAY_TANGENT] != null else PackedFloat32Array()
|
||||
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
|
||||
var vbones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var vweights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
||||
var nverts := verts.size()
|
||||
var influences := int(float(vbones.size()) / float(nverts))
|
||||
var orig_uvs := uvs.duplicate()
|
||||
|
||||
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)
|
||||
|
||||
# per-vertex combined LEFT-limb weight (arm incl. fingers + leg incl. ball)
|
||||
var left_bones := ["upperarm_l", "lowerarm_l", "hand_l",
|
||||
"thigh_l", "calf_l", "foot_l", "ball_l", "ball_leaf_l"]
|
||||
for f in ["index", "middle", "pinky", "ring", "thumb"]:
|
||||
for seg in ["01", "02", "03", "04_leaf"]:
|
||||
left_bones.append("%s_%s_l" % [f, seg])
|
||||
var left_binds := _bind_set(bind_bone, left_bones)
|
||||
var lw := PackedFloat32Array()
|
||||
lw.resize(nverts)
|
||||
for v in nverts:
|
||||
for k in influences:
|
||||
if left_binds.has(vbones[v * influences + k]):
|
||||
lw[v] += vweights[v * influences + k]
|
||||
|
||||
# moved triangles = what a left-limb bake would touch
|
||||
var ntris := indices.size() / 3
|
||||
var tri_moved := PackedByteArray()
|
||||
tri_moved.resize(ntris)
|
||||
var n_moved_tris := 0
|
||||
for t in ntris:
|
||||
var w := maxf(lw[indices[t * 3]], maxf(lw[indices[t * 3 + 1]], lw[indices[t * 3 + 2]]))
|
||||
if w >= MOVE_W:
|
||||
tri_moved[t] = 1
|
||||
n_moved_tris += 1
|
||||
print("moved tris: %d / %d" % [n_moved_tris, ntris])
|
||||
|
||||
# vertex usage: moved-only, unmoved-only, or boundary (both)
|
||||
var used_moved := PackedByteArray()
|
||||
used_moved.resize(nverts)
|
||||
var used_unmoved := PackedByteArray()
|
||||
used_unmoved.resize(nverts)
|
||||
for t in ntris:
|
||||
for j in 3:
|
||||
var v := indices[t * 3 + j]
|
||||
if tri_moved[t] == 1:
|
||||
used_moved[v] = 1
|
||||
else:
|
||||
used_unmoved[v] = 1
|
||||
|
||||
# connected components of moved tris (via shared verts)
|
||||
var parent := PackedInt32Array()
|
||||
parent.resize(nverts)
|
||||
for v in nverts:
|
||||
parent[v] = v
|
||||
for t in ntris:
|
||||
if tri_moved[t] == 0:
|
||||
continue
|
||||
var a := _find(parent, indices[t * 3])
|
||||
var b := _find(parent, indices[t * 3 + 1])
|
||||
var c := _find(parent, indices[t * 3 + 2])
|
||||
if b != a:
|
||||
parent[b] = a
|
||||
if c != a:
|
||||
parent[c] = a
|
||||
|
||||
# duplicate boundary verts; moved tris rebind to the copies
|
||||
var dup_of := {}
|
||||
var comp_of_new := {} # new/moved vert index -> component root
|
||||
for t in ntris:
|
||||
if tri_moved[t] == 0:
|
||||
continue
|
||||
var root := _find(parent, indices[t * 3])
|
||||
for j in 3:
|
||||
var v := indices[t * 3 + j]
|
||||
if used_unmoved[v] == 1:
|
||||
if not dup_of.has(v):
|
||||
var nv := verts.size()
|
||||
verts.append(verts[v])
|
||||
norms.append(norms[v])
|
||||
if tangents.size() > 0:
|
||||
for k in 4:
|
||||
tangents.append(tangents[v * 4 + k])
|
||||
uvs.append(uvs[v])
|
||||
orig_uvs.append(orig_uvs[v])
|
||||
for k in influences:
|
||||
vbones.append(vbones[v * influences + k])
|
||||
vweights.append(vweights[v * influences + k])
|
||||
dup_of[v] = nv
|
||||
comp_of_new[nv] = root
|
||||
indices[t * 3 + j] = dup_of[v]
|
||||
else:
|
||||
comp_of_new[v] = root
|
||||
print("boundary verts duplicated: ", dup_of.size())
|
||||
|
||||
# source texture → widened atlas
|
||||
var mat: BaseMaterial3D = mesh.surface_get_material(0)
|
||||
var img: Image = mat.albedo_texture.get_image()
|
||||
img.clear_mipmaps()
|
||||
img.convert(Image.FORMAT_RGBA8)
|
||||
var tw := img.get_width()
|
||||
var th := img.get_height()
|
||||
var wide := Image.create(tw * 2, th, false, Image.FORMAT_RGBA8)
|
||||
wide.blit_rect(img, Rect2i(0, 0, tw, th), Vector2i.ZERO)
|
||||
|
||||
# everyone's u compresses into the left half
|
||||
for v in uvs.size():
|
||||
uvs[v].x *= 0.5
|
||||
|
||||
# component bboxes in ORIGINAL texel space
|
||||
var comps := {}
|
||||
for v in comp_of_new:
|
||||
var root: int = comp_of_new[v]
|
||||
if not comps.has(root):
|
||||
comps[root] = {"verts": [], "mn": Vector2(1e9, 1e9), "mx": Vector2(-1e9, -1e9)}
|
||||
comps[root]["verts"].append(v)
|
||||
comps[root]["mn"] = (comps[root]["mn"] as Vector2).min(orig_uvs[v])
|
||||
comps[root]["mx"] = (comps[root]["mx"] as Vector2).max(orig_uvs[v])
|
||||
var pieces := []
|
||||
for root in comps:
|
||||
var m: Dictionary = comps[root]
|
||||
m["px0"] = maxi(int(m["mn"].x * tw) - PAD, 0)
|
||||
m["py0"] = maxi(int(m["mn"].y * th) - PAD, 0)
|
||||
m["px1"] = mini(int(m["mx"].x * tw) + PAD, tw - 1)
|
||||
m["py1"] = mini(int(m["mx"].y * th) + PAD, th - 1)
|
||||
m["h"] = m["py1"] - m["py0"]
|
||||
pieces.append(m)
|
||||
pieces.sort_custom(func(a, b): return a["h"] > b["h"])
|
||||
print("moved pieces: ", pieces.size())
|
||||
|
||||
# shelf-pack into the right half, blit texels, remap UVs
|
||||
var cursor_x := tw + PAD
|
||||
var cursor_y := PAD
|
||||
var row_h := 0
|
||||
for m in pieces:
|
||||
var w_px: int = m["px1"] - m["px0"]
|
||||
var h_px: int = m["py1"] - m["py0"]
|
||||
if cursor_x + w_px >= tw * 2 - PAD:
|
||||
cursor_x = tw + PAD
|
||||
cursor_y += row_h + PAD
|
||||
row_h = 0
|
||||
if cursor_y + h_px >= th - PAD:
|
||||
push_error("packing overflow — atlas half too small")
|
||||
quit(1)
|
||||
return
|
||||
wide.blit_rect(img, Rect2i(m["px0"], m["py0"], w_px, h_px),
|
||||
Vector2i(cursor_x, cursor_y))
|
||||
for v in m["verts"]:
|
||||
var opx: float = orig_uvs[v].x * tw
|
||||
var opy: float = orig_uvs[v].y * th
|
||||
uvs[v] = Vector2(
|
||||
(cursor_x + (opx - m["px0"])) / (tw * 2.0),
|
||||
(cursor_y + (opy - m["py0"])) / float(th))
|
||||
cursor_x += w_px + PAD
|
||||
row_h = maxi(row_h, h_px)
|
||||
|
||||
# rebuild surface + texture, export
|
||||
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||
arrays[Mesh.ARRAY_NORMAL] = norms
|
||||
if tangents.size() > 0:
|
||||
arrays[Mesh.ARRAY_TANGENT] = tangents
|
||||
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
||||
arrays[Mesh.ARRAY_BONES] = vbones
|
||||
arrays[Mesh.ARRAY_WEIGHTS] = vweights
|
||||
arrays[Mesh.ARRAY_INDEX] = indices
|
||||
var new_mesh := ArrayMesh.new()
|
||||
new_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
mat.albedo_texture = ImageTexture.create_from_image(wide)
|
||||
new_mesh.surface_set_material(0, mat)
|
||||
body.mesh = new_mesh
|
||||
|
||||
var out_doc := GLTFDocument.new()
|
||||
var out_state := GLTFState.new()
|
||||
if out_doc.append_from_scene(scene, out_state) != OK:
|
||||
push_error("export append failed")
|
||||
quit(1)
|
||||
return
|
||||
if out_doc.write_to_filesystem(out_state, GLB_OUT) != OK:
|
||||
push_error("export write failed")
|
||||
quit(1)
|
||||
return
|
||||
print("verts %d -> %d, atlas %dx%d" % [nverts, verts.size(), tw * 2, th])
|
||||
print("saved: ", GLB_OUT)
|
||||
quit()
|
||||
|
||||
func _bind_set(bind_bone: PackedInt32Array, names: Array) -> Dictionary:
|
||||
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 out := {}
|
||||
for i in bind_bone.size():
|
||||
if bone_ids.has(bind_bone[i]):
|
||||
out[i] = true
|
||||
return out
|
||||
|
||||
func _find(parent: PackedInt32Array, v: int) -> int:
|
||||
var r := v
|
||||
while parent[r] != r:
|
||||
parent[r] = parent[parent[r]]
|
||||
r = parent[r]
|
||||
return r
|
||||
Reference in New Issue
Block a user