From 552fa5f022ec8ff727ec99c2036daa16338aeccd Mon Sep 17 00:00:00 2001 From: "YITSUSE, Masami" Date: Wed, 2 Sep 2026 20:02:37 +0900 Subject: [PATCH 1/2] feat: flag skinned-mesh part-tracking targets A bone-skinned mesh part draws its vertices by skinning while its own transform stays near the setup pose, so following it snaps the target near the character root rather than onto the visible art. The node is followed faithfully (the behaviour is correct), but picking such a part is an easy mistake. - add SpriteStudioPlayer2D::is_part_skinned_mesh(part_name), backed by a binary-property cache in SsInternalPlayer (PartMeshBinding influence bone count > 0 -- the same test every sibling player uses) - drop skinned-mesh parts from the SpriteStudioPartAttachment2D part_name dropdown (still typable by hand for the rare deliberate case) - add a non-blocking configuration warning when part_name resolves to a skinned mesh, pointing at the NULL-part workaround - document the limitation in the Part Tracking guide (en/ja) Rigid (deform-only) meshes track faithfully and stay selectable. Claude-Session: https://claude.ai/code/session_01JsXPn69XiQoSonP8q68CiQ --- docs/en/workflow/usage_scripting.md | 3 +++ docs/ja/workflow/usage_scripting.md | 2 ++ ss_player/ss_internal_player.cpp | 16 ++++++++++++++++ ss_player/ss_internal_player.h | 12 ++++++++++++ ss_player/ss_part_attachment_2d.cpp | 24 ++++++++++++++++++++++-- ss_player/ss_player_node_2d.cpp | 9 +++++++++ ss_player/ss_player_node_2d.h | 5 +++++ 7 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/en/workflow/usage_scripting.md b/docs/en/workflow/usage_scripting.md index 6da239c..084fa56 100644 --- a/docs/en/workflow/usage_scripting.md +++ b/docs/en/workflow/usage_scripting.md @@ -150,6 +150,8 @@ Tracking is done with the dedicated **`SpriteStudioPartAttachment2D`** node. Pla | **Update Scale** (`update_scale`) | Reflect scale (OFF by default) | | **On Part Hidden** (`on_part_hidden`) | Behavior on frames where the part is hidden. `Follow Always` (keep following; default) / `Hide Target` (hide the target). From a script: `SpriteStudioPartAttachment2D.FOLLOW_ALWAYS` / `.HIDE_TARGET` | +> **Mesh (skinned) parts cannot be followed directly.** A bone-bound mesh part (a deforming part such as a hand or a face) has its vertices drawn by bone skinning, while the part's own transform stays at its setup node position — usually near the character root. So targeting such a part makes the follower snap near the root rather than onto the visible art. To follow the position of a mesh part, add a **NULL part (a bone-point / empty part)** at that spot in SpriteStudio and target that part's name instead; a bone (armature) or joint part works the same way. The `SpriteStudioPartAttachment2D` node flags this with a configuration warning when `part_name` is a skinned mesh, and `is_part_skinned_mesh(part_name)` reports it from a script. + ### Querying from a Script Instead of placing a node, you can also ask the player for a part's pose directly. @@ -175,6 +177,7 @@ func _on_frame_updated(frame_no: float): | `find_part_index(part_name)` | Part index, or `-1` if the part is not in the asset | | `get_part_transform(part_name)` | The part's transform for the current frame (a `Transform2D`, player-local, with `flip_h` / `flip_v` / `offset` already applied). Identity if the part is unknown | | `is_part_hidden(part_name)` | Whether the part is hidden on the current frame. `false` if the part is unknown | +| `is_part_skinned_mesh(part_name)` | Whether the part is a bone-skinned mesh — a poor follow target (see the note above). `false` if the part is unknown or is a rigid (deform-only) mesh | | signal `frame_updated(frame_no: float)` | Emitted right after the frame's part poses are finalized | > When you only need the pose at a single moment (a projectile spawn point, for example) rather than continuous following, calling `get_part_transform()` directly is simpler than placing a `SpriteStudioPartAttachment2D`. diff --git a/docs/ja/workflow/usage_scripting.md b/docs/ja/workflow/usage_scripting.md index c066b15..79a2f61 100644 --- a/docs/ja/workflow/usage_scripting.md +++ b/docs/ja/workflow/usage_scripting.md @@ -150,6 +150,8 @@ func change_costume(): | **Update Scale** (`update_scale`) | スケールを反映します(既定 OFF) | | **On Part Hidden** (`on_part_hidden`) | パーツが hide のフレームでの挙動。`Follow Always`(追従を継続。既定)/ `Hide Target`(対象を非表示)。スクリプトからは `SpriteStudioPartAttachment2D.FOLLOW_ALWAYS` / `.HIDE_TARGET` | +> **メッシュ(スキン)パーツには直接追従できません。** ボーンにバインドされたメッシュパーツ(手・顔などの変形パーツ)は、頂点がボーンのスキニングで描かれる一方、パーツ自身の変換はセットアップ時のノード位置(多くはキャラクターのルート付近)に留まります。そのため、こうしたパーツを追従先に指定すると、対象は見た目の絵ではなくルート付近に吸い寄せられます。メッシュパーツの位置に追従させたいときは、SpriteStudio 側でその位置に **NULL パーツ(ボーンポイント/空パーツ)** を1つ置き、そのパーツ名を追従先に指定してください。ボーン(armature)やジョイントパーツを指定しても同様に追従できます。`SpriteStudioPartAttachment2D` は `part_name` がスキンメッシュのとき構成警告(configuration warning)で知らせ、スクリプトからは `is_part_skinned_mesh(part_name)` で判定できます。 + ### スクリプトからの参照 ノードを置かずに、プレーヤへ直接パーツの姿勢を問い合わせることもできます。 diff --git a/ss_player/ss_internal_player.cpp b/ss_player/ss_internal_player.cpp index 1db839d..6c79ab1 100644 --- a/ss_player/ss_internal_player.cpp +++ b/ss_player/ss_internal_player.cpp @@ -341,16 +341,25 @@ void SsInternalPlayer::setSSABResource(const Ref& ssabRes) { void SsInternalPlayer::_rebuild_part_index_map() { _part_name_to_index.clear(); _part_names.clear(); + _part_skinned.clear(); if (_ssabRes.is_null()) return; const ss::format::SsAnimeBinary* binary = _ssabRes->get_ss_anime_binary(); if (!binary || !binary->parts()) return; auto parts = binary->parts(); const uint32_t n = parts->size(); _part_names.resize(n); + _part_skinned.resize(n); for (uint32_t i = 0; i < n; i++) { const auto* pd = parts->Get(i); String nm = (pd && pd->name()) ? String::utf8(pd->name()->c_str()) : String(); _part_names[i] = nm; + // A mesh part is "skinned" when its binding carries influence bones; a + // rigid (deform-only) mesh has none and follows its node transform + // faithfully. Same test every sibling player uses (PartMeshBinding + // influence_bone length > 0), so the flag agrees across engines. + const auto* mb = pd ? pd->mesh_binding() : nullptr; + const auto* bones = mb ? mb->influence_bone() : nullptr; + _part_skinned[i] = (bones && bones->size() > 0) ? 1 : 0; // First occurrence wins; duplicate part names are not expected within a // single binary, but guard against clobbering a lower index just in case. if (!nm.is_empty() && !_part_name_to_index.has(nm)) { @@ -385,6 +394,13 @@ bool SsInternalPlayer::try_get_part_hidden(int p_part_index, bool& r_hidden) con return true; } +bool SsInternalPlayer::try_get_part_skinned_mesh(int p_part_index, bool& r_skinned) const { + r_skinned = false; + if (p_part_index < 0 || (uint32_t)p_part_index >= _part_skinned.size()) return false; + r_skinned = _part_skinned[p_part_index] != 0; + return true; +} + int SsInternalPlayer::get_part_count() const { return (int)_part_names.size(); } diff --git a/ss_player/ss_internal_player.h b/ss_player/ss_internal_player.h index 6a2bb92..83d6f97 100644 --- a/ss_player/ss_internal_player.h +++ b/ss_player/ss_internal_player.h @@ -226,6 +226,14 @@ class SsInternalPlayer { // Per-part hide flag for the current frame. False (with r_hidden=false) // when the index is out of range. bool try_get_part_hidden(int p_part_index, bool& r_hidden) const; + // Whether the part is a bone-skinned mesh (its mesh_binding has one or more + // influence bones). Such a part's vertices are drawn by skinning while its + // own node transform stays near the setup pose, so it is a poor follow + // target — the query lets the attachment node flag it. A binary property + // (like the part name), so it is answered from the cache built by + // `_rebuild_part_index_map`, not the per-frame state. False (with + // r_skinned=false) when the index is out of range. + bool try_get_part_skinned_mesh(int p_part_index, bool& r_skinned) const; // Number of parts in the current binary, and the name of part i ("" if out // of range). Used to populate the editor part-name dropdown. int get_part_count() const; @@ -357,6 +365,10 @@ class SsInternalPlayer { // space the world matrices and `_parts_by_idx` use. HashMap _part_name_to_index; LocalVector _part_names; + // Per-part skinned-mesh flag, indexed by part_index. A binary property, so + // it is built once alongside _part_names in _rebuild_part_index_map (unlike + // the per-frame _part_hidden). 1 = bone-skinned mesh, 0 = anything else. + LocalVector _part_skinned; // Per-part hide flag for the current frame, indexed by part_index. Rebuilt // each _drawAnimation; 0 (visible) for parts absent from the frame. LocalVector _part_hidden; diff --git a/ss_player/ss_part_attachment_2d.cpp b/ss_player/ss_part_attachment_2d.cpp index 2f5c2fa..d5c1ba7 100644 --- a/ss_player/ss_part_attachment_2d.cpp +++ b/ss_player/ss_part_attachment_2d.cpp @@ -180,10 +180,23 @@ void SpriteStudioPartAttachment2D::_validate_property(PropertyInfo &p_property) const SpriteStudioPlayer2D* player = _resolve_player(); if (player) { PackedStringArray names = player->get_part_names(); + // Keep skinned (bone-bound) mesh parts OUT of the dropdown: their + // vertices are drawn by skinning while the part's own transform stays + // near the setup pose, so they track poorly and picking one from the + // list is an easy mistake. Rigid (deform-only) meshes follow their + // node faithfully, so they stay. A name can still be typed by hand + // for the rare deliberate case — the field is ENUM_SUGGESTION, not + // ENUM — and the configuration warning then flags it. + PackedStringArray suggestions; + for (int i = 0; i < names.size(); i++) { + if (!player->is_part_skinned_mesh(names[i])) { + suggestions.push_back(names[i]); + } + } // ENUM_SUGGESTION (not ENUM) so the field stays editable when the // player isn't resolvable at edit time — never lock out a name. p_property.hint = PROPERTY_HINT_ENUM_SUGGESTION; - p_property.hint_string = String(",").join(names); + p_property.hint_string = String(",").join(suggestions); } } } @@ -214,10 +227,17 @@ PackedStringArray SpriteStudioPartAttachment2D::_get_configuration_warnings() co PackedStringArray SpriteStudioPartAttachment2D::get_configuration_warnings() const { PackedStringArray warnings = Node2D::get_configuration_warnings(); #endif - if (_resolve_player() == nullptr) { + SpriteStudioPlayer2D* player = _resolve_player(); + if (player == nullptr) { warnings.push_back(tr("No SpriteStudioPlayer2D found. Set \"follow_path\" or place this node under a SpriteStudioPlayer2D.")); } else if (_part_name.is_empty()) { warnings.push_back(tr("Set \"part_name\" to the SpriteStudio part this node should follow.")); + } else if (player->is_part_skinned_mesh(_part_name)) { + // Non-blocking: following still runs, but a bone-skinned mesh draws its + // vertices by skinning while its own transform stays near the setup pose + // (usually the character root), so the target snaps near the root rather + // than onto the visible art. Point at the NULL-part workaround. + warnings.push_back(tr("\"part_name\" is a skinned (bone-bound) mesh part. Its transform stays near the setup pose, so the follower will not sit on the visible art. Add a NULL part at that spot in SpriteStudio and follow that instead.")); } return warnings; } diff --git a/ss_player/ss_player_node_2d.cpp b/ss_player/ss_player_node_2d.cpp index fbc3c8c..f600c93 100644 --- a/ss_player/ss_player_node_2d.cpp +++ b/ss_player/ss_player_node_2d.cpp @@ -193,6 +193,14 @@ bool SpriteStudioPlayer2D::is_part_hidden(const String& part_name) const { return hidden; } +bool SpriteStudioPlayer2D::is_part_skinned_mesh(const String& part_name) const { + int idx = _internal->resolve_part_index(part_name); + if (idx < 0) return false; + bool skinned = false; + _internal->try_get_part_skinned_mesh(idx, skinned); + return skinned; +} + PackedStringArray SpriteStudioPlayer2D::get_part_names() const { PackedStringArray names; int count = _internal->get_part_count(); @@ -554,6 +562,7 @@ void SpriteStudioPlayer2D::_bind_methods() { ClassDB::bind_method( D_METHOD( "find_part_index", "part_name" ), &SpriteStudioPlayer2D::find_part_index ); ClassDB::bind_method( D_METHOD( "get_part_transform", "part_name" ), &SpriteStudioPlayer2D::get_part_transform ); ClassDB::bind_method( D_METHOD( "is_part_hidden", "part_name" ), &SpriteStudioPlayer2D::is_part_hidden ); + ClassDB::bind_method( D_METHOD( "is_part_skinned_mesh", "part_name" ), &SpriteStudioPlayer2D::is_part_skinned_mesh ); ClassDB::bind_method( D_METHOD( "get_part_names" ), &SpriteStudioPlayer2D::get_part_names ); // ---- Override Layer (Phase 2): per-part runtime overrides ------------- diff --git a/ss_player/ss_player_node_2d.h b/ss_player/ss_player_node_2d.h index aed0cd4..b1dc2a3 100644 --- a/ss_player/ss_player_node_2d.h +++ b/ss_player/ss_player_node_2d.h @@ -191,6 +191,11 @@ class SpriteStudioPlayer2D : public Node2D { Transform2D get_part_transform(const String& part_name) const; // True if the named part is hidden on the current frame. False when unknown. bool is_part_hidden(const String& part_name) const; + // True if the named part is a bone-skinned mesh. Such a part's vertices are + // drawn by skinning while its own transform stays near the setup pose, so it + // is a poor follow target — the attachment node uses this to flag it. False + // when the part is unknown or is not a skinned mesh. + bool is_part_skinned_mesh(const String& part_name) const; // All part names in the current binary (for the attachment's dropdown). PackedStringArray get_part_names() const; From 377abdb4a5ed021b14eaf3d125da6b62f5126f5e Mon Sep 17 00:00:00 2001 From: "YITSUSE, Masami" Date: Wed, 2 Sep 2026 20:06:11 +0900 Subject: [PATCH 2/2] docs: spell out the skinned-mesh dropdown/warning behaviour The part-tracking guide already noted that skinned meshes track poorly; say what the editor does about it: the part_name dropdown omits skinned meshes (reachable only by typing a name by hand), and a name that does resolve to one raises the node's configuration warning. Rigid (deform-only) meshes stay in the dropdown and raise no warning. Claude-Session: https://claude.ai/code/session_01JsXPn69XiQoSonP8q68CiQ --- docs/en/workflow/usage_scripting.md | 6 ++++-- docs/ja/workflow/usage_scripting.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/en/workflow/usage_scripting.md b/docs/en/workflow/usage_scripting.md index 084fa56..1c6b145 100644 --- a/docs/en/workflow/usage_scripting.md +++ b/docs/en/workflow/usage_scripting.md @@ -142,7 +142,7 @@ Tracking is done with the dedicated **`SpriteStudioPartAttachment2D`** node. Pla | Property | Description | |---|---| -| **Part Name** (`part_name`) | The name of the part to follow (the PartData name in the `.ssab`). The inspector offers a dropdown populated from the asset's part names (still typable, for when the player cannot be resolved) | +| **Part Name** (`part_name`) | The name of the part to follow (the PartData name in the `.ssab`). The inspector offers a dropdown populated from the asset's part names (still typable, for when the player cannot be resolved). Skinned-mesh parts are left out of the dropdown — see the note below | | **Follow Path** (`follow_path`) | The `SpriteStudioPlayer2D` to read from. Empty (default) uses the **nearest ancestor** player | | **Remote Path** (`remote_path`) | The `Node2D` to drive. Empty (default) drives this node itself, and its children follow through scene-tree inheritance. Set it to push the pose to an external node instead (for assets that live outside the player's subtree) | | **Use Global Coordinates** (`use_global_coordinates`) | ON (default) writes the pose in global coordinates, OFF in the target's local coordinates | @@ -150,7 +150,9 @@ Tracking is done with the dedicated **`SpriteStudioPartAttachment2D`** node. Pla | **Update Scale** (`update_scale`) | Reflect scale (OFF by default) | | **On Part Hidden** (`on_part_hidden`) | Behavior on frames where the part is hidden. `Follow Always` (keep following; default) / `Hide Target` (hide the target). From a script: `SpriteStudioPartAttachment2D.FOLLOW_ALWAYS` / `.HIDE_TARGET` | -> **Mesh (skinned) parts cannot be followed directly.** A bone-bound mesh part (a deforming part such as a hand or a face) has its vertices drawn by bone skinning, while the part's own transform stays at its setup node position — usually near the character root. So targeting such a part makes the follower snap near the root rather than onto the visible art. To follow the position of a mesh part, add a **NULL part (a bone-point / empty part)** at that spot in SpriteStudio and target that part's name instead; a bone (armature) or joint part works the same way. The `SpriteStudioPartAttachment2D` node flags this with a configuration warning when `part_name` is a skinned mesh, and `is_part_skinned_mesh(part_name)` reports it from a script. +> **Mesh (skinned) parts cannot be followed directly.** A bone-bound mesh part (a deforming part such as a hand or a face) has its vertices drawn by bone skinning, while the part's own transform stays at its setup node position — usually near the character root. So targeting such a part makes the follower snap near the root rather than onto the visible art. To follow the position of a mesh part, add a **NULL part (a bone-point / empty part)** at that spot in SpriteStudio and target that part's name instead; a bone (armature) or joint part works the same way. +> +> To steer away from the mistake, **the `part_name` dropdown omits skinned-mesh parts** — you can only reach one by typing its name in the field by hand (kept possible for the rare deliberate case). Whenever `part_name` does resolve to a skinned mesh, the node shows a **configuration warning** (the ⚠ next to it in the Scene dock) so the choice is never silent; `is_part_skinned_mesh(part_name)` reports the same thing from a script. Rigid (deform-only) meshes track their node faithfully, so they stay in the dropdown and raise no warning. ### Querying from a Script diff --git a/docs/ja/workflow/usage_scripting.md b/docs/ja/workflow/usage_scripting.md index 79a2f61..595f8b9 100644 --- a/docs/ja/workflow/usage_scripting.md +++ b/docs/ja/workflow/usage_scripting.md @@ -142,7 +142,7 @@ func change_costume(): | プロパティ | 説明 | |---|---| -| **Part Name** (`part_name`) | 追従対象のパーツ名(`.ssab` の PartData 名)。インスペクタではアセットのパーツ名からドロップダウンで選べます(プレーヤを解決できない場面のために手入力も可) | +| **Part Name** (`part_name`) | 追従対象のパーツ名(`.ssab` の PartData 名)。インスペクタではアセットのパーツ名からドロップダウンで選べます(プレーヤを解決できない場面のために手入力も可)。スキンメッシュのパーツはドロップダウンには出ません(後述の注記を参照) | | **Follow Path** (`follow_path`) | 追従元の `SpriteStudioPlayer2D`。空(既定)なら**最も近い祖先**のプレーヤを使います | | **Remote Path** (`remote_path`) | 駆動する対象の `Node2D`。空(既定)なら自分自身を動かし、子はシーンツリーの継承で追従します。指定するとその外部ノードへ姿勢を書き込みます(プレーヤのサブツリーの外に置いた資産を追従させたい場合) | | **Use Global Coordinates** (`use_global_coordinates`) | ON(既定)でグローバル座標として、OFF で対象のローカル座標として書き込みます | @@ -150,7 +150,9 @@ func change_costume(): | **Update Scale** (`update_scale`) | スケールを反映します(既定 OFF) | | **On Part Hidden** (`on_part_hidden`) | パーツが hide のフレームでの挙動。`Follow Always`(追従を継続。既定)/ `Hide Target`(対象を非表示)。スクリプトからは `SpriteStudioPartAttachment2D.FOLLOW_ALWAYS` / `.HIDE_TARGET` | -> **メッシュ(スキン)パーツには直接追従できません。** ボーンにバインドされたメッシュパーツ(手・顔などの変形パーツ)は、頂点がボーンのスキニングで描かれる一方、パーツ自身の変換はセットアップ時のノード位置(多くはキャラクターのルート付近)に留まります。そのため、こうしたパーツを追従先に指定すると、対象は見た目の絵ではなくルート付近に吸い寄せられます。メッシュパーツの位置に追従させたいときは、SpriteStudio 側でその位置に **NULL パーツ(ボーンポイント/空パーツ)** を1つ置き、そのパーツ名を追従先に指定してください。ボーン(armature)やジョイントパーツを指定しても同様に追従できます。`SpriteStudioPartAttachment2D` は `part_name` がスキンメッシュのとき構成警告(configuration warning)で知らせ、スクリプトからは `is_part_skinned_mesh(part_name)` で判定できます。 +> **メッシュ(スキン)パーツには直接追従できません。** ボーンにバインドされたメッシュパーツ(手・顔などの変形パーツ)は、頂点がボーンのスキニングで描かれる一方、パーツ自身の変換はセットアップ時のノード位置(多くはキャラクターのルート付近)に留まります。そのため、こうしたパーツを追従先に指定すると、対象は見た目の絵ではなくルート付近に吸い寄せられます。メッシュパーツの位置に追従させたいときは、SpriteStudio 側でその位置に **NULL パーツ(ボーンポイント/空パーツ)** を1つ置き、そのパーツ名を追従先に指定してください。ボーン(armature)やジョイントパーツを指定しても同様に追従できます。 +> +> うっかり選択を防ぐため、**`part_name` のドロップダウンにはスキンメッシュのパーツを出していません** — 指定できるのはフィールドに名前を手入力した場合だけです(あえて選びたい稀なケースのために手入力は残しています)。手入力などで `part_name` がスキンメッシュに解決されたときは、ノードに**構成警告(configuration warning)**(シーンドックのノード横に付く⚠)が表示され、選択が黙って通ることはありません。スクリプトからは `is_part_skinned_mesh(part_name)` で同じ判定ができます。なお、リジッド(deform のみ・ボーン無し)メッシュはノードに忠実に追従するので、ドロップダウンにも残り、警告も出ません。 ### スクリプトからの参照