Files
tattoo-test/bake.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

362 lines
14 KiB
GDScript

# Bakes six tattoo marks onto the Quaternius Regular Male base-color texture:
# right arm chiefs-mark v3 (360° wrap shoulder→wrist, full sleeve)
# left arm iron-spine v3 (360° wrap shoulder→wrist, full sleeve)
# right leg voyagers-current v3 (360° wrap shorts-hem→ankle, full sleeve)
# left leg storm-bearer v3 (360° wrap shorts-hem→ankle, full sleeve)
# back hearth-warmth v3 (planar from -Z, cover-fit, tip at neck)
# chest star-eye v3 (planar from +Z, width-fit shoulder→shoulder)
# Every mark is auto-cropped to its ink bounding box first, so white margins in
# the source PNG never eat body coverage, and nothing gets aspect-squished.
# Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://bake.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 MARKS := "C:/Users/CAN/tinqs-ltd/docs/conceptart/generated-images/tattoo-marks/"
const OUT_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body.png"
const INK_COLOR := Color(0.10, 0.085, 0.08)
const INK_OPACITY := 0.88
# limb wraps: cropped design wraps the full circumference; v runs start→end joint
# "hem": true starts the design at the shorts hem instead of the root joint
const LIMBS := [
{"label": "arm_r chiefs-mark", "tex": MARKS + "mark-01-chiefs-mark_arm_v3.png",
"bones": ["upperarm_r", "lowerarm_r", "hand_r"], "center": "spine_03", "hem": false},
{"label": "arm_l iron-spine", "tex": MARKS + "mark-10-iron-spine_arm_v3.png",
"bones": ["upperarm_l", "lowerarm_l", "hand_l"], "center": "spine_03", "hem": false},
{"label": "leg_r voyagers-current", "tex": MARKS + "mark-02-voyagers-current_leg_v3.png",
"bones": ["thigh_r", "calf_r", "foot_r"], "center": "pelvis", "hem": true},
{"label": "leg_l storm-bearer", "tex": MARKS + "mark-07-storm-bearer_leg_v3.png",
"bones": ["thigh_l", "calf_l", "foot_l"], "center": "pelvis", "hem": true},
]
const BACK_PATH := MARKS + "mark-06-hearth-warmth_back_v3.png"
const CHEST_PATH := MARKS + "mark-09-star-eye_chest_v3.png"
# torso boxes: half-width + top anchor; bottom comes from the design's aspect
# ("width" fit) or the clip floor ("cover" fit, overflow clipped)
const BACK_HALF_WIDTH := 0.21
const BACK_Y_TOP_OFFSET := 0.04 # above neck_01 — flame tip reaches the neck
const BACK_Y_CLIP := 1.04 # shorts waistline (dark-texel skip also guards)
const CHEST_HALF_WIDTH := 0.21
const CHEST_Y_TOP := 1.50 # just under the clavicle line
const CHEST_Y_CLIP := 1.15 # hard floor — belly stays empty
var skel: Skeleton3D
var verts: PackedVector3Array
var normals: PackedVector3Array
var uvs: PackedVector2Array
var indices: PackedInt32Array
var limb_w := {} # limb label -> per-vertex weight array
var armw_any: PackedFloat32Array # both arms (torso-pass exclusion)
var skin_img: Image
var tw: int
var th: int
func _init() -> void:
var t_start := Time.get_ticks_msec()
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)
var limb_binds := {}
for limb in LIMBS:
limb_binds[limb["label"]] = _bind_set(bind_bone, limb["bones"])
var binds_arm_any := _bind_set(bind_bone,
["upperarm_l", "lowerarm_l", "hand_l", "upperarm_r", "lowerarm_r", "hand_r"])
for limb in LIMBS:
var w := PackedFloat32Array()
w.resize(nverts)
limb_w[limb["label"]] = w
armw_any = PackedFloat32Array()
armw_any.resize(nverts)
for v in nverts:
var wa := 0.0
for k in influences:
var bi := vbones[v * influences + k]
var vw: float = vweights[v * influences + k]
if binds_arm_any.has(bi):
wa += vw
for limb in LIMBS:
if limb_binds[limb["label"]].has(bi):
limb_w[limb["label"]][v] += vw
armw_any[v] = wa
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()
for limb in LIMBS:
_bake_limb(limb)
var back_y_top := _bone_pos("neck_01").y + BACK_Y_TOP_OFFSET
_bake_torso("back hearth-warmth", BACK_PATH, -1.0, BACK_HALF_WIDTH,
back_y_top, BACK_Y_CLIP, "cover")
_bake_torso("chest star-eye", CHEST_PATH, 1.0, CHEST_HALF_WIDTH,
CHEST_Y_TOP, CHEST_Y_CLIP, "width")
skin_img.save_png(OUT_PATH)
print("saved: ", OUT_PATH)
print("bake took %d ms" % (Time.get_ticks_msec() - t_start))
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 _bone_pos(bone: String) -> Vector3:
return skel.get_bone_global_rest(skel.find_bone(bone)).origin
# ink bounding box of a mark: normalized rect + pixel aspect (h/w) of the design
func _ink_bbox(img: Image) -> Dictionary:
var w := img.get_width()
var h := img.get_height()
var minx := w
var maxx := -1
var miny := h
var maxy := -1
for y in h:
for x in w:
var c := img.get_pixel(x, y)
if (c.r + c.g + c.b) / 3.0 < 0.75:
minx = mini(minx, x)
maxx = maxi(maxx, x)
miny = mini(miny, y)
maxy = maxi(maxy, y)
var rect := Rect2(float(minx) / w, float(miny) / h,
float(maxx - minx + 1) / w, float(maxy - miny + 1) / h)
return {"rect": rect, "aspect": float(maxy - miny + 1) / float(maxx - minx + 1)}
func _sample_crop(img: Image, rect: Rect2, u: float, v: float) -> Color:
return _sample_bilinear(img,
rect.position.x + clampf(u, 0.0, 1.0) * rect.size.x,
rect.position.y + clampf(v, 0.0, 1.0) * rect.size.y)
# highest skin-textured (non-shorts) vertex of a limb = the shorts hem line
func _hem_y(w: PackedFloat32Array, y_limit: float) -> float:
var hem := 0.0
for v in verts.size():
if w[v] < 0.5 or verts[v].y > y_limit:
continue
var px := clampi(int(uvs[v].x * tw), 0, tw - 1)
var py := clampi(int(uvs[v].y * th), 0, th - 1)
var c := skin_img.get_pixel(px, py)
if (c.r + c.g + c.b) / 3.0 < 0.30:
continue
hem = maxf(hem, verts[v].y)
return hem
# ── limb sleeve: cropped design wraps the full circumference ────────────────
func _bake_limb(limb: Dictionary) -> void:
var tat := Image.load_from_file(limb["tex"])
tat.convert(Image.FORMAT_RGBA8)
var bbox := _ink_bbox(tat)
var crop: Rect2 = bbox["rect"]
var w: PackedFloat32Array = limb_w[limb["label"]]
var bones: Array = limb["bones"]
var p0 := _bone_pos(bones[0])
var p1 := _bone_pos(bones[1])
var p2 := _bone_pos(bones[2])
var center := _bone_pos(limb["center"])
var len1 := p0.distance_to(p1)
var total_len := len1 + p1.distance_to(p2)
var outward := (p0 - center).normalized()
var segs := [[p0, p1, 0.0], [p1, p2, len1]]
# start the design at the shorts hem (small overlap so no bare gap at the edge)
var t0 := 0.0
if limb["hem"]:
var hem := _hem_y(w, p0.y - 0.01)
t0 = clampf((p0.y - hem) / total_len - 0.02, 0.0, 0.9)
print("%s hem y %.3f → t0 %.3f" % [limb["label"], hem, t0])
var painted := 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
painted += _raster_tri(i0, i1, i2, func(pos: Vector3, _nrm: Vector3, bary: Vector3) -> float:
var aw: float = bary.x * w[i0] + bary.y * w[i1] + bary.z * w[i2]
if aw < 0.30:
return -1.0
var best_dist := 1e9
var t_along := 0.0
var ang := 0.0
for seg in segs:
var a: Vector3 = seg[0]
var axis: Vector3 = (seg[1] as Vector3) - a
var seg_len := axis.length()
axis /= seg_len
var s := clampf((pos - a).dot(axis), 0.0, seg_len)
var cp: Vector3 = a + axis * s
var d := pos.distance_to(cp)
if d < best_dist:
best_dist = d
t_along = ((seg[2] as float) + s) / total_len
var uref: Vector3 = (outward - axis * outward.dot(axis)).normalized()
var dv: Vector3 = pos - cp
ang = atan2(dv.dot(axis.cross(uref)), dv.dot(uref))
var v_norm := (t_along - t0) / (1.0 - t0)
if v_norm <= 0.001 or v_norm >= 0.999:
return -1.0
var c := _sample_crop(tat, crop, fposmod(0.5 + ang / TAU, 1.0), v_norm)
var ink := 1.0 - smoothstep(0.45, 0.80, (c.r + c.g + c.b) / 3.0)
var alpha := ink * INK_OPACITY * smoothstep(0.30, 0.50, aw)
alpha *= smoothstep(0.0, 0.03, v_norm) * (1.0 - smoothstep(0.97, 1.0, v_norm))
return alpha)
print(limb["label"], " painted px: ", painted)
# ── torso piece, planar projection along Z (facing: -1 back, +1 chest) ──────
# fit "width": design spans the full box width, height follows its aspect.
# fit "cover": design fills the whole box (top-anchored), overflow is clipped.
func _bake_torso(label: String, tex_path: String, facing: float, hw: float,
y_top: float, y_clip: float, fit: String) -> void:
var tat := Image.load_from_file(tex_path)
tat.convert(Image.FORMAT_RGBA8)
var bbox := _ink_bbox(tat)
var crop: Rect2 = bbox["rect"]
var aspect: float = bbox["aspect"]
var box_w := 2.0 * hw
var design_w := box_w
if fit == "cover":
design_w = maxf(box_w, (y_top - y_clip) / aspect)
var design_h := design_w * aspect
var y_bot := maxf(y_clip, y_top - design_h)
print("%s: design %.2fw x %.2fh, y %.2f..%.2f, x ±%.2f" %
[label, design_w, design_h, y_bot, y_top, hw])
var painted := 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]
# prefilter: skip triangles clearly outside the box / on arms
if min(armw_any[i0], min(armw_any[i1], armw_any[i2])) > 0.5:
continue
var ymax := maxf(verts[i0].y, maxf(verts[i1].y, verts[i2].y))
var ymin := minf(verts[i0].y, minf(verts[i1].y, verts[i2].y))
if ymin > y_top + 0.05 or ymax < y_bot - 0.05:
continue
painted += _raster_tri(i0, i1, i2, func(pos: Vector3, nrm: Vector3, bary: Vector3) -> float:
if pos.y < y_bot or pos.y > y_top or absf(pos.x) > hw:
return -1.0
var faceness := nrm.z * facing
if faceness < 0.25:
return -1.0
var aw: float = bary.x * armw_any[i0] + bary.y * armw_any[i1] + bary.z * armw_any[i2]
if aw > 0.4:
return -1.0
# u flips with facing so the design reads unmirrored from the viewer's side
var x_norm := (pos.x * facing + hw) / box_w
var u := 0.5 + (x_norm - 0.5) * (box_w / design_w)
var v := (y_top - pos.y) / design_h
if u < 0.0 or u > 1.0 or v > 1.0:
return -1.0
var c := _sample_crop(tat, crop, u, v)
var ink := 1.0 - smoothstep(0.45, 0.80, (c.r + c.g + c.b) / 3.0)
var alpha := ink * INK_OPACITY * smoothstep(0.25, 0.45, faceness)
alpha *= 1.0 - smoothstep(0.2, 0.4, aw)
return alpha)
print(label, " painted px: ", painted)
# rasterizes one triangle in UV space; shade(pos, normal, bary) → alpha (<=0 skips).
# skips texels that are already dark (shorts) so ink stays on skin.
func _raster_tri(i0: int, i1: int, i2: int, shade: Callable) -> int:
var p0 := Vector2(uvs[i0].x * tw, uvs[i0].y * th)
var p1 := Vector2(uvs[i1].x * tw, uvs[i1].y * th)
var p2 := Vector2(uvs[i2].x * tw, uvs[i2].y * th)
var denom := (p1.y - p2.y) * (p0.x - p2.x) + (p2.x - p1.x) * (p0.y - p2.y)
if absf(denom) < 1e-9:
return 0
var minx := clampi(int(floor(min(p0.x, min(p1.x, p2.x)))), 0, tw - 1)
var maxx := clampi(int(ceil(max(p0.x, max(p1.x, p2.x)))), 0, tw - 1)
var miny := clampi(int(floor(min(p0.y, min(p1.y, p2.y)))), 0, th - 1)
var maxy := clampi(int(ceil(max(p0.y, max(p1.y, p2.y)))), 0, th - 1)
var painted := 0
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
var base_col := skin_img.get_pixel(px, py)
if (base_col.r + base_col.g + base_col.b) / 3.0 < 0.30:
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()
var alpha: float = shade.call(pos, nrm, Vector3(b0, b1, b2))
if alpha <= 0.003:
continue
skin_img.set_pixel(px, py, base_col.lerp(INK_COLOR, alpha))
painted += 1
return painted
func _sample_bilinear(img: Image, u: float, v: float) -> Color:
var w := img.get_width()
var h := img.get_height()
var x := clampf(u * w - 0.5, 0.0, w - 1.001)
var y := clampf(v * h - 0.5, 0.0, h - 1.001)
var x0 := int(x)
var y0 := int(y)
var x1 := mini(x0 + 1, w - 1)
var y1 := mini(y0 + 1, h - 1)
var fx := x - x0
var fy := y - y0
return img.get_pixel(x0, y0).lerp(img.get_pixel(x1, y0), fx) \
.lerp(img.get_pixel(x0, y1).lerp(img.get_pixel(x1, y1), fx), fy)