Tattoo test bed: Quaternius v3-v6 bakes + Lena pipeline (bake, de-mirror, re-UV, viewers)
Session 2026-07-23/24 additions on top of the existing Quaternius bed: - bake_lena.gd: 6-piece bake on QuatSkin Lena (chiefs-mark/iron-spine arms, voyagers/storm-bearer legs, star-eye chest, hearth-warmth back); global-axis wrap frame + 3cm junction blend kill the elbow split line - demirror_lena.gd: tri-level split of mirror-shared limb UVs (arms 100%->0%, legs 99%->5% crotch-only residual) - reuv_lena.gd: Quaternius-style semantic re-UV (16 islands, cylinder limbs + cylinder head/torso with planar caps) + texture transfer; fixes armpit/chin/ cheek planar smears; single 4096 atlas, per-side tattoos native - main_lena / anim_lena viewers (18 UAL clips, U light toggle) + probes + shots Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,2 @@
|
||||
.godot/
|
||||
frames/
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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()
|
||||
@@ -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
@@ -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
Binary file not shown.
@@ -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.
@@ -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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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.
@@ -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.
@@ -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
@@ -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)
|
||||
@@ -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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)])
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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
Reference in New Issue
Block a user