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:
Can Narin
2026-07-24 00:47:12 +03:00
commit ce4c4e7d13
105 changed files with 5090 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
+2
View File
@@ -0,0 +1,2 @@
.godot/
frames/
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Tattoo v4 pipeline — mask-fitted marks</title>
<style>
body { background:#0a0a0f; color:#e8e0d4; font-family:Segoe UI,sans-serif; margin:0; padding:32px; }
h1 { color:#c9935a; font-size:22px; }
h2 { color:#c9935a; font-size:16px; margin-top:36px; border-bottom:1px solid #2a2a33; padding-bottom:6px; }
p { max-width:900px; line-height:1.5; color:#b8b0a4; }
.row { display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; }
.cell { background:#14141c; border:1px solid #2a2a33; border-radius:8px; padding:10px; text-align:center; }
.cell img { display:block; border-radius:4px; }
.cell .cap { font-size:12px; color:#9a927f; margin-top:6px; }
.big img { width:460px; height:460px; image-rendering:auto; }
.med img { width:300px; height:300px; }
.sm img { width:220px; height:220px; }
.status { display:inline-block; background:#3a2a1a; color:#e0a050; border:1px solid #5a4020; border-radius:4px; padding:4px 10px; font-size:13px; margin-top:8px; }
</style>
</head>
<body>
<h1>Tattoo v4 — fitting marks to the Quaternius body (pipeline preview)</h1>
<p>The v3 marks looked incomplete because they were generated as free-standing art and 3D-projected
onto the body. v4 flips it: we cut the <b>exact UV island of each body part</b> from the model and
generate the design <b>inside that silhouette</b>, so ink coverage reaches every edge by construction.</p>
<div class="status">⏳ Waiting on fal.ai top-up → then: generate 6 marks → composite → re-render shots</div>
<h2>1 · The exact shapes Quaternius uses (UV template, color = body part)</h2>
<div class="row">
<div class="cell big"><img src="uv_template.png"><div class="cap">uv_template.png — arms salmon/blue, legs gold/green, torso cream, hands/feet/head separate</div></div>
<div class="cell big"><img src="baked_body.png"><div class="cap">baked_body.png — current v3 bake: ink misses island edges, tapers early, seams on inner limbs</div></div>
</div>
<h2>2 · Per-part masks cut from the model (white = tattooable skin)</h2>
<div class="row">
<div class="cell sm"><img src="masks/mask_arm_r.png"><div class="cap">arm_r — shoulder→wrist</div></div>
<div class="cell sm"><img src="masks/mask_arm_l.png"><div class="cap">arm_l</div></div>
<div class="cell sm"><img src="masks/mask_leg_r.png"><div class="cap">leg_r — shorts hem→ankle</div></div>
<div class="cell sm"><img src="masks/mask_leg_l.png"><div class="cap">leg_l</div></div>
<div class="cell sm"><img src="masks/mask_chest.png"><div class="cap">chest — front plate</div></div>
<div class="cell sm"><img src="masks/mask_back.png"><div class="cap">back — back plate</div></div>
</div>
<h2>3 · Gen canvases (GPT Image 2 fills the parchment silhouette edge-to-edge)</h2>
<div class="row">
<div class="cell med"><img src="gen-v4/canvas_arm_r.png"><div class="cap">arm_r → chiefs-mark</div></div>
<div class="cell med"><img src="gen-v4/canvas_arm_l.png"><div class="cap">arm_l → iron-spine</div></div>
<div class="cell med"><img src="gen-v4/canvas_leg_r.png"><div class="cap">leg_r → voyagers-current</div></div>
<div class="cell med"><img src="gen-v4/canvas_leg_l.png"><div class="cap">leg_l → storm-bearer</div></div>
<div class="cell med"><img src="gen-v4/canvas_chest.png"><div class="cap">chest → star-eye</div></div>
<div class="cell med"><img src="gen-v4/canvas_back.png"><div class="cap">back → hearth-warmth</div></div>
</div>
<h2>4 · Where v3 falls short on the model (why this rework)</h2>
<div class="row">
<div class="cell med"><img src="shots/05_arm_r_chiefs_mark.png"><div class="cap">sleeve tapers off mid-forearm (sparse design bottom)</div></div>
<div class="cell med"><img src="shots/07_leg_r_voyagers_front.png"><div class="cap">bare seam stripes down the inner shins (wrap seam)</div></div>
<div class="cell med"><img src="shots/03_chest_star_eye.png"><div class="cap">chest piece clipped at the sides (planar projection)</div></div>
</div>
</body>
</html>
After
+243
View File
@@ -0,0 +1,243 @@
# Animation test: the tattooed QuatSkin Lena driven by UAL1 clips.
# Controls:
# ←/→ or A/D previous / next animation
# Space pause / resume
# U lighting ON / FLAT (kills the sun+fill lights, boosts ambient)
# left-drag orbit camera, scroll wheel zooms
# Saves a few mid-animation shots to shots-anim-lena/ first, then goes interactive.
extends Node3D
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_clean_uv.glb"
const BAKED_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_lena_body.png"
const UAL1 := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/anim/UAL1.glb"
const SHOT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/shots-anim-lena/"
const TAKE_SHOTS := false
# gallery order: locomotion first, then actions
const CLIPS := [
"Idle_Loop", "Walk_Loop", "Jog_Fwd_Loop", "Sprint_Loop",
"Crouch_Idle_Loop", "Crouch_Fwd_Loop", "Jump_Loop",
"Swim_Idle_Loop", "Swim_Fwd_Loop",
"Dance_Loop", "Punch_Jab", "Punch_Cross", "Roll", "Interact",
"Fixing_Kneeling", "Sitting_Idle_Loop", "Spell_Simple_Shoot", "Death01",
]
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
var player: AnimationPlayer
var hud: Label
var clip_idx := 0
var lights: Array[Light3D] = []
var env: Environment
var lights_on := true
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.4
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
env = Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
lights = [sun, fill, under]
# ground plane so locomotion reads against something
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(12, 12)
ground.mesh = plane
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.16, 0.17, 0.21)
gmat.roughness = 1.0
ground.set_surface_override_material(0, gmat)
add_child(ground)
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
push_error("GLB load failed")
return
model = doc.generate_scene(state)
add_child(model)
var baked := ImageTexture.create_from_image(Image.load_from_file(BAKED_PATH))
var stack: Array = [model]
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"):
var mat := StandardMaterial3D.new()
mat.roughness = 0.85
mat.albedo_texture = baked
mat.cull_mode = BaseMaterial3D.CULL_BACK
for s in n.mesh.get_surface_count():
n.set_surface_override_material(s, mat)
for c in n.get_children():
stack.push_back(c)
_load_ual_clips()
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
hud = Label.new()
hud.position = Vector2(16, 12)
hud.add_theme_font_size_override("font_size", 22)
hud.add_theme_color_override("font_color", Color(0.95, 0.95, 0.9))
add_child(hud)
_play_clip(0)
_update_cam()
if TAKE_SHOTS:
await _take_shots()
print("SHOTS_DONE")
_play_clip(0)
interactive = true
# pulls the wanted UAL1 clips onto a local AnimationPlayer, remapping the
# "Armature/Skeleton3D" track prefix to this scene's skeleton (game approach,
# see PlayerController.LoadUalLibrary)
func _load_ual_clips() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(UAL1, state) != OK:
push_error("UAL1 load failed")
return
var ual := doc.generate_scene(state)
var src: AnimationPlayer = null
var stack: Array = [ual]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
src = n
break
for c in n.get_children():
stack.push_back(c)
if src == null:
push_error("no AnimationPlayer in UAL1")
return
player = AnimationPlayer.new()
add_child(player)
var skel_path := String(get_path_to(skel))
var lib := AnimationLibrary.new()
for clip_name in CLIPS:
if not src.has_animation(clip_name):
push_warning("UAL1 missing clip: " + clip_name)
continue
var anim := src.get_animation(clip_name).duplicate() as Animation
anim.loop_mode = Animation.LOOP_LINEAR
for i in anim.get_track_count():
var path := String(anim.track_get_path(i))
if path.contains("Armature/Skeleton3D"):
anim.track_set_path(i, path.replace("Armature/Skeleton3D", skel_path))
lib.add_animation(clip_name, anim)
player.add_animation_library("", lib)
print("loaded %d clips" % lib.get_animation_list().size())
ual.queue_free()
func _play_clip(idx: int) -> void:
clip_idx = wrapi(idx, 0, CLIPS.size())
var clip: String = CLIPS[clip_idx]
if player and player.has_animation(clip):
player.play(clip)
_update_hud()
func _set_lights(on: bool) -> void:
lights_on = on
for l in lights:
l.visible = on
# flat mode: strong even ambient so the texture reads unshaded
env.ambient_light_energy = 0.7 if on else 1.6
_update_hud()
func _update_hud() -> void:
if hud:
hud.text = "[%d/%d] %s light: %s\n←/→ switch Space pause U light drag rotate scroll zoom" \
% [clip_idx + 1, CLIPS.size(), CLIPS[clip_idx], "SUN" if lights_on else "FLAT"]
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_RIGHT, KEY_D:
_play_clip(clip_idx + 1)
KEY_LEFT, KEY_A:
_play_clip(clip_idx - 1)
KEY_SPACE:
if player.is_playing():
player.pause()
else:
player.play()
KEY_U:
_set_lights(not lights_on)
elif event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 8.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 8.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
func _snap(shot_name: String) -> void:
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + shot_name + ".png")
print("shot saved: ", SHOT_DIR + shot_name + ".png")
func _take_shots() -> void:
for spec in [["Jog_Fwd_Loop", 0.4], ["Dance_Loop", 1.2], ["Punch_Jab", 0.5], ["Interact", 0.6]]:
var clip: String = spec[0]
if not player.has_animation(clip):
continue
player.play(clip)
await get_tree().create_timer(spec[1]).timeout
await _snap("anim_" + clip.to_lower())
# verify the U-key light toggle path: one flat-lit shot, then restore
_set_lights(false)
await _snap("anim_light_flat")
_set_lights(true)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://anim_lena.gd" id="1"]
[node name="AnimTest" type="Node3D"]
script = ExtResource("1")
+238
View File
@@ -0,0 +1,238 @@
# Animation test: the tattooed Quaternius Regular Male driven by UAL1 clips.
# Controls:
# ←/→ or A/D previous / next animation
# Space pause / resume
# left-drag orbit camera, scroll wheel zooms
# Saves a few mid-animation shots to shots-anim/ first, then goes interactive.
extends Node3D
const GLTF_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/Regular_Male_FullBody.gltf"
const BAKED_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body.png"
const NORMAL_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Regular_Male_Normal_png.png"
const EYE_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Eye_Brown.png"
const HAIR_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Hair_1_BaseColor.png"
const UAL1 := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/anim-lib-1/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb"
const SHOT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/shots-anim/"
const TAKE_SHOTS := true
# gallery order: locomotion first, then actions
const CLIPS := [
"Idle_Loop", "Walk_Loop", "Jog_Fwd_Loop", "Sprint_Loop",
"Crouch_Idle_Loop", "Crouch_Fwd_Loop", "Jump_Loop",
"Swim_Idle_Loop", "Swim_Fwd_Loop",
"Dance_Loop", "Punch_Jab", "Punch_Cross", "Roll", "Interact",
"Fixing_Kneeling", "Sitting_Idle_Loop", "Spell_Simple_Shoot", "Death01",
]
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
var player: AnimationPlayer
var hud: Label
var clip_idx := 0
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.4
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
# ground plane so locomotion reads against something
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(12, 12)
ground.mesh = plane
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.16, 0.17, 0.21)
gmat.roughness = 1.0
ground.set_surface_override_material(0, gmat)
add_child(ground)
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLTF_PATH, state) != OK:
push_error("GLTF load failed")
return
model = doc.generate_scene(state)
add_child(model)
var baked := ImageTexture.create_from_image(Image.load_from_file(BAKED_PATH))
var body_normal := ImageTexture.create_from_image(Image.load_from_file(NORMAL_PATH))
var eye_tex := ImageTexture.create_from_image(Image.load_from_file(EYE_PATH))
var hair_tex := ImageTexture.create_from_image(Image.load_from_file(HAIR_PATH))
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
if n is MeshInstance3D:
var lname := String(n.name).to_lower()
var mat := StandardMaterial3D.new()
mat.roughness = 0.85
if lname.contains("regularmale"):
mat.albedo_texture = baked
mat.normal_enabled = true
mat.normal_texture = body_normal
elif lname.contains("eye") and not lname.contains("brow"):
mat.albedo_texture = eye_tex
else:
mat.albedo_texture = hair_tex
for s in n.mesh.get_surface_count():
n.set_surface_override_material(s, mat)
for c in n.get_children():
stack.push_back(c)
_load_ual_clips()
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
hud = Label.new()
hud.position = Vector2(16, 12)
hud.add_theme_font_size_override("font_size", 22)
hud.add_theme_color_override("font_color", Color(0.95, 0.95, 0.9))
add_child(hud)
_play_clip(0)
_update_cam()
if TAKE_SHOTS:
await _take_shots()
print("SHOTS_DONE")
_play_clip(0)
interactive = true
# pulls the wanted UAL1 clips onto a local AnimationPlayer, remapping the
# "Armature/Skeleton3D" track prefix to this scene's skeleton (game approach,
# see PlayerController.LoadUalLibrary)
func _load_ual_clips() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(UAL1, state) != OK:
push_error("UAL1 load failed")
return
var ual := doc.generate_scene(state)
var src: AnimationPlayer = null
var stack: Array = [ual]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
src = n
break
for c in n.get_children():
stack.push_back(c)
if src == null:
push_error("no AnimationPlayer in UAL1")
return
player = AnimationPlayer.new()
add_child(player)
var skel_path := String(get_path_to(skel))
var lib := AnimationLibrary.new()
for clip_name in CLIPS:
if not src.has_animation(clip_name):
push_warning("UAL1 missing clip: " + clip_name)
continue
var anim := src.get_animation(clip_name).duplicate() as Animation
anim.loop_mode = Animation.LOOP_LINEAR
for i in anim.get_track_count():
var path := String(anim.track_get_path(i))
if path.contains("Armature/Skeleton3D"):
anim.track_set_path(i, path.replace("Armature/Skeleton3D", skel_path))
lib.add_animation(clip_name, anim)
player.add_animation_library("", lib)
print("loaded %d clips" % lib.get_animation_list().size())
ual.queue_free()
func _play_clip(idx: int) -> void:
clip_idx = wrapi(idx, 0, CLIPS.size())
var clip: String = CLIPS[clip_idx]
if player and player.has_animation(clip):
player.play(clip)
_update_hud()
func _update_hud() -> void:
if hud:
hud.text = "[%d/%d] %s\n←/→ switch Space pause drag rotate scroll zoom" \
% [clip_idx + 1, CLIPS.size(), CLIPS[clip_idx]]
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_RIGHT, KEY_D:
_play_clip(clip_idx + 1)
KEY_LEFT, KEY_A:
_play_clip(clip_idx - 1)
KEY_SPACE:
if player.is_playing():
player.pause()
else:
player.play()
elif event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 8.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 8.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
func _snap(shot_name: String) -> void:
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + shot_name + ".png")
print("shot saved: ", SHOT_DIR + shot_name + ".png")
func _take_shots() -> void:
for spec in [["Jog_Fwd_Loop", 0.4], ["Dance_Loop", 1.2], ["Punch_Jab", 0.5]]:
var clip: String = spec[0]
if not player.has_animation(clip):
continue
player.play(clip)
await get_tree().create_timer(spec[1]).timeout
await _snap("anim_" + clip.to_lower())
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://anim_test.gd" id="1"]
[node name="AnimTest" type="Node3D"]
script = ExtResource("1")
+361
View File
@@ -0,0 +1,361 @@
# 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)
+359
View File
@@ -0,0 +1,359 @@
# Bakes v3 tattoo marks onto the QuatSkin Lena body texture:
# right arm chiefs-mark v3 (360° wrap shoulder→wrist)
# right leg voyagers-current v3 (360° wrap below-underwear→ankle)
# chest star-eye v3 (planar +Z, width fit, clipped above navel)
# back hearth-warmth v3 (planar -Z, cover fit, tip at neck)
# Lena's arms AND legs are mirror-UV'd (~100% shared islands), so each limb
# design prints on BOTH sides — bake one side only, never two designs per pair.
# Painting happens in 3D bone space; her fragmented Tripo UV atlas receives the
# ink wherever her UVs put it. Fabric texels (painted bra/underwear: bright +
# low red-blue chroma) are skipped so ink stays on skin.
# Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://bake_lena.gd
extends SceneTree
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_clean_uv.glb"
const MARKS := "C:/Users/CAN/tinqs-ltd/design/conceptart/generated-images/tattoo-marks/"
const OUT_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_lena_body.png"
const INK_COLOR := Color(0.10, 0.085, 0.08)
const INK_OPACITY := 0.88
# Lena's baked Tripo texture has real shading — only skip near-black texels
const DARK_SKIP := 0.12
# limb sleeves; t0 = fraction of the bone chain where the design starts
# (legs start below the painted underwear hem)
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", "t0": 0.0},
{"label": "arm_l iron-spine", "tex": MARKS + "mark-10-iron-spine_arm_v3.png",
"bones": ["upperarm_l", "lowerarm_l", "hand_l"], "center": "spine_03", "t0": 0.0},
{"label": "leg_r voyagers-current", "tex": MARKS + "mark-02-voyagers-current_leg_v3.png",
"bones": ["thigh_r", "calf_r", "foot_r"], "center": "pelvis", "t0": 0.15},
{"label": "leg_l storm-bearer", "tex": MARKS + "mark-07-storm-bearer_leg_v3.png",
"bones": ["thigh_l", "calf_l", "foot_l"], "center": "pelvis", "t0": 0.15},
]
# torso boxes (heights from her skeleton: neck_01 1.413, pelvis 0.932)
const CHEST_PATH := MARKS + "mark-09-star-eye_chest_v3.png"
const CHEST_HALF_WIDTH := 0.17
const CHEST_Y_TOP := 1.38 # just under her clavicle line (1.397)
const CHEST_Y_CLIP := 1.05 # hard floor — navel stays empty
const BACK_PATH := MARKS + "mark-06-hearth-warmth_back_v3.png"
const BACK_HALF_WIDTH := 0.18
const BACK_Y_TOP_OFFSET := 0.03 # above neck_01 — flame tip reaches the neck
const BACK_Y_CLIP := 0.96 # waistline
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 incl. fingers (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(GLB_PATH, state) != OK:
push_error("GLB 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("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 = 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"])
# arm exclusion for torso passes — Lena has full finger bones, include them
var arm_bones := []
for side in ["l", "r"]:
arm_bones += ["upperarm_%s" % side, "lowerarm_%s" % side, "hand_%s" % side]
for f in ["index", "middle", "pinky", "ring", "thumb"]:
for seg in ["01", "02", "03", "04_leaf"]:
arm_bones.append("%s_%s_%s" % [f, seg, side])
var binds_arm_any := _bind_set(bind_bone, arm_bones)
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:
for k in influences:
var bi := vbones[v * influences + k]
var vw: float = vweights[v * influences + k]
if binds_arm_any.has(bi):
armw_any[v] += vw
for limb in LIMBS:
if limb_binds[limb["label"]].has(bi):
limb_w[limb["label"]][v] += vw
# source texture straight from the GLB material — UVs are guaranteed to match
var mat: Material = mesh.surface_get_material(0)
skin_img = (mat as BaseMaterial3D).albedo_texture.get_image()
skin_img.clear_mipmaps()
skin_img.convert(Image.FORMAT_RGBA8)
tw = skin_img.get_width()
th = skin_img.get_height()
print("texture %dx%d" % [tw, th])
for limb in LIMBS:
_bake_limb(limb)
_bake_torso("chest star-eye", CHEST_PATH, 1.0, CHEST_HALF_WIDTH,
CHEST_Y_TOP, CHEST_Y_CLIP, "width")
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")
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)
# ── 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 len2 := p1.distance_to(p2)
var total_len := len1 + len2
var outward := (p0 - center).normalized()
# one global angular frame for the whole limb — per-segment frames jump at the
# joint when the rest pose bends there (Lena's elbow ~17°), printing a split line
var limb_axis := (p2 - p0).normalized()
var uref_g := (outward - limb_axis * outward.dot(limb_axis)).normalized()
var vref_g := limb_axis.cross(uref_g)
var ax1 := (p1 - p0) / len1
var ax2 := (p2 - p1) / len2
# blend width for the seg1/seg2 handover: a hard closest-segment pick is
# discontinuous on the bend's concave (inner-joint) side
const JUNCTION_BLEND := 0.03
var t0: float = limb["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 s1 := clampf((pos - p0).dot(ax1), 0.0, len1)
var cp1 := p0 + ax1 * s1
var d1 := pos.distance_to(cp1)
var s2 := clampf((pos - p1).dot(ax2), 0.0, len2)
var cp2 := p1 + ax2 * s2
var d2 := pos.distance_to(cp2)
var wb := smoothstep(-JUNCTION_BLEND, JUNCTION_BLEND, d1 - d2)
var t_along := lerpf(s1 / total_len, (len1 + s2) / total_len, wb)
var dv: Vector3 = pos - cp1.lerp(cp2, wb)
var ang := atan2(dv.dot(vref_g), dv.dot(uref_g))
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 near-black texels and fabric texels (painted bra/underwear: bright with
# low red-blue chroma; skin is warm — (r-b)/r ≈ 0.5) 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)
var lum := (base_col.r + base_col.g + base_col.b) / 3.0
if lum < DARK_SKIP:
continue
if lum > 0.60 and (base_col.r - base_col.b) / maxf(base_col.r, 0.001) < 0.27:
continue # fabric
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)
+415
View File
@@ -0,0 +1,415 @@
# v5 bake — seamless limb sleeves + v3 torso plates:
# limbs: the rectangular gen-v5 sleeve textures wrap the limb cylindrically
# with a MIRRORED repeat (u ping-pongs around the circumference), so
# the loop points are continuous by construction — no seam line, no
# pattern mismatch. Every texel of the limb's UV island is painted
# (full coverage), shorts texels skipped on legs.
# torso: the v3 planar chest/back pieces (the look Can preferred), verbatim
# from bake.gd.
# Output: baked_body.png. Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://bake_v5.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_V3 := "C:/Users/CAN/tinqs-ltd/docs/conceptart/generated-images/tattoo-marks/"
const GEN_V5 := "C:/Users/CAN/tinqs-ltd/tattoo-test/gen-v5/"
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
const DILATE := 4
const LIMBS := [
{"label": "arm_r", "tex": GEN_V5 + "v5_arm_r_chiefs-mark.png",
"bones": ["upperarm_r", "lowerarm_r", "hand_r"], "center": "spine_03", "hem": false},
{"label": "arm_l", "tex": GEN_V5 + "v5_arm_l_iron-spine.png",
"bones": ["upperarm_l", "lowerarm_l", "hand_l"], "center": "spine_03", "hem": false},
{"label": "leg_r", "tex": GEN_V5 + "v5_leg_r_voyagers-current.png",
"bones": ["thigh_r", "calf_r", "foot_r"], "center": "pelvis", "hem": true},
{"label": "leg_l", "tex": GEN_V5 + "v5_leg_l_storm-bearer.png",
"bones": ["thigh_l", "calf_l", "foot_l"], "center": "pelvis", "hem": true},
]
# v3 torso pieces + framing constants, verbatim from bake.gd
const BACK_PATH := MARKS_V3 + "mark-06-hearth-warmth_back_v3.png"
const CHEST_PATH := MARKS_V3 + "mark-09-star-eye_chest_v3.png"
const BACK_HALF_WIDTH := 0.21
const BACK_Y_TOP_OFFSET := 0.04
const BACK_Y_CLIP := 1.04
const CHEST_HALF_WIDTH := 0.21
const CHEST_Y_TOP := 1.50
const CHEST_Y_CLIP := 1.15
var skel: Skeleton3D
var verts: PackedVector3Array
var normals: PackedVector3Array
var uvs: PackedVector2Array
var indices: PackedInt32Array
var armw_any: PackedFloat32Array
var skin_img: Image
var painted_mask: PackedByteArray
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)
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()
painted_mask = PackedByteArray()
painted_mask.resize(tw * th)
# per-vertex weights: per-limb (island pick) + any-arm (torso exclusion)
var limb_w := {}
for limb in LIMBS:
limb_w[limb["label"]] = _weight_sum(bind_bone, vbones, vweights, influences, nverts,
limb["bones"].slice(0, 2))
armw_any = _weight_sum(bind_bone, vbones, vweights, influences, nverts,
["upperarm_l", "lowerarm_l", "hand_l", "upperarm_r", "lowerarm_r", "hand_r"])
var comp := _uv_components(nverts)
for limb in LIMBS:
var root := _dominant_component(comp, limb_w[limb["label"]], nverts)
_bake_limb_island(limb, comp, root)
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")
_dilate()
skin_img.save_png(OUT_PATH)
print("saved: ", OUT_PATH)
print("bake took %d ms" % (Time.get_ticks_msec() - t_start))
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
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
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
func _bone_pos(bone: String) -> Vector3:
return skel.get_bone_global_rest(skel.find_bone(bone)).origin
func _ink_bbox(img: Image) -> Rect2:
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)
return Rect2(float(minx) / w, float(miny) / h,
float(maxx - minx + 1) / w, float(maxy - miny + 1) / h)
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)
func _is_skin(px: int, py: int) -> bool:
var c := skin_img.get_pixel(px, py)
return (c.r + c.g + c.b) / 3.0 >= 0.30
# cylindrical coords of a 3D point against the limb's bone chain
func _limb_coords(pos: Vector3, segs: Array, total_len: float, outward: Vector3) -> Vector2:
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))
return Vector2(ang, t_along)
# ── limb sleeve: full island painted, mirrored wrap (seamless loop) ─────────
func _bake_limb_island(limb: Dictionary, comp: PackedInt32Array, root: int) -> void:
var tat := Image.load_from_file(limb["tex"])
tat.convert(Image.FORMAT_RGBA8)
var crop := _ink_bbox(tat)
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]]
var skip_shorts: bool = limb["hem"]
# v range over the island's SKIN vertices (legs: hem→ankle, arms: cap→wrist)
var t_min := 1e9
var t_max := -1e9
for v in verts.size():
if comp[v] != root:
continue
if skip_shorts:
var sx := clampi(int(uvs[v].x * tw), 0, tw - 1)
var sy := clampi(int(uvs[v].y * th), 0, th - 1)
if not _is_skin(sx, sy):
continue
var t := _limb_coords(verts[v], segs, total_len, outward).y
t_min = minf(t_min, t)
t_max = maxf(t_max, t)
print("%s v-range %.3f..%.3f" % [limb["label"], t_min, t_max])
var painted := 0
var ntris := indices.size() / 3
for t in ntris:
var i0 := indices[t * 3]
if comp[i0] != root:
continue
var i1 := indices[t * 3 + 1]
var i2 := indices[t * 3 + 2]
painted += _raster_tri(i0, i1, i2, skip_shorts,
func(pos: Vector3, _nrm: Vector3, _bary: Vector3) -> float:
var cc := _limb_coords(pos, segs, total_len, outward)
var v_norm := clampf((cc.y - t_min) / (t_max - t_min), 0.0, 1.0)
var frac := fposmod(0.5 + cc.x / TAU, 1.0)
var u := 1.0 - absf(2.0 * frac - 1.0) # mirrored repeat: seamless loop
var c := _sample_crop(tat, crop, u, v_norm)
var ink := 1.0 - smoothstep(0.45, 0.80, (c.r + c.g + c.b) / 3.0)
return ink * INK_OPACITY)
print(limb["label"], " painted px: ", painted)
# ── torso piece, v3 planar projection (verbatim from bake.gd) ────────────────
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 crop := _ink_bbox(tat)
var aspect := crop.size.y / crop.size.x # normalized rect → recompute px aspect
var timg_aspect := float(tat.get_height()) / float(tat.get_width())
aspect = aspect * timg_aspect / 1.0
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" % [label, design_w, design_h, y_bot, y_top])
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 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, true,
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
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)
# rasterize one triangle in UV space; shade(pos, nrm, bary) → alpha (<=0 skips
# ink but still marks coverage). skip_shorts guards dark shorts texels.
func _raster_tri(i0: int, i1: int, i2: int, skip_shorts: bool, 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
if skip_shorts and not _is_skin(px, py):
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))
painted_mask[py * tw + px] = 1
if alpha <= 0.003:
continue
skin_img.set_pixel(px, py, skin_img.get_pixel(px, py).lerp(INK_COLOR, alpha))
painted += 1
return painted
# pull composited colors DILATE px into unpainted texels (seam gutters)
func _dilate() -> void:
for pass_i in DILATE:
var grown := PackedInt32Array()
for y in th:
for x in tw:
if painted_mask[y * tw + x] == 1:
continue
var acc := Color(0, 0, 0)
var n := 0
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 >= tw or ny < 0 or ny >= th:
continue
if painted_mask[ny * tw + nx] == 1:
acc += skin_img.get_pixel(nx, ny)
n += 1
if n > 0:
skin_img.set_pixel(x, y, acc / n)
grown.append(y * tw + x)
for p in grown:
painted_mask[p] = 1
func _sample_bilinear(img: Image, x: float, y: float) -> Color:
var w := img.get_width()
var h := img.get_height()
var fx := clampf(x * w - 0.5, 0.0, w - 1.001)
var fy := clampf(y * h - 0.5, 0.0, h - 1.001)
var x0 := int(fx)
var y0 := int(fy)
var x1 := mini(x0 + 1, w - 1)
var y1 := mini(y0 + 1, h - 1)
var tx := fx - x0
var ty := fy - y0
return img.get_pixel(x0, y0).lerp(img.get_pixel(x1, y0), tx) \
.lerp(img.get_pixel(x0, y1).lerp(img.get_pixel(x1, y1), tx), ty)
+526
View File
@@ -0,0 +1,526 @@
# v6 bake — aspect-true sleeves + inset tops + raised back (on top of v5):
# limbs: mirrored cylindrical wrap as v5, but sampling is ASPECT-CORRECT —
# per limb we measure length L and half-circumference C from the mesh
# and sample only a centered horizontal strip of the texture
# (u_span = C/L * texH/texW) so on-body texels stay square: no more
# horizontal/vertical stretch. Limbs sample the FULL texture (no ink
# bbox crop) so authored negative space survives. Arms start INSET
# below the shoulder cap so the sleeve never touches the arm/torso
# meeting line.
# torso: v3 planar pieces; back raised (top offset 0.04→0.10), bottom lifted
# (clip 1.04→1.08), widened (half-width 0.21→0.26) and its arm-weight
# exclusion relaxed so it rides a little onto the rear shoulder.
# Output: baked_body.png. Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://bake_v6.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_V3 := "C:/Users/CAN/tinqs-ltd/design/conceptart/generated-images/tattoo-marks/"
const GEN_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/gen-v6/"
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
const DILATE := 4
const LIMBS := [
# arms: T-pose "up" is the anatomical outer face once arms hang down (the
# spine→shoulder direction is near-parallel to a T-pose arm — unusable)
{"label": "arm_r", "tex": GEN_DIR + "v6_arm_r_chiefs-mark.png",
"bones": ["upperarm_r", "lowerarm_r", "hand_r"], "center": "spine_03",
"hem": false, "inset": 0.10, "out_up": true},
{"label": "arm_l", "tex": GEN_DIR + "v6_arm_l_iron-spine.png",
"bones": ["upperarm_l", "lowerarm_l", "hand_l"], "center": "spine_03",
"hem": false, "inset": 0.10, "out_up": true},
{"label": "leg_r", "tex": GEN_DIR + "v6_leg_r_voyagers-current.png",
"bones": ["thigh_r", "calf_r", "foot_r"], "center": "pelvis",
"hem": true, "inset": 0.0},
{"label": "leg_l", "tex": GEN_DIR + "v6_leg_l_storm-bearer.png",
"bones": ["thigh_l", "calf_l", "foot_l"], "center": "pelvis",
"hem": true, "inset": 0.0},
]
# v3 torso pieces; back framing raised + widened for v6
const BACK_PATH := MARKS_V3 + "mark-06-hearth-warmth_back_v3.png"
const CHEST_PATH := MARKS_V3 + "mark-09-star-eye_chest_v3.png"
const BACK_HALF_WIDTH := 0.26
const BACK_Y_TOP_OFFSET := 0.10
const BACK_Y_CLIP := 1.08
const CHEST_HALF_WIDTH := 0.21
const CHEST_Y_TOP := 1.50
const CHEST_Y_CLIP := 1.15
var skel: Skeleton3D
var verts: PackedVector3Array
var normals: PackedVector3Array
var uvs: PackedVector2Array
var indices: PackedInt32Array
var armw_any: PackedFloat32Array
var skin_img: Image
var painted_mask: PackedByteArray
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)
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()
painted_mask = PackedByteArray()
painted_mask.resize(tw * th)
# per-vertex weights: per-limb (island pick) + any-arm (torso exclusion)
var limb_w := {}
for limb in LIMBS:
limb_w[limb["label"]] = _weight_sum(bind_bone, vbones, vweights, influences, nverts,
limb["bones"].slice(0, 2))
armw_any = _weight_sum(bind_bone, vbones, vweights, influences, nverts,
["upperarm_l", "lowerarm_l", "hand_l", "upperarm_r", "lowerarm_r", "hand_r"])
var comp := _uv_components(nverts)
for limb in LIMBS:
var root := _dominant_component(comp, limb_w[limb["label"]], nverts)
_bake_limb_island(limb, comp, root)
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", 0.5, 0.75, 0.85)
_bake_torso("chest star-eye", CHEST_PATH, 1.0, CHEST_HALF_WIDTH,
CHEST_Y_TOP, CHEST_Y_CLIP, "width", 0.2, 0.4, 0.5)
_dilate()
skin_img.save_png(OUT_PATH)
print("saved: ", OUT_PATH)
print("bake took %d ms" % (Time.get_ticks_msec() - t_start))
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
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
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
func _bone_pos(bone: String) -> Vector3:
return skel.get_bone_global_rest(skel.find_bone(bone)).origin
func _ink_bbox(img: Image) -> Rect2:
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)
return Rect2(float(minx) / w, float(miny) / h,
float(maxx - minx + 1) / w, float(maxy - miny + 1) / h)
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)
func _is_skin(px: int, py: int) -> bool:
var c := skin_img.get_pixel(px, py)
return (c.r + c.g + c.b) / 3.0 >= 0.30
# cylindrical coords of a 3D point against the limb's bone chain; z = radius
func _limb_coords(pos: Vector3, segs: Array, total_len: float, outward: Vector3) -> Vector3:
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))
return Vector3(ang, t_along, best_dist)
# rows of the design that contain ink, merged into clusters [y0, y1]
func _detect_clusters(tat: Image) -> Array:
var w := tat.get_width()
var h := tat.get_height()
var runs := []
var s := -1
for y in h:
var cnt := 0
for x in w:
var c := tat.get_pixel(x, y)
if (c.r + c.g + c.b) / 3.0 < 0.70:
cnt += 1
if cnt >= 3:
break
var inky := cnt >= 3
if inky and s == -1:
s = y
elif not inky and s != -1:
runs.append([s, y - 1])
s = -1
if s != -1:
runs.append([s, h - 1])
var clusters := []
for r in runs:
if clusters.size() > 0 and r[0] - clusters[-1][1] <= 10:
clusters[-1][1] = r[1]
else:
clusters.append(r)
return clusters
# virtual-canvas row remap: keep ink clusters at true pixel scale, stretch only
# the parchment gaps between them to fill the limb's length. remap[y'] = source
# row, or -1 for parchment. Top/bottom margins keep their authored pixel size.
func _build_remap(clusters: Array, h: int, hprime: int) -> PackedInt32Array:
var remap := PackedInt32Array()
remap.resize(hprime)
remap.fill(-1)
if clusters.is_empty():
return remap
var ink_h := 0
for c in clusters:
ink_h += c[1] - c[0] + 1
var top: int = mini(clusters[0][0], int(0.02 * hprime))
var bot: int = mini(h - 1 - clusters[-1][1], int(0.03 * hprime))
var free := hprime - ink_h - top - bot
if free < 0: # limb shorter than the design: uniform scale fallback
for y in hprime:
remap[y] = int(float(y) * h / hprime)
return remap
var gsum := 0.0
for i in clusters.size() - 1:
gsum += clusters[i + 1][0] - clusters[i][1] - 1
var cursor := top
for i in clusters.size():
var c: Array = clusters[i]
for k in c[1] - c[0] + 1:
if cursor + k < hprime:
remap[cursor + k] = c[0] + k
cursor += c[1] - c[0] + 1
if i < clusters.size() - 1:
var g := float(clusters[i + 1][0] - c[1] - 1)
cursor += int(free * (g / gsum if gsum > 0.0 else 1.0 / (clusters.size() - 1)))
return remap
# ── limb sleeve: mirrored wrap, aspect-true cluster layout, inset top ────────
func _bake_limb_island(limb: Dictionary, comp: PackedInt32Array, root: int) -> void:
var tat := Image.load_from_file(limb["tex"])
tat.convert(Image.FORMAT_RGBA8)
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 := Vector3.UP if limb.get("out_up", false) else (p0 - center).normalized()
var segs := [[p0, p1, 0.0], [p1, p2, len1]]
var skip_shorts: bool = limb["hem"]
# island SKIN vertices: v range (legs hem→ankle, arms cap→wrist) + mean radius
var t_min := 1e9
var t_max := -1e9
var r_sum := 0.0
var r_n := 0
for v in verts.size():
if comp[v] != root:
continue
if skip_shorts:
var sx := clampi(int(uvs[v].x * tw), 0, tw - 1)
var sy := clampi(int(uvs[v].y * th), 0, th - 1)
if not _is_skin(sx, sy):
continue
var cc := _limb_coords(verts[v], segs, total_len, outward)
t_min = minf(t_min, cc.y)
t_max = maxf(t_max, cc.y)
r_sum += cc.z
r_n += 1
# Plain 360° wrap, design column centered on the OUTER face; the seam falls
# in the design's parchment margins on the inner limb, so it is invisible.
# Aspect-true: one scale s (px/m) for both axes — the motif column may take
# up to FRAC_CIRC of the circumference and the ink rows up to FRAC_LEN of
# the length; parchment gaps between clusters absorb the rest (row remap).
const FRAC_CIRC := 0.75
const FRAC_LEN := 0.85
var r_mean := r_sum / maxf(1.0, float(r_n))
var t_top := t_min + float(limb["inset"]) * (t_max - t_min)
var phys_len := (t_max - t_top) * total_len
var circ := TAU * r_mean
var crop := _ink_bbox(tat)
var texw := float(tat.get_width())
var texh := tat.get_height()
var crop_w_px := crop.size.x * texw
var crop_cx := (crop.position.x + crop.size.x * 0.5) * texw
var clusters := _detect_clusters(tat)
var ink_rows := 0
for cl in clusters:
ink_rows += cl[1] - cl[0] + 1
var s := maxf(crop_w_px / (FRAC_CIRC * circ), float(ink_rows) / (FRAC_LEN * phys_len))
var hprime := maxi(1, int(s * phys_len))
var remap := _build_remap(clusters, texh, hprime)
print("%s v-range %.3f..%.3f (inset→%.3f) r=%.3f L=%.3f circ=%.3f | %d clusters, ink %dpx, s=%.0f px/m, col=%.2f of circ"
% [limb["label"], t_min, t_max, t_top, r_mean, phys_len, circ,
clusters.size(), ink_rows, s, crop_w_px / (s * circ)])
var painted := 0
var ntris := indices.size() / 3
for t in ntris:
var i0 := indices[t * 3]
if comp[i0] != root:
continue
var i1 := indices[t * 3 + 1]
var i2 := indices[t * 3 + 2]
painted += _raster_tri(i0, i1, i2, skip_shorts,
func(pos: Vector3, _nrm: Vector3, _bary: Vector3) -> float:
var cc := _limb_coords(pos, segs, total_len, outward)
var v_norm := (cc.y - t_top) / (t_max - t_top)
if v_norm < 0.0 or v_norm > 1.0:
return -1.0
var src_row := remap[clampi(int(v_norm * (hprime - 1)), 0, hprime - 1)]
if src_row < 0:
return 0.0 # parchment gap: bare skin, but mark coverage
var u_frac := fposmod(0.5 + cc.x / TAU, 1.0) # 0.5 = outer face
var x_px := crop_cx + (u_frac - 0.5) * s * circ
if x_px < 0.0 or x_px >= texw:
return 0.0 # beyond the canvas: bare skin (seam side)
var c := _sample_bilinear(tat, x_px / texw, (float(src_row) + 0.5) / texh)
var ink := 1.0 - smoothstep(0.45, 0.80, (c.r + c.g + c.b) / 3.0)
return ink * INK_OPACITY * smoothstep(0.0, 0.012, v_norm))
print(limb["label"], " painted px: ", painted)
# ── torso piece, v3 planar projection with tunable arm-weight exclusion ──────
func _bake_torso(label: String, tex_path: String, facing: float, hw: float,
y_top: float, y_clip: float, fit: String,
arm_fade_lo: float, arm_fade_hi: float, arm_skip: float) -> void:
var tat := Image.load_from_file(tex_path)
tat.convert(Image.FORMAT_RGBA8)
var crop := _ink_bbox(tat)
var aspect := crop.size.y / crop.size.x # normalized rect → recompute px aspect
var timg_aspect := float(tat.get_height()) / float(tat.get_width())
aspect = aspect * timg_aspect / 1.0
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" % [label, design_w, design_h, y_bot, y_top])
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 min(armw_any[i0], min(armw_any[i1], armw_any[i2])) > arm_skip:
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, true,
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 > arm_fade_hi:
return -1.0
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(arm_fade_lo, arm_fade_hi, aw)
return alpha)
print(label, " painted px: ", painted)
# rasterize one triangle in UV space; shade(pos, nrm, bary) → alpha (<=0 skips
# ink but still marks coverage). skip_shorts guards dark shorts texels.
func _raster_tri(i0: int, i1: int, i2: int, skip_shorts: bool, 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
if skip_shorts and not _is_skin(px, py):
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))
painted_mask[py * tw + px] = 1
if alpha <= 0.003:
continue
skin_img.set_pixel(px, py, skin_img.get_pixel(px, py).lerp(INK_COLOR, alpha))
painted += 1
return painted
# pull composited colors DILATE px into unpainted texels (seam gutters)
func _dilate() -> void:
for pass_i in DILATE:
var grown := PackedInt32Array()
for y in th:
for x in tw:
if painted_mask[y * tw + x] == 1:
continue
var acc := Color(0, 0, 0)
var n := 0
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 >= tw or ny < 0 or ny >= th:
continue
if painted_mask[ny * tw + nx] == 1:
acc += skin_img.get_pixel(nx, ny)
n += 1
if n > 0:
skin_img.set_pixel(x, y, acc / n)
grown.append(y * tw + x)
for p in grown:
painted_mask[p] = 1
func _sample_bilinear(img: Image, x: float, y: float) -> Color:
var w := img.get_width()
var h := img.get_height()
var fx := clampf(x * w - 0.5, 0.0, w - 1.001)
var fy := clampf(y * h - 0.5, 0.0, h - 1.001)
var x0 := int(fx)
var y0 := int(fy)
var x1 := mini(x0 + 1, w - 1)
var y1 := mini(y0 + 1, h - 1)
var tx := fx - x0
var ty := fy - y0
return img.get_pixel(x0, y0).lerp(img.get_pixel(x1, y0), tx) \
.lerp(img.get_pixel(x0, y1).lerp(img.get_pixel(x1, y1), tx), ty)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
# Prints image size + ink bounding box (brightness < 0.75) for each v3 mark.
extends SceneTree
const MARKS := "C:/Users/CAN/tinqs-ltd/docs/conceptart/generated-images/tattoo-marks/"
const FILES := [
"mark-01-chiefs-mark_arm_v3.png",
"mark-10-iron-spine_arm_v3.png",
"mark-02-voyagers-current_leg_v3.png",
"mark-07-storm-bearer_leg_v3.png",
"mark-06-hearth-warmth_back_v3.png",
"mark-09-star-eye_chest_v3.png",
]
func _init() -> void:
for f in FILES:
var img := Image.load_from_file(MARKS + f)
img.convert(Image.FORMAT_RGBA8)
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 bw := maxx - minx + 1
var bh := maxy - miny + 1
print("%s %dx%d bbox x %d..%d y %d..%d (%dx%d, aspect h/w %.2f)" %
[f, w, h, minx, maxx, miny, maxy, bw, bh, float(bh) / float(bw)])
quit()
+26
View File
@@ -0,0 +1,26 @@
# Dumps all bone names + rest origins of the Quaternius Regular Male skeleton.
extends SceneTree
const GLTF_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/Regular_Male_FullBody.gltf"
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 stack: Array = [scene]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D:
var skel: Skeleton3D = n
for i in skel.get_bone_count():
var o := skel.get_bone_global_rest(i).origin
print("%s (%.3f, %.3f, %.3f)" % [skel.get_bone_name(i), o.x, o.y, o.z])
quit()
return
for c in n.get_children():
stack.push_back(c)
quit(1)
+119
View File
@@ -0,0 +1,119 @@
# Composites the v4 mask-fitted marks into the Quaternius base-color texture:
# maps each gen-v4 result back into UV space (inverse of prep.gd's transform),
# extracts ink by luminance, multiplies it onto skin inside the part mask, then
# dilates 4px into unpainted texels so UV-seam bilinear/mip sampling never shows
# a bare gutter line. Backs up the previous bake to baked_body_v3.png once.
# Run headless (after _gen-marks-v4.ps1):
# tinqs.console.exe --headless --path tattoo-test -s res://composite.gd
extends SceneTree
const SKIN_TEX_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Regular_Male_Dark_BaseColor_png.png"
const MASK_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/masks/"
const GEN_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/gen-v4/"
const OUT_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body.png"
const BACKUP_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body_v3.png"
const INK_COLOR := Color(0.10, 0.085, 0.08)
const INK_OPACITY := 0.88
const DILATE := 4
const JOBS := [
{"part": "arm_r", "mark": "chiefs-mark"},
{"part": "arm_l", "mark": "iron-spine"},
{"part": "leg_r", "mark": "voyagers-current"},
{"part": "leg_l", "mark": "storm-bearer"},
{"part": "chest", "mark": "star-eye"},
{"part": "back", "mark": "hearth-warmth"},
]
func _init() -> void:
var prep := load("res://prep.gd")
if FileAccess.file_exists(OUT_PATH) and not FileAccess.file_exists(BACKUP_PATH):
DirAccess.copy_absolute(OUT_PATH, BACKUP_PATH)
print("backed up previous bake -> ", BACKUP_PATH)
var img := Image.load_from_file(SKIN_TEX_PATH)
img.convert(Image.FORMAT_RGBA8)
var size := img.get_width()
var painted := PackedByteArray()
painted.resize(size * size)
var missing := 0
for job in JOBS:
var gen_path: String = GEN_DIR + "v4_" + job["part"] + "_" + job["mark"] + ".png"
if not FileAccess.file_exists(gen_path):
push_error("missing gen result: " + gen_path + " (run _gen-marks-v4.ps1 first)")
missing += 1
continue
var mask := Image.load_from_file(MASK_DIR + "mask_" + job["part"] + ".png")
var gen := Image.load_from_file(gen_path)
gen.convert(Image.FORMAT_RGBA8)
var t: Dictionary = prep.transform_for(mask)
var rect: Rect2i = t["rect"]
var s: float = t["scale"]
var off: Vector2 = t["offset"]
var out_scale := float(gen.get_width()) / float(prep.CANVAS)
var mask_scale := float(mask.get_width()) / float(size)
var count := 0
for py in range(rect.position.y, rect.end.y):
for px in range(rect.position.x, rect.end.x):
var mx := int(px * mask_scale)
var my := int(py * mask_scale)
if mask.get_pixel(mx, my).r < 0.5:
continue
var cx := ((px - rect.position.x) + 0.5) * s + off.x
var cy := ((py - rect.position.y) + 0.5) * s + off.y
var c := _sample_bilinear(gen, cx * out_scale, cy * out_scale)
var ink := 1.0 - smoothstep(0.45, 0.80, (c.r + c.g + c.b) / 3.0)
var alpha := ink * INK_OPACITY
if alpha > 0.003:
img.set_pixel(px, py, img.get_pixel(px, py).lerp(INK_COLOR, alpha))
count += 1
painted[py * size + px] = 1 # whole mask counts as painted (bare skin is intentional)
print("%s %s painted px: %d" % [job["part"], job["mark"], count])
if missing == JOBS.size():
push_error("no gen results found - nothing composited")
quit(1)
return
# edge padding: pull composited colors DILATE px outward into unpainted texels
for pass_i in DILATE:
var grown := PackedInt32Array()
for y in size:
for x in size:
if painted[y * size + x] == 1:
continue
var acc := Color(0, 0, 0)
var n := 0
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
if painted[ny * size + nx] == 1:
acc += img.get_pixel(nx, ny)
n += 1
if n > 0:
img.set_pixel(x, y, acc / n)
grown.append(y * size + x)
for p in grown:
painted[p] = 1
img.save_png(OUT_PATH)
print("saved: ", OUT_PATH)
quit()
func _sample_bilinear(img: Image, x: float, y: float) -> Color:
var w := img.get_width()
var h := img.get_height()
var fx := clampf(x - 0.5, 0.0, w - 1.001)
var fy := clampf(y - 0.5, 0.0, h - 1.001)
var x0 := int(fx)
var y0 := int(fy)
var x1 := mini(x0 + 1, w - 1)
var y1 := mini(y0 + 1, h - 1)
var tx := fx - x0
var ty := fy - y0
return img.get_pixel(x0, y0).lerp(img.get_pixel(x1, y0), tx) \
.lerp(img.get_pixel(x0, y1).lerp(img.get_pixel(x1, y1), tx), ty)
BIN
View File
Binary file not shown.
+257
View File
@@ -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
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not $env:FAL_KEY) { $env:FAL_KEY = [System.Environment]::GetEnvironmentVariable("FAL_KEY","User") }
if (-not $env:FAL_KEY) { throw "FAL_KEY not set." }
$auth = @{ Authorization = "Key $env:FAL_KEY" }
$dir = "C:\Users\CAN\tinqs-ltd\tattoo-test\gen-v4"
$log = "$dir\_gen-marks-v4.log"
"START $(Get-Date -Format o)" | Out-File $log
# v4: generated INTO the exact UV-island silhouette. Arms/legs are FULL SLEEVES:
# dense allover coverage, zero bare skin. Torso plates fill their whole silhouette.
$style = "Polynesian / Micronesian TRIBAL tattoo, solid black ink on off-white parchment. ONE clear bold hero symbol carried by tribal detailing: thin NEGATIVE-SPACE lines carved through the thick black shapes, small koru spirals, shark-tooth (niho) notches and light dotwork. Confident linework, strong tribal character."
$fill = "The input image shows a light parchment SILHOUETTE on a black background - it is the UV texture island of a 3D character's body part. Fill the ENTIRE parchment silhouette with the tattoo design so it reaches EVERY edge of the silhouette. Keep the black background outside the silhouette pure black and untouched. The silhouette's vertical axis maps to the body part: TOP of the silhouette is the TOP of the body part."
$sleeve = "This is a FULL SLEEVE: cover the COMPLETE silhouette with dense, continuous tribal patternwork - stacked horizontal pattern bands (niho teeth rows, current lines, koru scroll bands, dotwork bands) flowing from the very top edge to the very bottom edge. Parchment may only appear as thin negative-space linework INSIDE the pattern, never as empty regions. ABSOLUTELY NO bare parchment areas, margins or gaps anywhere inside the silhouette."
$plate = "Fill the whole silhouette with the piece: bold hero plus supporting pattern bands reaching every edge, parchment only as carved negative-space detail inside the pattern."
$neg = "No color, no grey shading, no gradient, no photoreal, no 3D, no human figure, no text. Keep the carved negative-space detail crisp - not a solid featureless black blob."
$jobs = @(
@{ part="arm_r"; mark="chiefs-mark"; mode="sleeve"; d="Theme - CHIEF'S CREST: hero is a bold spearhead crown with a fan of broad rays at the TOP (shoulder); below it, continuous bands of interlocking koru spirals and niho tooth rows all the way down to the BOTTOM edge (wrist)." }
@{ part="arm_l"; mark="iron-spine"; mode="sleeve"; d="Theme - IRON SPINE (warrior): hero is two bold crossed spears with barbed heads at the TOP (shoulder); below it, continuous stacked rows of shark-tooth (niho) triangles, spearhead chevrons and small spirals down to the BOTTOM edge (wrist)." }
@{ part="leg_r"; mark="voyagers-current"; mode="sleeve"; d="Theme - VOYAGER'S CURRENT (ocean): hero is a big bold curling koru WAVE at the TOP (thigh); below it, continuous flowing current bands, wave scrolls and spiral eddies down to the BOTTOM edge (ankle)." }
@{ part="leg_l"; mark="storm-bearer"; mode="sleeve"; d="Theme - STORM BEARER: hero is a bold LIGHTNING bolt flanked by two storm-cloud koru scrolls at the TOP (thigh); below it, continuous bands of jagged rain lines, zigzag bolts and dotwork down to the BOTTOM edge (ankle)." }
@{ part="chest"; mark="star-eye"; mode="plate"; d="Theme - STAR EYE (navigator): hero is a bold eight-point STAR with a carved spiral center, high on the silhouette (chest); a navigator's line band with star dots sweeps symmetrically outward and down the abdomen to the BOTTOM edge. SYMMETRICAL layout." }
@{ part="back"; mark="hearth-warmth"; mode="plate"; d="Theme - HEARTH WARMTH (fire): hero is a bold three-tongue FLAME whose tip reaches the TOP edge (neck); a radiant base of carved rays and ember dotwork spreads down to the BOTTOM edge (lower back). SYMMETRICAL layout." }
)
$perImage = 0.17 # GPT Image 2 edit, quality=high (estimate)
$runCost = 0.0; $ok = 0; $fail = 0; $skip = 0
foreach ($j in $jobs) {
$name = "v4_$($j.part)_$($j.mark)"
$path = "$dir\$name.png"
if (Test-Path $path) { $skip++; "SKIP $name (exists)" | Tee-Object -FilePath $log -Append | Out-Null; continue }
Write-Host "=== $name ===" -ForegroundColor Cyan
$canvasB64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes("$dir\canvas_$($j.part).png"))
$coverage = if ($j.mode -eq "sleeve") { $sleeve } else { $plate }
$prompt = "$style`n$fill`n$coverage`n$($j.d)`nSTRICT: $neg"
try {
$body = @{ prompt=$prompt; image_urls=@("data:image/png;base64,$canvasB64"); image_size="square"; quality="high"; num_images=1 } | ConvertTo-Json -Depth 6 -Compress
$resp = Invoke-RestMethod -Method Post -Uri "https://fal.run/openai/gpt-image-2/edit" -Headers $auth -ContentType 'application/json' -Body $body -TimeoutSec 600
Invoke-WebRequest -Uri $resp.images[0].url -OutFile $path -TimeoutSec 300
$runCost += $perImage; $ok++
"OK $name" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " OK" -ForegroundColor Green
} catch {
$fail++
"FAIL $name : $($_.Exception.Message) $($_.ErrorDetails.Message)" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " FAIL: $($_.Exception.Message)" -ForegroundColor Red
}
}
"DONE ok=$ok fail=$fail skip=$skip RUN_COST_USD=$([math]::Round($runCost,2)) $(Get-Date -Format o)" | Tee-Object -FilePath $log -Append
Write-Host ("RUN_COST_USD={0:N2} ok={1} fail={2} skip={3}" -f $runCost,$ok,$fail,$skip) -ForegroundColor Yellow
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not $env:FAL_KEY) { $env:FAL_KEY = [System.Environment]::GetEnvironmentVariable("FAL_KEY","User") }
if (-not $env:FAL_KEY) { throw "FAL_KEY not set." }
$auth = @{ Authorization = "Key $env:FAL_KEY" }
$dir = "C:\Users\CAN\tinqs-ltd\tattoo-test\gen-v5"
$log = "$dir\_gen-sleeves-v5.log"
"START $(Get-Date -Format o)" | Out-File $log
# v5 limb sleeves: RECTANGULAR dense pattern textures, wrapped around the limb
# with a MIRRORED repeat by bake_v5.gd (mirror = seamless at the loop points,
# no left/right edge matching required). Chest/back stay on the v3 planar art.
$style = "Polynesian / Micronesian TRIBAL tattoo pattern, solid black ink on off-white parchment. Thick black shapes carved with thin NEGATIVE-SPACE lines, koru spirals, shark-tooth (niho) notches, light dotwork. Confident linework, strong tribal character."
$fill = "FULL-BLEED RECTANGULAR SLEEVE TEXTURE: dense continuous tribal patternwork covering the ENTIRE canvas edge-to-edge on all four sides - stacked horizontal pattern bands flowing top to bottom. Parchment appears ONLY as thin negative-space linework inside the pattern, never as empty areas, borders or margins. The pattern must run fully to the left and right edges without a framing border."
$neg = "No color, no grey shading, no gradient, no photoreal, no 3D, no human figure, no text, no outer border or frame, no vignette. Keep carved negative-space detail crisp - not a solid black field."
$jobs = @(
@{ part="arm_r"; mark="chiefs-mark"; d="Theme - CHIEF'S CREST: a bold spearhead crown with a fan of broad rays as the TOP band (shoulder); below it, continuous bands of interlocking koru spirals and niho tooth rows down to the BOTTOM edge (wrist)." }
@{ part="arm_l"; mark="iron-spine"; d="Theme - IRON SPINE (warrior): two bold crossed spears with barbed heads as the TOP band (shoulder); below it, continuous stacked rows of shark-tooth (niho) triangles, spearhead chevrons and small spirals down to the BOTTOM edge (wrist)." }
@{ part="leg_r"; mark="voyagers-current"; d="Theme - VOYAGER'S CURRENT (ocean): a big bold curling koru WAVE as the TOP band (thigh); below it, continuous flowing current bands, wave scrolls and spiral eddies down to the BOTTOM edge (ankle)." }
@{ part="leg_l"; mark="storm-bearer"; d="Theme - STORM BEARER: a bold LIGHTNING bolt flanked by two storm-cloud koru scrolls as the TOP band (thigh); below it, continuous bands of jagged rain lines, zigzag bolts and dotwork down to the BOTTOM edge (ankle)." }
)
$perImage = 0.17 # GPT Image 2, quality=high (estimate)
$runCost = 0.0; $ok = 0; $fail = 0; $skip = 0
foreach ($j in $jobs) {
$name = "v5_$($j.part)_$($j.mark)"
$path = "$dir\$name.png"
if (Test-Path $path) { $skip++; "SKIP $name (exists)" | Tee-Object -FilePath $log -Append | Out-Null; continue }
Write-Host "=== $name ===" -ForegroundColor Cyan
$prompt = "$style`n$fill`n$($j.d)`nSTRICT: $neg"
try {
$body = @{ prompt=$prompt; image_size="portrait_4_3"; quality="high"; num_images=1 } | ConvertTo-Json -Depth 6 -Compress
$resp = Invoke-RestMethod -Method Post -Uri "https://fal.run/openai/gpt-image-2" -Headers $auth -ContentType 'application/json' -Body $body -TimeoutSec 600
Invoke-WebRequest -Uri $resp.images[0].url -OutFile $path -TimeoutSec 300
$runCost += $perImage; $ok++
"OK $name" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " OK" -ForegroundColor Green
} catch {
$fail++
"FAIL $name : $($_.Exception.Message) $($_.ErrorDetails.Message)" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " FAIL: $($_.Exception.Message)" -ForegroundColor Red
}
}
"DONE ok=$ok fail=$fail skip=$skip RUN_COST_USD=$([math]::Round($runCost,2)) $(Get-Date -Format o)" | Tee-Object -FilePath $log -Append
Write-Host ("RUN_COST_USD={0:N2} ok={1} fail={2} skip={3}" -f $runCost,$ok,$fail,$skip) -ForegroundColor Yellow
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not $env:FAL_KEY) { $env:FAL_KEY = [System.Environment]::GetEnvironmentVariable("FAL_KEY","User") }
if (-not $env:FAL_KEY) { throw "FAL_KEY not set." }
$auth = @{ Authorization = "Key $env:FAL_KEY" }
$dir = "C:\Users\CAN\tinqs-ltd\tattoo-test\gen-v6"
$log = "$dir\_gen-sleeves-v6.log"
"START $(Get-Date -Format o)" | Out-File $log
# v6 limb sleeves: SPARSE vertical compositions with v3-style negative space.
# bake_v6.gd wraps these mirrored + aspect-true and samples only a centered
# strip (arms ~46%, legs ~29% of the width), so the design must live in the
# central column and breathe: bold isolated motif clusters, lots of parchment.
$style = "Polynesian / Marquesan TRIBAL tattoo, solid black ink on off-white parchment. Bold black shapes with crisp edges, koru spirals, shark-tooth (niho) notches, sparing dotwork. Confident, elegant linework."
$fill = "SPARSE VERTICAL SLEEVE COMPOSITION: one elegant column of 3-4 bold motif clusters flowing top to bottom in the CENTER of the canvas, separated by generous EMPTY parchment gaps. AT LEAST HALF of the canvas stays empty parchment - the design must breathe like a classic Maori arm sleeve. The composition starts at the very TOP EDGE with one strong horizontal terminal band; below it the clusters get smaller and sparser toward the bottom edge. The left and right outer edges of the canvas stay mostly empty."
$neg = "No color, no grey shading, no gradients, no photoreal, no 3D, no human figure, no text, no outer border or frame, no vignette, no dense edge-to-edge pattern fill, no background texture."
$jobs = @(
@{ part="arm_r"; mark="chiefs-mark"; d="Theme - CHIEF'S CREST: the terminal band is a spearhead crown with a short fan of broad rays; below it one large koru spiral cluster, then a slim niho tooth band, then one small isolated spiral near the bottom." }
@{ part="arm_l"; mark="iron-spine"; d="Theme - IRON SPINE (warrior): the terminal band is a row of interlocked spearhead chevrons; below it two bold crossed spears with barbed heads as the main motif, then a slim shark-tooth band, then one small chevron mark near the bottom." }
@{ part="leg_r"; mark="voyagers-current"; d="Theme - VOYAGER'S CURRENT (ocean): the terminal band is a row of wave scrolls; below it one large curling koru wave as the main motif, then a slim flowing current line with a single spiral eddy, then one small wave mark near the bottom." }
@{ part="leg_l"; mark="storm-bearer"; d="Theme - STORM BEARER: the terminal band is a row of storm-cloud koru scrolls; below it one bold jagged lightning bolt as the main motif, then a slim zigzag rain band, then one small bolt mark near the bottom." }
)
$perImage = 0.17 # GPT Image 2, quality=high (estimate)
$runCost = 0.0; $ok = 0; $fail = 0; $skip = 0
foreach ($j in $jobs) {
$name = "v6_$($j.part)_$($j.mark)"
$path = "$dir\$name.png"
if (Test-Path $path) { $skip++; "SKIP $name (exists)" | Tee-Object -FilePath $log -Append | Out-Null; continue }
Write-Host "=== $name ===" -ForegroundColor Cyan
$prompt = "$style`n$fill`n$($j.d)`nSTRICT: $neg"
try {
$body = @{ prompt=$prompt; image_size="portrait_4_3"; quality="high"; num_images=1 } | ConvertTo-Json -Depth 6 -Compress
$resp = Invoke-RestMethod -Method Post -Uri "https://fal.run/openai/gpt-image-2" -Headers $auth -ContentType 'application/json' -Body $body -TimeoutSec 600
Invoke-WebRequest -Uri $resp.images[0].url -OutFile $path -TimeoutSec 300
$runCost += $perImage; $ok++
"OK $name" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " OK" -ForegroundColor Green
} catch {
$fail++
"FAIL $name : $($_.Exception.Message) $($_.ErrorDetails.Message)" | Tee-Object -FilePath $log -Append | Out-Null
Write-Host " FAIL: $($_.Exception.Message)" -ForegroundColor Red
}
}
"DONE ok=$ok fail=$fail skip=$skip RUN_COST_USD=$([math]::Round($runCost,2)) $(Get-Date -Format o)" | Tee-Object -FilePath $log -Append
Write-Host ("RUN_COST_USD={0:N2} ok={1} fail={2} skip={3}" -f $runCost,$ok,$fail,$skip) -ForegroundColor Yellow
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+197
View File
@@ -0,0 +1,197 @@
# GIF capture: plays a showcase of UAL1 clips on the tattooed model while the
# camera orbits a full 360°, then quits. Run with movie-maker mode:
# tinqs...console.exe --path <this dir> res://gif_capture.tscn \
# --write-movie <frames dir>/capture.png --fixed-fps 15
extends Node3D
const GLTF_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/Regular_Male_FullBody.gltf"
const BAKED_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body.png"
const NORMAL_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Regular_Male_Normal_png.png"
const EYE_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Eye_Brown.png"
const HAIR_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Hair_1_BaseColor.png"
const UAL1 := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/anim-lib-1/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb"
# [clip, seconds] — one full camera orbit over the whole timeline
const SCHEDULE := [
["Idle_Loop", 2.5],
["Walk_Loop", 2.5],
["Jog_Fwd_Loop", 2.5],
["Dance_Loop", 3.0],
["Punch_Jab", 1.2],
["Punch_Cross", 1.3],
]
var skel: Skeleton3D
var cam: Camera3D
var player: AnimationPlayer
var hud: Label
var elapsed := 0.0
var total := 0.0
var seg_idx := -1
var orbit_pitch := 0.12
var orbit_dist := 2.5
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(12, 12)
ground.mesh = plane
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.16, 0.17, 0.21)
gmat.roughness = 1.0
ground.set_surface_override_material(0, gmat)
add_child(ground)
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLTF_PATH, state) != OK:
push_error("GLTF load failed")
get_tree().quit(1)
return
var model := doc.generate_scene(state)
add_child(model)
var baked := ImageTexture.create_from_image(Image.load_from_file(BAKED_PATH))
var body_normal := ImageTexture.create_from_image(Image.load_from_file(NORMAL_PATH))
var eye_tex := ImageTexture.create_from_image(Image.load_from_file(EYE_PATH))
var hair_tex := ImageTexture.create_from_image(Image.load_from_file(HAIR_PATH))
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
if n is MeshInstance3D:
var lname := String(n.name).to_lower()
var mat := StandardMaterial3D.new()
mat.roughness = 0.85
if lname.contains("regularmale"):
mat.albedo_texture = baked
mat.normal_enabled = true
mat.normal_texture = body_normal
elif lname.contains("eye") and not lname.contains("brow"):
mat.albedo_texture = eye_tex
else:
mat.albedo_texture = hair_tex
for s in n.mesh.get_surface_count():
n.set_surface_override_material(s, mat)
for c in n.get_children():
stack.push_back(c)
_load_ual_clips()
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
hud = Label.new()
hud.position = Vector2(16, 12)
hud.add_theme_font_size_override("font_size", 26)
hud.add_theme_color_override("font_color", Color(0.95, 0.95, 0.9))
add_child(hud)
for spec in SCHEDULE:
total += spec[1]
func _load_ual_clips() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(UAL1, state) != OK:
push_error("UAL1 load failed")
get_tree().quit(1)
return
var ual := doc.generate_scene(state)
var src: AnimationPlayer = null
var stack: Array = [ual]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
src = n
break
for c in n.get_children():
stack.push_back(c)
if src == null:
push_error("no AnimationPlayer in UAL1")
get_tree().quit(1)
return
player = AnimationPlayer.new()
add_child(player)
var skel_path := String(get_path_to(skel))
var lib := AnimationLibrary.new()
for spec in SCHEDULE:
var clip_name: String = spec[0]
if lib.has_animation(clip_name):
continue
if not src.has_animation(clip_name):
push_warning("UAL1 missing clip: " + clip_name)
continue
var anim := src.get_animation(clip_name).duplicate() as Animation
anim.loop_mode = Animation.LOOP_LINEAR
for i in anim.get_track_count():
var path := String(anim.track_get_path(i))
if path.contains("Armature/Skeleton3D"):
anim.track_set_path(i, path.replace("Armature/Skeleton3D", skel_path))
lib.add_animation(clip_name, anim)
player.add_animation_library("", lib)
ual.queue_free()
func _process(delta: float) -> void:
if player == null:
return
elapsed += delta
if elapsed >= total:
get_tree().quit()
return
# which schedule segment are we in?
var t := elapsed
var idx := 0
for spec in SCHEDULE:
if t < spec[1]:
break
t -= spec[1]
idx += 1
idx = mini(idx, SCHEDULE.size() - 1)
if idx != seg_idx:
seg_idx = idx
var clip: String = SCHEDULE[idx][0]
if player.has_animation(clip):
player.play(clip)
hud.text = clip.replace("_Loop", "").replace("_", " ")
# one full orbit over the whole timeline, starting from the front
var yaw := TAU * elapsed / total
var dir := Vector3(
sin(yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://gif_capture.gd" id="1"]
[node name="GifCapture" type="Node3D"]
script = ExtResource("1")
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+187
View File
@@ -0,0 +1,187 @@
# Tattoo viewer: Quaternius Regular Male with six baked tattoo marks.
# Saves verification shots to shots/, then hands over to manual control:
# left-drag orbits the camera around the model, scroll wheel zooms in/out.
extends Node3D
const GLTF_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/Regular_Male_FullBody.gltf"
const BAKED_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_body.png"
const NORMAL_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Regular_Male_Normal_png.png"
const EYE_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Eye_Brown.png"
const HAIR_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/source-models/T_Hair_1_BaseColor.png"
const SHOT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/shots/"
const TAKE_SHOTS := true
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
# manual orbit state (active once shots are done)
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.4
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
var doc := GLTFDocument.new()
var state := GLTFState.new()
var err := doc.append_from_file(GLTF_PATH, state)
if err != OK:
push_error("GLTF load failed")
return
model = doc.generate_scene(state)
add_child(model)
var baked := ImageTexture.create_from_image(Image.load_from_file(BAKED_PATH))
var body_normal := ImageTexture.create_from_image(Image.load_from_file(NORMAL_PATH))
var eye_tex := ImageTexture.create_from_image(Image.load_from_file(EYE_PATH))
var hair_tex := ImageTexture.create_from_image(Image.load_from_file(HAIR_PATH))
var stack: Array = [model]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is Skeleton3D and skel == null:
skel = n
if n is MeshInstance3D:
var lname := String(n.name).to_lower()
var mat := StandardMaterial3D.new()
mat.roughness = 0.85
if lname.contains("regularmale"):
mat.albedo_texture = baked
mat.normal_enabled = true
mat.normal_texture = body_normal
elif lname.contains("eye") and not lname.contains("brow"):
mat.albedo_texture = eye_tex
else:
mat.albedo_texture = hair_tex
for s in n.mesh.get_surface_count():
n.set_surface_override_material(s, mat)
for c in n.get_children():
stack.push_back(c)
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
if TAKE_SHOTS:
await _take_shots()
print("SHOTS_DONE")
# hand over to manual orbit
model.rotation.y = 0.0
interactive = true
_update_cam()
# ── manual orbit: left-drag rotates, scroll wheel zooms ─────────────────────
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 8.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 8.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
# ── verification shots ───────────────────────────────────────────────────────
func _bone_point(bone: String) -> Vector3:
return (skel.global_transform * skel.get_bone_global_rest(skel.find_bone(bone)).origin)
func _limb_mid(a: String, b: String) -> Vector3:
return (_bone_point(a) + _bone_point(b)) * 0.5
func _snap(shot_name: String) -> void:
await get_tree().process_frame
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + shot_name + ".png")
print("shot saved: ", SHOT_DIR + shot_name + ".png")
func _take_shots() -> void:
# full body front / back
model.rotation.y = 0.0
cam.look_at_from_position(Vector3(0, 1.1, 3.4), Vector3(0, 1.0, 0))
await _snap("01_full_front")
model.rotation.y = PI
await get_tree().process_frame
await _snap("02_full_back")
# torso closeups
model.rotation.y = 0.0
await get_tree().process_frame
cam.look_at_from_position(Vector3(0, 1.3, 0.9), Vector3(0, 1.3, 0))
await _snap("03_chest_star_eye")
model.rotation.y = PI
await get_tree().process_frame
cam.look_at_from_position(Vector3(0, 1.25, 0.9), Vector3(0, 1.25, 0))
await _snap("04_back_hearth_warmth")
# arms (front view of each arm)
model.rotation.y = 0.0
await get_tree().process_frame
var mid_r := _limb_mid("upperarm_r", "hand_r")
cam.look_at_from_position(mid_r + Vector3(0, 0.12, 1.0), mid_r)
await _snap("05_arm_r_chiefs_mark")
var mid_l := _limb_mid("upperarm_l", "hand_l")
cam.look_at_from_position(mid_l + Vector3(0, 0.12, 1.0), mid_l)
await _snap("06_arm_l_iron_spine")
# legs (front + outer side of each leg)
var leg_r := _limb_mid("thigh_r", "foot_r")
cam.look_at_from_position(leg_r + Vector3(0, 0.05, 1.1), leg_r)
await _snap("07_leg_r_voyagers_front")
cam.look_at_from_position(leg_r + Vector3(-1.0, 0.05, 0.3), leg_r)
await _snap("08_leg_r_voyagers_outer")
var leg_l := _limb_mid("thigh_l", "foot_l")
cam.look_at_from_position(leg_l + Vector3(0, 0.05, 1.1), leg_l)
await _snap("09_leg_l_storm_front")
cam.look_at_from_position(leg_l + Vector3(1.0, 0.05, 0.3), leg_l)
await _snap("10_leg_l_storm_outer")
model.rotation.y = PI
await get_tree().process_frame
cam.look_at_from_position(leg_r + Vector3(0, 0.05, 1.1), leg_r)
await _snap("11_legs_back")
model.rotation.y = 0.0
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://main.gd" id="1"]
[node name="Main" type="Node3D"]
script = ExtResource("1")
+190
View File
@@ -0,0 +1,190 @@
# Tattoo viewer: QuatSkin Lena with the chiefs-mark v3 right-arm sleeve baked in.
# Saves verification shots to shots-lena/, then hands over to manual control:
# left-drag orbits the camera around the model, scroll wheel zooms in/out.
extends Node3D
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_clean_uv.glb"
const BAKED_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/baked_lena_body.png"
const SHOT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/shots-lena/"
const TAKE_SHOTS := true
var model: Node3D
var skel: Skeleton3D
var cam: Camera3D
var interactive := false
var dragging := false
var orbit_yaw := 0.0
var orbit_pitch := 0.08
var orbit_dist := 3.4
var orbit_target := Vector3(0, 1.0, 0)
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(SHOT_DIR)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.12, 0.13, 0.16)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.75, 0.78, 0.85)
env.ambient_light_energy = 0.7
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, -35, 0)
sun.light_energy = 1.5
add_child(sun)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-15, 140, 0)
fill.light_energy = 0.6
add_child(fill)
var under := DirectionalLight3D.new()
under.rotation_degrees = Vector3(35, 0, 0)
under.light_energy = 0.35
add_child(under)
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
push_error("GLB load failed")
return
model = doc.generate_scene(state)
add_child(model)
var baked := ImageTexture.create_from_image(Image.load_from_file(BAKED_PATH))
var stack: Array = [model]
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"):
var mat := StandardMaterial3D.new()
mat.roughness = 0.85
mat.albedo_texture = baked
mat.cull_mode = BaseMaterial3D.CULL_BACK
for s in n.mesh.get_surface_count():
n.set_surface_override_material(s, mat)
for c in n.get_children():
stack.push_back(c)
cam = Camera3D.new()
cam.fov = 50
add_child(cam)
cam.current = true
if TAKE_SHOTS:
await _take_shots()
print("SHOTS_DONE")
model.rotation.y = 0.0
interactive = true
_update_cam()
func _unhandled_input(event: InputEvent) -> void:
if not interactive:
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
dragging = event.pressed
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
orbit_dist = clampf(orbit_dist * 0.90, 0.35, 8.0)
_update_cam()
elif event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
orbit_dist = clampf(orbit_dist / 0.90, 0.35, 8.0)
_update_cam()
elif event is InputEventMouseMotion and dragging:
orbit_yaw -= event.relative.x * 0.008
orbit_pitch = clampf(orbit_pitch + event.relative.y * 0.008, -1.3, 1.3)
_update_cam()
func _update_cam() -> void:
var dir := Vector3(
sin(orbit_yaw) * cos(orbit_pitch),
sin(orbit_pitch),
cos(orbit_yaw) * cos(orbit_pitch))
cam.look_at_from_position(orbit_target + dir * orbit_dist, orbit_target)
func _bone_point(bone: String) -> Vector3:
return (skel.global_transform * skel.get_bone_global_rest(skel.find_bone(bone)).origin)
func _limb_mid(a: String, b: String) -> Vector3:
return (_bone_point(a) + _bone_point(b)) * 0.5
func _snap(shot_name: String) -> void:
await get_tree().process_frame
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png(SHOT_DIR + shot_name + ".png")
print("shot saved: ", SHOT_DIR + shot_name + ".png")
func _take_shots() -> void:
# full body front / back
model.rotation.y = 0.0
cam.look_at_from_position(Vector3(0, 1.1, 3.4), Vector3(0, 1.0, 0))
await _snap("01_full_front")
model.rotation.y = PI
await get_tree().process_frame
await _snap("02_full_back")
# right arm: front, outer side, back
model.rotation.y = 0.0
await get_tree().process_frame
var mid_r := _limb_mid("upperarm_r", "hand_r")
cam.look_at_from_position(mid_r + Vector3(0, 0.12, 1.0), mid_r)
await _snap("03_arm_r_front")
cam.look_at_from_position(mid_r + Vector3(-1.0, 0.12, 0.15), mid_r)
await _snap("04_arm_r_outer")
model.rotation.y = PI
await get_tree().process_frame
var mid_r_back := _limb_mid("upperarm_r", "hand_r")
mid_r_back.x = -mid_r_back.x
mid_r_back.z = -mid_r_back.z
cam.look_at_from_position(mid_r_back + Vector3(0, 0.12, 1.0), mid_r_back)
await _snap("05_arm_r_back")
# inner (underside) of the arm — where the elbow bend is concave
model.rotation.y = 0.0
await get_tree().process_frame
cam.look_at_from_position(mid_r + Vector3(0, -0.85, 0.5), mid_r)
await _snap("06_arm_r_inner")
# torso closeups
cam.look_at_from_position(Vector3(0, 1.25, 0.9), Vector3(0, 1.25, 0))
await _snap("07_chest")
model.rotation.y = PI
await get_tree().process_frame
cam.look_at_from_position(Vector3(0, 1.2, 0.9), Vector3(0, 1.2, 0))
await _snap("08_back")
# legs front / outer / back
model.rotation.y = 0.0
await get_tree().process_frame
var leg_r := _limb_mid("thigh_r", "foot_r")
cam.look_at_from_position(leg_r + Vector3(0, 0.05, 1.1), leg_r)
await _snap("09_leg_r_front")
cam.look_at_from_position(leg_r + Vector3(-1.0, 0.05, 0.3), leg_r)
await _snap("10_leg_r_outer")
model.rotation.y = PI
await get_tree().process_frame
var leg_b := Vector3(-leg_r.x, leg_r.y, -leg_r.z)
cam.look_at_from_position(leg_b + Vector3(0, 0.05, 1.1), leg_b)
await _snap("11_legs_back")
# problem-area closeups: armpit, under-chin, cheek/ear
model.rotation.y = 0.0
await get_tree().process_frame
var pit_r := _bone_point("upperarm_r") + Vector3(-0.02, -0.05, 0)
cam.look_at_from_position(pit_r + Vector3(-0.12, -0.28, 0.42), pit_r)
await _snap("12_armpit_r")
var head_p := _bone_point("Head")
var chin := head_p + Vector3(0, -0.05, 0.06)
cam.look_at_from_position(chin + Vector3(0, -0.22, 0.40), chin)
await _snap("13_under_chin")
var ear_r := head_p + Vector3(-0.06, 0.04, 0)
cam.look_at_from_position(ear_r + Vector3(-0.38, 0.02, 0.16), ear_r)
await _snap("14_ear_cheek_r")
model.rotation.y = 0.0
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://main_lena.gd" id="1"]
[node name="Main" type="Node3D"]
script = ExtResource("1")
+320
View File
@@ -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])
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
# Builds 1024² gen canvases from the per-part UV masks: the island silhouette
# (padded bbox, aspect-true, centered) as parchment white on black. The exact
# same transform is recomputed in composite.gd to map results back into UV space.
# Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://prep.gd
extends SceneTree
const MASK_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/masks/"
const OUT_DIR := "C:/Users/CAN/tinqs-ltd/tattoo-test/gen-v4/"
const PARTS := ["arm_r", "arm_l", "leg_r", "leg_l", "chest", "back"]
const CANVAS := 1024
const MARGIN := 48 # silhouette keeps this distance from canvas edges
const PAD := 24 # px padding around the mask bbox in UV space
const PARCHMENT := Color(0.97, 0.94, 0.88)
func _init() -> void:
DirAccess.make_dir_recursive_absolute(OUT_DIR)
for part: String in PARTS:
var mask := Image.load_from_file(MASK_DIR + "mask_" + part + ".png")
var t := transform_for(mask)
var rect: Rect2i = t["rect"]
var s: float = t["scale"]
var off: Vector2 = t["offset"]
var canvas := Image.create(CANVAS, CANVAS, false, Image.FORMAT_RGBA8)
canvas.fill(Color(0, 0, 0))
for cy in CANVAS:
for cx in CANVAS:
var mx := int((cx - off.x) / s) + rect.position.x
var my := int((cy - off.y) / s) + rect.position.y
if mx < rect.position.x or my < rect.position.y \
or mx >= rect.end.x or my >= rect.end.y:
continue
if mask.get_pixel(mx, my).r > 0.5:
canvas.set_pixel(cx, cy, PARCHMENT)
var path := OUT_DIR + "canvas_" + part + ".png"
canvas.save_png(path)
print("CANVAS %s rect=%s scale=%.4f offset=%.1f,%.1f saved=%s" %
[part, rect, s, off.x, off.y, path])
quit()
# shared with composite.gd — keep in sync
static func transform_for(mask: Image) -> Dictionary:
var size := mask.get_width()
var minx := size
var maxx := -1
var miny := size
var maxy := -1
for y in size:
for x in size:
if mask.get_pixel(x, y).r > 0.5:
minx = mini(minx, x)
maxx = maxi(maxx, x)
miny = mini(miny, y)
maxy = maxi(maxy, y)
var rect := Rect2i(
maxi(minx - PAD, 0), maxi(miny - PAD, 0),
mini(maxx + PAD + 1, size) - maxi(minx - PAD, 0),
mini(maxy + PAD + 1, size) - maxi(miny - PAD, 0))
var s := minf(
float(CANVAS - 2 * MARGIN) / rect.size.x,
float(CANVAS - 2 * MARGIN) / rect.size.y)
var off := Vector2(
(CANVAS - rect.size.x * s) * 0.5,
(CANVAS - rect.size.y * s) * 0.5)
return {"rect": rect, "scale": s, "offset": off}
+16
View File
@@ -0,0 +1,16 @@
# Probe the back tattoo PNG pixel values
extends SceneTree
func _init() -> void:
for p in ["C:/Users/CAN/tinqs-ltd/docs/conceptart/generated-images/tattoo-marks/mark-06-hearth-warmth_back_v3.png",
"C:/Users/CAN/tinqs-ltd/docs/conceptart/generated-images/tattoo-marks/mark-01-chiefs-mark_arm_v3.png"]:
var img := Image.load_from_file(p)
print(p.get_file())
print(" size=%dx%d format(raw)=%d mipmaps=%s" % [img.get_width(), img.get_height(), img.get_format(), img.has_mipmaps()])
img.convert(Image.FORMAT_RGBA8)
var w := img.get_width()
var h := img.get_height()
for uv in [[0.5, 0.28], [0.57, 0.64], [0.5, 0.5], [0.05, 0.05]]:
var c := img.get_pixel(int(uv[0] * w), int(uv[1] * h))
print(" u=%.2f v=%.2f -> %s" % [uv[0], uv[1], c])
quit()
+64
View File
@@ -0,0 +1,64 @@
# Probe the QuatSkin Lena GLB: skeleton bones, meshes, UV/weight arrays, textures.
# Run: tinqs.console.exe --headless --path tattoo-test -s res://probe_lena.gd
extends SceneTree
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb"
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
push_error("GLB load failed")
quit(1)
return
var scene := doc.generate_scene(state)
var skel: Skeleton3D = 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":
var mesh = n.mesh
if mesh is ImporterMesh:
mesh = mesh.get_mesh()
print("MESH node=", n.name, " surfaces=", mesh.get_surface_count())
for s in mesh.get_surface_count():
var arrays: Array = mesh.surface_get_arrays(s)
var v: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var uv = arrays[Mesh.ARRAY_TEX_UV]
var bones = arrays[Mesh.ARRAY_BONES]
var nrm = arrays[Mesh.ARRAY_NORMAL]
var mat: Material = mesh.surface_get_material(s)
var matname: String = mat.resource_name if mat else "none"
var texinfo := "no-tex"
if mat is BaseMaterial3D and mat.albedo_texture:
var img: Image = mat.albedo_texture.get_image()
texinfo = "%dx%d" % [img.get_width(), img.get_height()]
print(" surf %d: verts=%d uv=%s normals=%s bones=%s mat=%s tex=%s" % [
s, v.size(),
"yes" if uv != null else "NO",
"yes" if nrm != null else "NO",
str(bones.size() / v.size()) + "x" if bones != null and v.size() > 0 else "NO",
matname, texinfo])
if n.get("skin") != null and n.skin != null:
print(" skin binds=", n.skin.get_bind_count())
for c in n.get_children():
stack.push_back(c)
if skel:
print("SKELETON bones=", skel.get_bone_count())
var names := []
for i in skel.get_bone_count():
names.append(skel.get_bone_name(i))
print(" names: ", ", ".join(names))
for bn in ["upperarm_r", "lowerarm_r", "hand_r", "spine_03", "clavicle_r"]:
var idx := skel.find_bone(bn)
if idx >= 0:
print(" %s idx=%d pos=%s" % [bn, idx, skel.get_bone_global_rest(idx).origin])
else:
print(" %s NOT FOUND" % bn)
else:
print("NO SKELETON")
quit()
+144
View File
@@ -0,0 +1,144 @@
# Measurements for the full-body Lena bake: bone heights, leg UV overlap,
# torso half-width, and fabric (bra/underwear) color stats.
extends SceneTree
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb"
const GRID := 256
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
quit(1)
return
var scene := doc.generate_scene(state)
var skel: Skeleton3D = null
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("lena"):
body = n
for c in n.get_children():
stack.push_back(c)
for bn in ["neck_01", "spine_03", "spine_02", "spine_01", "pelvis",
"thigh_r", "calf_r", "foot_r", "clavicle_r"]:
print("bone %s: %s" % [bn, skel.get_bone_global_rest(skel.find_bone(bn)).origin])
var mesh = body.mesh
if mesh is ImporterMesh:
mesh = mesh.get_mesh()
var arrays: Array = mesh.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
var normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
var vbones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var vweights = arrays[Mesh.ARRAY_WEIGHTS]
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)
# leg UV overlap (same method that showed 100% for arms)
var sets := {}
for side in [["r", ["thigh_r", "calf_r", "foot_r"]], ["l", ["thigh_l", "calf_l", "foot_l"]]]:
var ids := {}
for bn in side[1]:
ids[skel.find_bone(bn)] = true
var binds := {}
for i in bind_bone.size():
if ids.has(bind_bone[i]):
binds[i] = true
var mask := PackedByteArray()
mask.resize(GRID * GRID)
for v in nverts:
var w := 0.0
for k in influences:
if binds.has(vbones[v * influences + k]):
w += vweights[v * influences + k]
if w > 0.3:
var gx := clampi(int(uvs[v].x * GRID), 0, GRID - 1)
var gy := clampi(int(uvs[v].y * GRID), 0, GRID - 1)
mask[gy * GRID + gx] = 1
sets[side[0]] = mask
var r: PackedByteArray = sets["r"]
var l: PackedByteArray = sets["l"]
var cr := 0
var ov := 0
for i in GRID * GRID:
if r[i] == 1:
cr += 1
if l[i] == 1:
ov += 1
print("LEG UV overlap: %d/%d cells (%.0f%%)" % [ov, cr, 100.0 * ov / maxf(cr, 1)])
# arm weights (to exclude arms when measuring torso width)
var arm_ids := {}
for bn in ["upperarm_l", "lowerarm_l", "hand_l", "upperarm_r", "lowerarm_r", "hand_r"]:
arm_ids[skel.find_bone(bn)] = true
var arm_binds := {}
for i in bind_bone.size():
if arm_ids.has(bind_bone[i]):
arm_binds[i] = true
# torso extents + fabric stats: front-facing verts in the chest band
var mat: Material = mesh.surface_get_material(0)
var img: Image = (mat as BaseMaterial3D).albedo_texture.get_image()
img.clear_mipmaps()
img.convert(Image.FORMAT_RGBA8)
var tw := img.get_width()
var th := img.get_height()
var neck_y: float = skel.get_bone_global_rest(skel.find_bone("neck_01")).origin.y
var pelvis_y: float = skel.get_bone_global_rest(skel.find_bone("pelvis")).origin.y
var maxx_front := 0.0
var maxx_back := 0.0
var n_fab := 0
var n_skin := 0
var sat_fab := 0.0
var sat_skin := 0.0
var lum_fab := 0.0
var lum_skin := 0.0
for v in nverts:
var aw := 0.0
for k in influences:
if arm_binds.has(vbones[v * influences + k]):
aw += vweights[v * influences + k]
if aw > 0.3:
continue
var p := verts[v]
if p.y < pelvis_y or p.y > neck_y:
continue
if normals[v].z > 0.25:
maxx_front = maxf(maxx_front, absf(p.x))
elif normals[v].z < -0.25:
maxx_back = maxf(maxx_back, absf(p.x))
var px := clampi(int(uvs[v].x * tw), 0, tw - 1)
var py := clampi(int(uvs[v].y * th), 0, th - 1)
var c := img.get_pixel(px, py)
var mx := maxf(c.r, maxf(c.g, c.b))
var mn := minf(c.r, minf(c.g, c.b))
var sat := (mx - mn) / maxf(mx, 0.001)
var lum := (c.r + c.g + c.b) / 3.0
# crude split: bra/underwear are pale + desaturated
if sat < 0.15 and lum > 0.55:
n_fab += 1
sat_fab += sat
lum_fab += lum
else:
n_skin += 1
sat_skin += sat
lum_skin += lum
print("torso band y %.2f..%.2f half-width front=%.3f back=%.3f" % [pelvis_y, neck_y, maxx_front, maxx_back])
print("fabric-ish verts: %d (avg sat %.3f lum %.3f)" % [n_fab, sat_fab / maxf(n_fab, 1), lum_fab / maxf(n_fab, 1)])
print("skin-ish verts: %d (avg sat %.3f lum %.3f)" % [n_skin, sat_skin / maxf(n_skin, 1), lum_skin / maxf(n_skin, 1)])
quit()
+66
View File
@@ -0,0 +1,66 @@
# Color signature of Lena's painted bra vs bare skin (for a fabric-skip predicate).
extends SceneTree
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb"
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
quit(1)
return
var scene := doc.generate_scene(state)
var skel: Skeleton3D = null
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("lena"):
body = n
for c in n.get_children():
stack.push_back(c)
var mesh = body.mesh
if mesh is ImporterMesh:
mesh = mesh.get_mesh()
var arrays: Array = mesh.surface_get_arrays(0)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var uvs: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
var normals: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
var mat: Material = mesh.surface_get_material(0)
var img: Image = (mat as BaseMaterial3D).albedo_texture.get_image()
img.clear_mipmaps()
img.convert(Image.FORMAT_RGBA8)
var tw := img.get_width()
var th := img.get_height()
# bands: [label, ymin, ymax] — front-facing, near body midline (|x|<0.15)
for band in [["bra front", 1.16, 1.32], ["belly skin", 0.98, 1.07],
["upper chest", 1.34, 1.40], ["hip underwear", 0.82, 0.90],
["thigh skin", 0.55, 0.75]]:
var sr := 0.0
var sg := 0.0
var sb := 0.0
var cnt := 0
var rb_min := 1.0
var rb_max := -1.0
for v in verts.size():
var p := verts[v]
if p.y < band[1] or p.y > band[2] or absf(p.x) > 0.15 or normals[v].z < 0.25:
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 := img.get_pixel(px, py)
sr += c.r
sg += c.g
sb += c.b
var rb := (c.r - c.b) / maxf(c.r, 0.001)
rb_min = minf(rb_min, rb)
rb_max = maxf(rb_max, rb)
cnt += 1
if cnt > 0:
print("%s: n=%d avg RGB (%.3f, %.3f, %.3f) (r-b)/r range %.3f..%.3f" %
[band[0], cnt, sr / cnt, sg / cnt, sb / cnt, rb_min, rb_max])
quit()
+86
View File
@@ -0,0 +1,86 @@
# Measures left/right UV overlap for arms and legs (mirror-mapping check).
extends SceneTree
const GLB_PATH := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_demirrored.glb"
const GRID := 256
var skel: Skeleton3D
var uvs: PackedVector2Array
var vbones: PackedInt32Array
var vweights
var bind_bone: PackedInt32Array
var nverts: int
var influences: int
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(GLB_PATH, state) != OK:
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("lena"):
body = n
for c in n.get_children():
stack.push_back(c)
var mesh = body.mesh
if mesh is ImporterMesh:
mesh = mesh.get_mesh()
var arrays: Array = mesh.surface_get_arrays(0)
uvs = arrays[Mesh.ARRAY_TEX_UV]
vbones = arrays[Mesh.ARRAY_BONES]
vweights = arrays[Mesh.ARRAY_WEIGHTS]
nverts = (arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
influences = int(float(vbones.size()) / float(nverts))
var skin: Skin = body.skin
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)
_overlap("ARM", ["upperarm_r", "lowerarm_r", "hand_r"], ["upperarm_l", "lowerarm_l", "hand_l"])
_overlap("LEG", ["thigh_r", "calf_r", "foot_r"], ["thigh_l", "calf_l", "foot_l"])
quit()
func _mask(bones: Array) -> PackedByteArray:
var ids := {}
for bn in bones:
ids[skel.find_bone(bn)] = true
var binds := {}
for i in bind_bone.size():
if ids.has(bind_bone[i]):
binds[i] = true
var mask := PackedByteArray()
mask.resize(GRID * GRID)
for v in nverts:
var w := 0.0
for k in influences:
if binds.has(vbones[v * influences + k]):
w += vweights[v * influences + k]
if w > 0.3:
var gx := clampi(int(uvs[v].x * GRID), 0, GRID - 1)
var gy := clampi(int(uvs[v].y * GRID), 0, GRID - 1)
mask[gy * GRID + gx] = 1
return mask
func _overlap(tag: String, bones_r: Array, bones_l: Array) -> void:
var r := _mask(bones_r)
var l := _mask(bones_l)
var cr := 0
var ov := 0
for i in GRID * GRID:
if r[i] == 1:
cr += 1
if l[i] == 1:
ov += 1
print("%s UV overlap: %d/%d cells (%.0f%%)" % [tag, ov, cr, 100.0 * ov / maxf(cr, 1)])
+34
View File
@@ -0,0 +1,34 @@
# Probes the UAL1 GLB loaded at runtime: animation names + sample track paths.
extends SceneTree
const UAL1 := "C:/Users/CAN/tinqs-ltd/ariki-game/assets/quaternius/anim-lib-1/Universal Animation Library[Standard]/Unreal-Godot/UAL1_Standard.glb"
func _init() -> void:
var doc := GLTFDocument.new()
var state := GLTFState.new()
if doc.append_from_file(UAL1, state) != OK:
push_error("UAL load failed")
quit(1)
return
var scene := doc.generate_scene(state)
var player: AnimationPlayer = null
var stack: Array = [scene]
while not stack.is_empty():
var n: Node = stack.pop_back()
print("node: ", n.get_class(), " '", n.name, "'")
if n is AnimationPlayer and player == null:
player = n
for c in n.get_children():
stack.push_back(c)
if player == null:
print("NO AnimationPlayer")
quit(1)
return
var names := player.get_animation_list()
print("clip count: ", names.size())
print("clips: ", ", ".join(names.slice(0, 40)))
var a := player.get_animation(names[0])
print("first clip '", names[0], "' tracks: ", a.get_track_count())
for i in mini(4, a.get_track_count()):
print(" track path: ", a.track_get_path(i))
quit()
+16
View File
@@ -0,0 +1,16 @@
; Tattoo test harness — standalone mini-project (not part of ariki-game)
config_version=5
[application]
config/name="Ariki Tattoo Test"
run/main_scene="res://main.tscn"
[display]
window/size/viewport_width=1280
window/size/viewport_height=960
[rendering]
renderer/rendering_method="forward_plus"
+518
View File
@@ -0,0 +1,518 @@
# Re-UVs the QuatSkin Lena with a Quaternius-style semantic atlas and transfers
# her skin texture into it (v2).
# v1 used planar +-Z for head/torso: side/down-facing surfaces (cheeks by the
# ears, under the jaw, armpit side strips) were edge-on to the projection ->
# texel starvation -> smear streaks. v2 unwraps head and torso as CYLINDERS
# around the vertical axis (u = angle x local radius, v = height) with small
# planar caps (crown, under-jaw, shoulder tops), and smooths the per-triangle
# region assignment so the arm/torso border stops jittering in the armpit.
# arms/legs cylinder around the bone chain (unchanged from v1)
# torso/head cylinder around +Y, seam at center back, + planar caps
# hands/feet planar +-Y (unchanged)
# Run headless:
# tinqs.console.exe --headless --path tattoo-test -s res://reuv_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_clean_uv.glb"
const TEX_OUT := "C:/Users/CAN/tinqs-ltd/tattoo-test/lena_clean_uv_base.png"
const ATLAS := 4096
const PAD_PX := 24
const JB := 0.03
var skel: Skeleton3D
var verts: PackedVector3Array
var norms: PackedVector3Array
var uvs_old: PackedVector2Array
var vbones: PackedInt32Array
var vweights: PackedFloat32Array
var indices: PackedInt32Array
var nverts: int
var influences: int
var region_w := {}
const ISLAND_ORDER := [
"head_cyl", "head_top", "head_bot",
"torso_cyl", "torso_top", "torso_bot",
"arm_r", "arm_l", "leg_r", "leg_l",
"hand_r_back", "hand_r_palm", "hand_l_back", "hand_l_palm",
"foot_r_top", "foot_r_sole", "foot_l_top", "foot_l_sole",
]
func _init() -> void:
var t_start := Time.get_ticks_msec()
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)
var mesh: ArrayMesh = body.mesh
var arrays: Array = mesh.surface_get_arrays(0)
verts = arrays[Mesh.ARRAY_VERTEX]
norms = arrays[Mesh.ARRAY_NORMAL]
uvs_old = arrays[Mesh.ARRAY_TEX_UV]
vbones = arrays[Mesh.ARRAY_BONES]
vweights = arrays[Mesh.ARRAY_WEIGHTS]
indices = arrays[Mesh.ARRAY_INDEX]
nverts = verts.size()
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)
# ── region weights ──────────────────────────────────────────────────────
var fingers_l := []
var fingers_r := []
for f in ["index", "middle", "pinky", "ring", "thumb"]:
for seg in ["01", "02", "03", "04_leaf"]:
fingers_l.append("%s_%s_l" % [f, seg])
fingers_r.append("%s_%s_r" % [f, seg])
var regions := {
"head": ["Head", "neck_01"],
"torso": ["pelvis", "spine_01", "spine_02", "spine_03", "clavicle_l", "clavicle_r"],
"arm_l": ["upperarm_l", "lowerarm_l"],
"arm_r": ["upperarm_r", "lowerarm_r"],
"hand_l": ["hand_l"] + fingers_l,
"hand_r": ["hand_r"] + fingers_r,
"leg_l": ["thigh_l", "calf_l"],
"leg_r": ["thigh_r", "calf_r"],
"foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
"foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
}
for key in regions:
var binds := _bind_set(bind_bone, regions[key])
var w := PackedFloat32Array()
w.resize(nverts)
for v in nverts:
for k in influences:
if binds.has(vbones[v * influences + k]):
w[v] += vweights[v * influences + k]
region_w[key] = w
# ── per-triangle region (argmax) + adjacency smoothing ──────────────────
var ntris := indices.size() / 3
var tri_region := PackedStringArray()
tri_region.resize(ntris)
for t in ntris:
var best := "torso"
var best_s := -1.0
for key in region_w:
var w: PackedFloat32Array = region_w[key]
var s: float = w[indices[t * 3]] + w[indices[t * 3 + 1]] + w[indices[t * 3 + 2]]
if s > best_s:
best_s = s
best = key
tri_region[t] = best
# edge adjacency
var edge_tri := {}
var tri_nbrs := []
tri_nbrs.resize(ntris)
for t in ntris:
tri_nbrs[t] = []
for t in ntris:
for j in 3:
var a := indices[t * 3 + j]
var b := indices[t * 3 + ((j + 1) % 3)]
var key := "%d_%d" % [mini(a, b), maxi(a, b)]
if edge_tri.has(key):
var o: int = edge_tri[key]
tri_nbrs[t].append(o)
tri_nbrs[o].append(t)
else:
edge_tri[key] = t
# majority smoothing: kills 1-triangle islands at region borders (armpit!)
for pass_i in 3:
var changed := 0
var next := tri_region.duplicate()
for t in ntris:
var counts := {}
counts[tri_region[t]] = 1
for o in tri_nbrs[t]:
var r: String = tri_region[o]
counts[r] = counts.get(r, 0) + 1
var best := tri_region[t]
var best_c: int = counts[best]
for r in counts:
if counts[r] > best_c:
best_c = counts[r]
best = r
if best != tri_region[t]:
next[t] = best
changed += 1
tri_region = next
print("region smoothing pass %d: %d flips" % [pass_i, changed])
# ── axes for the vertical cylinders ──────────────────────────────────────
var head_pos := _bone_pos("Head")
var head_ax := Vector2(head_pos.x, head_pos.z)
var tz := 0.0
for bn in ["pelvis", "spine_01", "spine_02", "spine_03"]:
tz += _bone_pos(bn).z
var torso_ax := Vector2(0.0, tz / 4.0)
# ── limb chains (unchanged) ──────────────────────────────────────────────
var chains := {
"arm_l": ["upperarm_l", "lowerarm_l", "hand_l", "spine_03"],
"arm_r": ["upperarm_r", "lowerarm_r", "hand_r", "spine_03"],
"leg_l": ["thigh_l", "calf_l", "foot_l", "pelvis"],
"leg_r": ["thigh_r", "calf_r", "foot_r", "pelvis"],
}
var limb := {}
for key in chains:
var c: Array = chains[key]
var p0 := _bone_pos(c[0])
var p1 := _bone_pos(c[1])
var p2 := _bone_pos(c[2])
var center := _bone_pos(c[3])
var axis := (p2 - p0).normalized()
var outward := (p0 - center).normalized()
var uref := (outward - axis * outward.dot(axis)).normalized()
limb[key] = {"p0": p0, "p1": p1, "p2": p2,
"len1": p0.distance_to(p1), "len2": p1.distance_to(p2),
"uref": uref, "vref": axis.cross(uref)}
# ── per-triangle island id (with normal-based caps) + smoothing ─────────
var tri_island := PackedStringArray()
tri_island.resize(ntris)
for t in ntris:
var reg := tri_region[t]
var i0 := indices[t * 3]
var an := (norms[i0] + norms[indices[t * 3 + 1]] + norms[indices[t * 3 + 2]]).normalized()
var island := reg
if reg == "head":
island = "head_top" if an.y > 0.55 else ("head_bot" if an.y < -0.60 else "head_cyl")
elif reg == "torso":
island = "torso_top" if an.y > 0.65 else ("torso_bot" if an.y < -0.75 else "torso_cyl")
elif reg.begins_with("hand"):
island = reg + ("_back" if an.y >= 0.0 else "_palm")
elif reg.begins_with("foot"):
island = reg + ("_top" if an.y >= 0.0 else "_sole")
tri_island[t] = island
for pass_i in 2:
var next := tri_island.duplicate()
for t in ntris:
var counts := {}
counts[tri_island[t]] = 1
for o in tri_nbrs[t]:
if tri_region[o] == tri_region[t]:
counts[tri_island[o]] = counts.get(tri_island[o], 0) + 1
var best := tri_island[t]
var best_c: int = counts[best]
for r in counts:
if counts[r] > best_c:
best_c = counts[r]
best = r
next[t] = best
tri_island = next
# ── per-corner meter coords ──────────────────────────────────────────────
var corner_um := PackedFloat32Array()
corner_um.resize(indices.size())
var corner_vm := PackedFloat32Array()
corner_vm.resize(indices.size())
for t in ntris:
var island := tri_island[t]
for j in 3:
var ci := t * 3 + j
var p := verts[indices[ci]]
match island:
"head_top", "torso_top":
corner_um[ci] = p.x
corner_vm[ci] = p.z
"head_bot", "torso_bot":
corner_um[ci] = p.x
corner_vm[ci] = -p.z
"head_cyl":
corner_um[ci] = atan2(p.x - head_ax.x, p.z - head_ax.y)
corner_vm[ci] = -p.y
"torso_cyl":
corner_um[ci] = atan2(p.x - torso_ax.x, p.z - torso_ax.y)
corner_vm[ci] = -p.y
"hand_l_back", "hand_r_back":
corner_um[ci] = p.z
corner_vm[ci] = absf(p.x)
"hand_l_palm", "hand_r_palm":
corner_um[ci] = -p.z
corner_vm[ci] = absf(p.x)
"foot_l_top", "foot_r_top":
corner_um[ci] = p.x
corner_vm[ci] = p.z
"foot_l_sole", "foot_r_sole":
corner_um[ci] = -p.x
corner_vm[ci] = p.z
_:
var L: Dictionary = limb[island]
var res := _limb_coords(p, L)
corner_um[ci] = res.x
corner_vm[ci] = res.y
# cylinder wrap: one angular branch per triangle, then angle -> arc meters
var is_cyl := island.begins_with("arm") or island.begins_with("leg") \
or island == "head_cyl" or island == "torso_cyl"
if is_cyl:
var a0 := corner_um[t * 3]
var a1 := corner_um[t * 3 + 1]
var a2 := corner_um[t * 3 + 2]
if maxf(a0, maxf(a1, a2)) - minf(a0, minf(a1, a2)) > PI:
for j in 3:
if corner_um[t * 3 + j] < 0.0:
corner_um[t * 3 + j] += TAU
for j in 3:
var ci2 := t * 3 + j
var p2 := verts[indices[ci2]]
var r := 0.015
if island == "head_cyl":
r = maxf(Vector2(p2.x - head_ax.x, p2.z - head_ax.y).length(), 0.015)
elif island == "torso_cyl":
r = maxf(Vector2(p2.x - torso_ax.x, p2.z - torso_ax.y).length(), 0.015)
else:
r = _limb_radius(p2, limb[island])
corner_um[ci2] = corner_um[ci2] * r
# ── island bboxes, pack, weld, transfer, export ──────────────────────────
var isl := {}
for name in ISLAND_ORDER:
isl[name] = {"mn": Vector2(1e9, 1e9), "mx": Vector2(-1e9, -1e9)}
for t in ntris:
for j in 3:
var ci := t * 3 + j
var m: Dictionary = isl[tri_island[t]]
var q := Vector2(corner_um[ci], corner_vm[ci])
m["mn"] = (m["mn"] as Vector2).min(q)
m["mx"] = (m["mx"] as Vector2).max(q)
var lo := 100.0
var hi := 4000.0
for it in 24:
var mid := (lo + hi) * 0.5
if _try_pack(isl, mid, false):
lo = mid
else:
hi = mid
var scale := lo
_try_pack(isl, scale, true)
print("atlas scale: %.0f px/m" % scale)
for name in ISLAND_ORDER:
var m: Dictionary = isl[name]
print(" %s -> (%d,%d) %dx%d px" % [name, m["x"], m["y"], m["w"], m["h"]])
var new_verts := PackedVector3Array()
var new_norms := PackedVector3Array()
var new_uvs := PackedVector2Array()
var new_uvs_old := PackedVector2Array()
var new_bones := PackedInt32Array()
var new_weights := PackedFloat32Array()
var new_indices := PackedInt32Array()
new_indices.resize(indices.size())
var weld := {}
for t in ntris:
for j in 3:
var ci := t * 3 + j
var v := indices[ci]
var m: Dictionary = isl[tri_island[t]]
var px: float = m["x"] + (corner_um[ci] - (m["mn"] as Vector2).x) * scale
var py: float = m["y"] + (corner_vm[ci] - (m["mn"] as Vector2).y) * scale
var uv := Vector2(px / ATLAS, py / ATLAS)
var key := "%d_%d_%d" % [v, int(round(px * 4.0)), int(round(py * 4.0))]
if not weld.has(key):
var nv := new_verts.size()
new_verts.append(verts[v])
new_norms.append(norms[v])
new_uvs.append(uv)
new_uvs_old.append(uvs_old[v])
for k in influences:
new_bones.append(vbones[v * influences + k])
new_weights.append(vweights[v * influences + k])
weld[key] = nv
new_indices[ci] = weld[key]
print("verts %d -> %d" % [nverts, new_verts.size()])
var mat: BaseMaterial3D = mesh.surface_get_material(0)
var src: Image = mat.albedo_texture.get_image()
src.clear_mipmaps()
src.convert(Image.FORMAT_RGBA8)
var avg := Color(0, 0, 0)
var n_avg := 0
for sy in range(0, src.get_height(), 61):
for sx in range(0, src.get_width(), 61):
var c := src.get_pixel(sx, sy)
if (c.r + c.g + c.b) / 3.0 > 0.2:
avg += c
n_avg += 1
avg = Color(avg.r / n_avg, avg.g / n_avg, avg.b / n_avg)
var dst := Image.create(ATLAS, ATLAS, false, Image.FORMAT_RGBA8)
dst.fill(avg)
var painted := 0
for t in ntris:
painted += _transfer_tri(dst, src,
new_indices[t * 3], new_indices[t * 3 + 1], new_indices[t * 3 + 2],
new_uvs, new_uvs_old)
print("transferred px: ", painted)
dst.save_png(TEX_OUT)
var new_arrays := []
new_arrays.resize(Mesh.ARRAY_MAX)
new_arrays[Mesh.ARRAY_VERTEX] = new_verts
new_arrays[Mesh.ARRAY_NORMAL] = new_norms
new_arrays[Mesh.ARRAY_TEX_UV] = new_uvs
new_arrays[Mesh.ARRAY_BONES] = new_bones
new_arrays[Mesh.ARRAY_WEIGHTS] = new_weights
new_arrays[Mesh.ARRAY_INDEX] = new_indices
var new_mesh := ArrayMesh.new()
new_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, new_arrays)
mat.albedo_texture = ImageTexture.create_from_image(dst)
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("saved: %s + %s" % [GLB_OUT, TEX_OUT])
print("took %d ms" % (Time.get_ticks_msec() - t_start))
quit()
func _limb_coords(pos: Vector3, L: Dictionary) -> Vector2:
var p0: Vector3 = L["p0"]
var p1: Vector3 = L["p1"]
var p2: Vector3 = L["p2"]
var len1: float = L["len1"]
var len2: float = L["len2"]
var ax1 := (p1 - p0) / len1
var ax2 := (p2 - p1) / len2
var s1 := clampf((pos - p0).dot(ax1), 0.0, len1)
var cp1 := p0 + ax1 * s1
var s2 := clampf((pos - p1).dot(ax2), 0.0, len2)
var cp2 := p1 + ax2 * s2
var wb := smoothstep(-JB, JB, pos.distance_to(cp1) - pos.distance_to(cp2))
var t_m := lerpf(s1, len1 + s2, wb)
var dv: Vector3 = pos - cp1.lerp(cp2, wb)
var ang := atan2(dv.dot(L["vref"]), dv.dot(L["uref"]))
return Vector2(ang, t_m)
func _limb_radius(pos: Vector3, L: Dictionary) -> float:
var p0: Vector3 = L["p0"]
var p1: Vector3 = L["p1"]
var p2: Vector3 = L["p2"]
var len1: float = L["len1"]
var len2: float = L["len2"]
var ax1 := (p1 - p0) / len1
var ax2 := (p2 - p1) / len2
var s1 := clampf((pos - p0).dot(ax1), 0.0, len1)
var cp1 := p0 + ax1 * s1
var s2 := clampf((pos - p1).dot(ax2), 0.0, len2)
var cp2 := p1 + ax2 * s2
var wb := smoothstep(-JB, JB, pos.distance_to(cp1) - pos.distance_to(cp2))
return maxf(pos.distance_to(cp1.lerp(cp2, wb)), 0.015)
func _try_pack(isl: Dictionary, scale: float, commit: bool) -> bool:
var cx := PAD_PX
var cy := PAD_PX
var row_h := 0
for name in ISLAND_ORDER:
var m: Dictionary = isl[name]
var w := int(((m["mx"] as Vector2).x - (m["mn"] as Vector2).x) * scale) + 1
var h := int(((m["mx"] as Vector2).y - (m["mn"] as Vector2).y) * scale) + 1
if w > ATLAS - 2 * PAD_PX:
return false
if cx + w >= ATLAS - PAD_PX:
cx = PAD_PX
cy += row_h + PAD_PX
row_h = 0
if cy + h >= ATLAS - PAD_PX:
return false
if commit:
m["x"] = cx
m["y"] = cy
m["w"] = w
m["h"] = h
cx += w + PAD_PX
row_h = maxi(row_h, h)
return true
func _transfer_tri(dst: Image, src: Image, i0: int, i1: int, i2: int,
nuv: PackedVector2Array, ouv: PackedVector2Array) -> int:
var p0 := nuv[i0] * ATLAS
var p1 := nuv[i1] * ATLAS
var p2 := nuv[i2] * ATLAS
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)))) - 1, 0, ATLAS - 1)
var maxx := clampi(int(ceil(max(p0.x, max(p1.x, p2.x)))) + 1, 0, ATLAS - 1)
var miny := clampi(int(floor(min(p0.y, min(p1.y, p2.y)))) - 1, 0, ATLAS - 1)
var maxy := clampi(int(ceil(max(p0.y, max(p1.y, p2.y)))) + 1, 0, ATLAS - 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.06 or b1 < -0.06 or b2 < -0.06:
continue
b0 = clampf(b0, 0.0, 1.0)
b1 = clampf(b1, 0.0, 1.0)
b2 = clampf(b2, 0.0, 1.0)
var s := b0 + b1 + b2
var ou: Vector2 = (ouv[i0] * b0 + ouv[i1] * b1 + ouv[i2] * b2) / s
dst.set_pixel(px, py, _sample_bilinear(src, ou.x, ou.y))
painted += 1
return painted
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
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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More