Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
639a5aa
feat(comms): Pulse protos + quantization codegen (M0)
kuruk-mm Jul 14, 2026
764d325
feat(comms): Pulse ENet transport driver + handshake + activation (M1)
kuruk-mm Jul 14, 2026
771b81c
feat(comms): Pulse outbound wiring + dual-channel movement flag (M3)
kuruk-mm Jul 14, 2026
088253d
feat(comms): Pulse coexistence — transport-preference gate + emote st…
kuruk-mm Jul 14, 2026
f61afd9
feat(comms): Pulse deeplink activation + env service group
kuruk-mm Jul 14, 2026
d5ce9f8
feat(debug): multiplayer debug panel replaces LiveKit debug panel
kuruk-mm Jul 14, 2026
6aea74a
feat(comms): dual-channel deeplink param + explicit peer room labels
kuruk-mm Jul 14, 2026
eb34ec9
fix(env): pulse follows the comms env group
kuruk-mm Jul 14, 2026
65899e8
chore(deps): DCL-org rusty_enet mirror + protocol PR-429 branch pin
kuruk-mm Jul 15, 2026
97ca345
feat(comms): runtime pulse-only mode (--no-livekit / livekit=false de…
kuruk-mm Jul 15, 2026
ee352a8
fix(comms): pulse-unity interop (rotation clamp, emote timing, join a…
kuruk-mm Jul 15, 2026
da5b045
feat(debug): collapsible multiplayer panel, token elision, peer names
kuruk-mm Jul 21, 2026
6ec28a8
fix(comms): tolerate peer lambdasEndpoint without trailing slash
kuruk-mm Jul 22, 2026
48c4ccf
fix(comms): fast realm fallback when peer lambdasEndpoint is broken
kuruk-mm Jul 22, 2026
d4effc0
fix(avatar): latch network emotes that arrive before the avatar is ready
kuruk-mm Jul 22, 2026
37ddaf4
Merge origin/main into feat/pulse-transport
kuruk-mm Jul 22, 2026
72020ff
feat(comms): Pulse transport on by default
kuruk-mm Jul 22, 2026
969e691
Merge branch 'main' into feat/pulse-transport
kuruk-mm Jul 23, 2026
56c0734
feat: remote feature flags with archipelago kill-switch
kuruk-mm Jul 27, 2026
7f21d6f
fix(comms): recover all transports after mobile app resume
kuruk-mm Jul 29, 2026
871a4cb
Merge remote-tracking branch 'origin/main' into feat/pulse-transport
kuruk-mm Jul 30, 2026
2f06348
refactor(ui): extract chat /commands from explorer.gd into ChatCommands
kuruk-mm Jul 30, 2026
0525ec9
Merge branch 'main' into feat/pulse-transport
kuruk-mm Jul 30, 2026
655004f
refactor(avatar): extract bone merging + toon materials into AvatarMe…
kuruk-mm Jul 30, 2026
528a952
fix(comms): recycle archipelago session after 3 failed island connects
kuruk-mm Jul 30, 2026
6dc0891
fix(comms): pulse hardening round — kill switch, resurrection path, d…
kuruk-mm Jul 31, 2026
9d03422
fix(deeplink): multiplayer_debug survives lobby/menu flow; no empty j…
kuruk-mm Jul 31, 2026
c288ad2
fix(scene-runner): survive Explorer teardown — scene-UI & player-node…
kuruk-mm Jul 31, 2026
715a333
fix(ui): never open a data-less jump-in sheet; collapsed debug panel …
kuruk-mm Jul 31, 2026
9b8d1ca
fix(ui): multiplayer debug panel keeps its width when snapping height
kuruk-mm Jul 31, 2026
13960d7
fix(ui): double the multiplayer debug collapse button size
kuruk-mm Jul 31, 2026
c67c3ef
chore(protocol): repoint @dcl/protocol pin at merged main (commit 0ff…
kuruk-mm Aug 3, 2026
88b8c99
feat(comms): fail-closed Pulse activation on the server feature flag
kuruk-mm Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 34 additions & 213 deletions godot/src/decentraland_components/avatar/avatar.gd
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,6 @@ const WEARABLE_NAME_PREFIX = "__"
# "off screen" freezes a drawn one.
const SCREEN_NOTIFIER_AABB: AABB = AABB(Vector3(-1.0, -0.3, -1.0), Vector3(2.0, 2.8, 2.0))

const TOON_SHADER = preload("res://assets/avatar/dcl_toon.gdshader")
const TOON_SHADER_ALPHA_CLIP = preload("res://assets/avatar/dcl_toon_alpha_clip.gdshader")
const TOON_SHADER_ALPHA_BLEND = preload("res://assets/avatar/dcl_toon_alpha_blend.gdshader")
const TOON_SHADER_DOUBLE = preload("res://assets/avatar/dcl_toon_double.gdshader")
const TOON_SHADER_ALPHA_CLIP_DOUBLE = preload(
"res://assets/avatar/dcl_toon_alpha_clip_double.gdshader"
)
const TOON_SHADER_ALPHA_BLEND_DOUBLE = preload(
"res://assets/avatar/dcl_toon_alpha_blend_double.gdshader"
)

# Maps AvatarAnchorPointType (SDK proto, see avatar_attach.proto) to skeleton
# bone names. Ids 0 (POSITION) and 1 (NAME_TAG) are non-skeletal and resolved
# directly in get_anchor_point_global_transform.
Expand Down Expand Up @@ -156,17 +145,18 @@ var _prop_last_glide_state: int = 0
var _prop_sync_pending: bool = true
var _glide_forward_blend: float = 0.0

# Network emote that arrived while the avatar was still loading — Pulse replays
# the peer's last emote announcement at join, and LiveKit emotes can race the
# profile fetch. Playing it now would resolve against the default body shape and
# be wiped by the rebuild anyway, so it's latched and replayed on avatar_ready.
var _pending_network_emote: String = ""

# Registry for scene emote content URLs: scene_id -> {base_url, emotes: {glb_hash -> audio_hash}}
var _scene_emote_registry: Dictionary = {}

# Indices of bones added to body_shape_skeleton_3d by _merge_extra_wearable_bones_into_base
# and currently in use by the active wearables.
var _active_extra_bone_indices: Array[int] = []
# Slots recycled from previous merges (renamed + disabled) waiting to be reused by the next
# merge. Skeleton3D has no remove_bone() in Godot 4.6, so reusing slots is the only way to
# keep bone_count bounded across outfit / body-shape changes.
var _free_bone_pool: Array[int] = []
var _stale_bone_counter: int = 0
# Merges/recycles extra wearable bones and applies the shared toon materials;
# bound to body_shape_skeleton_3d in _ready.
var _mesh_assembler: AvatarMeshAssembler = null

var _lod_state: int = LODState.FULL
# 2D screen-space nameplate (non-XR) vs legacy viewport quad (XR). Runtime lives in
Expand Down Expand Up @@ -245,18 +235,9 @@ var _anim_throttle_active: bool = false
@onready var glider_prop: Node3D = %GliderProp
@onready var audio_player_double_jump: AudioStreamPlayer3D = %AudioPlayer_DoubleJump

# Cache of toon ShaderMaterials keyed by source BaseMaterial3D's instance_id.
# Lets avatars wearing the same wearable share a single ShaderMaterial across
# the whole scene. Skin/hair surfaces clone-on-write in apply_color_and_facial
# so per-avatar tints don't leak.
static var _toon_material_cache: Dictionary = {}

# Issue #1945: matches a Blender-style `_<digits>$` duplicate-import suffix on a
# bone name (e.g. `Avatar_Hips_2`). Compiled once and shared across instances.
static var _bone_suffix_regex: RegEx = RegEx.create_from_string("^(.*)_\\d+$")


func _ready():
_mesh_assembler = AvatarMeshAssembler.new(body_shape_skeleton_3d)
var billboard_mode = (
BaseMaterial3D.BillboardMode.BILLBOARD_FIXED_Y
if Global.is_xr()
Expand Down Expand Up @@ -798,8 +779,8 @@ func async_try_to_set_body_shape(body_shape_hash):
child.queue_free()

# Recycle any extra bones merged in the previous assembly so the upcoming
# _merge_extra_wearable_bones_into_base pass starts from a clean slate.
_recycle_extra_wearable_bones()
# merge_extra_bones pass starts from a clean slate.
_mesh_assembler.recycle_extra_bones()

# Reparent children directly (no need to duplicate since wearable_loader
# returns a fresh instantiated scene that we'll discard anyway)
Expand All @@ -814,184 +795,6 @@ func async_try_to_set_body_shape(body_shape_hash):
_reresolve_active_anchors()


# Renames bones previously merged via _merge_extra_wearable_bones_into_base to a
# unique stale sentinel, disables them, detaches them from the active hierarchy
# (parent=-1, rest=identity), and pushes their indices into the free pool so the
# next merge can reuse them instead of growing the skeleton. Detaching keeps the
# per-frame skeleton transform walk cheap by leaving stale slots as flat roots.
func _recycle_extra_wearable_bones() -> void:
if _active_extra_bone_indices.is_empty():
return
for bone_idx in _active_extra_bone_indices:
if bone_idx < 0 or bone_idx >= body_shape_skeleton_3d.get_bone_count():
continue
var stale_name = "__stale_bone_%d" % _stale_bone_counter
_stale_bone_counter += 1
body_shape_skeleton_3d.set_bone_name(bone_idx, stale_name)
body_shape_skeleton_3d.set_bone_enabled(bone_idx, false)
body_shape_skeleton_3d.set_bone_parent(bone_idx, -1)
body_shape_skeleton_3d.set_bone_rest(bone_idx, Transform3D.IDENTITY)
body_shape_skeleton_3d.reset_bone_pose(bone_idx)
_free_bone_pool.push_back(bone_idx)
_active_extra_bone_indices.clear()


# Resolves a wearable bone name to its counterpart in body_shape_skeleton_3d,
# stripping a Blender-style duplicate-import suffix (`_2`, `_001`, ...) only
# when the un-suffixed name already exists. Returns the original name otherwise
# so genuine extra bones (ADR-316 spring bones) still get merged as new bones.
# Fixes #1945: wearables exported from Blender after re-importing the DCL armature
# carry `Avatar_Hips_2` etc. — without this collapse they merge as a parallel,
# un-animated leg/spine chain that stays in rest pose during emotes/jump/glide.
func _resolve_to_base_bone_name(bone_name: String) -> String:
if body_shape_skeleton_3d.find_bone(bone_name) != -1:
return bone_name
var m := _bone_suffix_regex.search(bone_name)
if m == null:
return bone_name
var stripped := m.get_string(1)
if body_shape_skeleton_3d.find_bone(stripped) != -1:
return stripped
return bone_name


# Copies bones that exist in the wearable's Skeleton3D but not in body_shape_skeleton_3d
# (typically ADR-316 spring bones for hair, earrings, capes, etc.). Parents are added
# before children so parent-by-name resolution always succeeds. Without this, mesh
# skins referencing indices beyond body_shape_skeleton_3d.get_bone_count() log
# `Skin bind #N contains bone index bind: N, which is greater than the skeleton bone count`.
func _merge_extra_wearable_bones_into_base(wearable_skel: Skeleton3D) -> void:
var wearable_bone_count = wearable_skel.get_bone_count()
if wearable_bone_count == 0:
return

# Collect missing bones along with their depth in the wearable hierarchy so we
# can add parents before children.
var missing: Array = [] # Array of [depth, wearable_idx, name]
for i in wearable_bone_count:
var bone_name = wearable_skel.get_bone_name(i)
# Skip if the bone already exists in the base, including under its
# de-suffixed name. The wearable's `Avatar_Hips_2` collapses onto the
# animated `Avatar_Hips` instead of being merged as a parallel root.
if _resolve_to_base_bone_name(bone_name) != bone_name:
continue
if body_shape_skeleton_3d.find_bone(bone_name) != -1:
continue
var depth = 0
var cursor = wearable_skel.get_bone_parent(i)
while cursor != -1:
depth += 1
cursor = wearable_skel.get_bone_parent(cursor)
missing.push_back([depth, i, bone_name])

if missing.is_empty():
return

missing.sort_custom(func(a, b): return a[0] < b[0])

for entry in missing:
var wearable_idx: int = entry[1]
var bone_name: String = entry[2]
var new_idx: int
if not _free_bone_pool.is_empty():
new_idx = _free_bone_pool.pop_back()
body_shape_skeleton_3d.set_bone_name(new_idx, bone_name)
body_shape_skeleton_3d.set_bone_enabled(new_idx, true)
else:
new_idx = body_shape_skeleton_3d.add_bone(bone_name)
body_shape_skeleton_3d.set_bone_rest(new_idx, wearable_skel.get_bone_rest(wearable_idx))
body_shape_skeleton_3d.reset_bone_pose(new_idx)
_active_extra_bone_indices.push_back(new_idx)
# Always reset parent: a recycled slot may have been linked to a stale
# parent from its previous use. Resolve through the same de-suffix path
# so a spring bone whose parent is `Avatar_Spine_2` reparents onto the
# base `Avatar_Spine` instead of leaving as root.
var parent_wearable_idx = wearable_skel.get_bone_parent(wearable_idx)
var parent_base_idx = -1
if parent_wearable_idx != -1:
var parent_name = wearable_skel.get_bone_name(parent_wearable_idx)
parent_base_idx = body_shape_skeleton_3d.find_bone(
_resolve_to_base_bone_name(parent_name)
)
if parent_base_idx == -1:
push_warning(
(
"[AVATAR] Extra bone '%s' parent '%s' not found in base skeleton; leaving as root"
% [bone_name, parent_name]
)
)
body_shape_skeleton_3d.set_bone_parent(new_idx, parent_base_idx)


# Rewrites a MeshInstance3D's Skin so every bind references its target bone by name.
# Godot resolves named binds against the attached skeleton at runtime, so once the
# mesh is reparented to body_shape_skeleton_3d (which may have been extended with
# extra wearable bones) every joint resolves correctly, including ADR-316 spring bones.
# Issue #1945: when the wearable was exported with duplicate-suffixed bones
# (`Avatar_Hips_2`, `Avatar_LeftLeg_2`, ...), the de-suffix lookup retargets the
# binds onto the animated base bones — without it the mesh tracks merged but
# inert `_2` clones and stays in rest pose during emotes/jump/glide.
func _rebind_skin_by_name(mesh: MeshInstance3D, wearable_skel: Skeleton3D) -> void:
if mesh.skin == null:
return
var skin: Skin = mesh.skin.duplicate()
var wearable_bone_count = wearable_skel.get_bone_count()
for i in skin.get_bind_count():
var bone_idx = skin.get_bind_bone(i)
if bone_idx >= 0 and bone_idx < wearable_bone_count:
var bone_name = wearable_skel.get_bone_name(bone_idx)
skin.set_bind_name(i, _resolve_to_base_bone_name(bone_name))
mesh.skin = skin


func _convert_to_toon(base_mat: BaseMaterial3D) -> ShaderMaterial:
var is_alpha_scissor = base_mat.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
var is_alpha_blend = (
base_mat.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA
or base_mat.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_HASH
or base_mat.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS
)
var double_sided = base_mat.cull_mode == BaseMaterial3D.CULL_DISABLED
var toon_mat = ShaderMaterial.new()
if is_alpha_scissor and double_sided:
toon_mat.shader = TOON_SHADER_ALPHA_CLIP_DOUBLE
elif is_alpha_scissor:
toon_mat.shader = TOON_SHADER_ALPHA_CLIP
elif is_alpha_blend and double_sided:
toon_mat.shader = TOON_SHADER_ALPHA_BLEND_DOUBLE
elif is_alpha_blend:
toon_mat.shader = TOON_SHADER_ALPHA_BLEND
elif double_sided:
toon_mat.shader = TOON_SHADER_DOUBLE
else:
toon_mat.shader = TOON_SHADER
toon_mat.set_shader_parameter("albedo_color", base_mat.albedo_color)
if base_mat.albedo_texture:
toon_mat.set_shader_parameter("albedo_texture", base_mat.albedo_texture)
if base_mat.emission_enabled:
toon_mat.set_shader_parameter("emission_color", base_mat.emission)
if base_mat.emission_texture:
toon_mat.set_shader_parameter("emission_texture", base_mat.emission_texture)
if is_alpha_scissor:
toon_mat.set_shader_parameter("alpha_scissor_threshold", base_mat.alpha_scissor_threshold)
return toon_mat


func apply_toon_material(node_to_apply: Node):
if not (node_to_apply is MeshInstance3D) or node_to_apply.mesh == null:
return
for surface_idx in range(node_to_apply.mesh.get_surface_count()):
var mat = node_to_apply.mesh.surface_get_material(surface_idx)
if mat == null or not (mat is BaseMaterial3D):
continue
var key: int = mat.get_instance_id()
var cached = _toon_material_cache.get(key)
if cached == null or not is_instance_valid(cached):
cached = _convert_to_toon(mat)
_toon_material_cache[key] = cached
node_to_apply.set_surface_override_material(surface_idx, cached)


func async_load_wearables():
# Safety check: avatar may have been freed during async operations
if not is_instance_valid(wearable_loader) or not is_inside_tree():
Expand Down Expand Up @@ -1075,11 +878,11 @@ func async_load_wearables():
# Spring bones (ADR-316) and other extra bones not in the base armature
# must be copied into body_shape_skeleton_3d before meshes are reparented,
# otherwise mesh skins reference bone indices that don't exist here.
_merge_extra_wearable_bones_into_base(skeleton_3d)
_mesh_assembler.merge_extra_bones(skeleton_3d)

for child in skeleton_3d.get_children():
if child is MeshInstance3D:
_rebind_skin_by_name(child, skeleton_3d)
_mesh_assembler.rebind_skin_by_name(child, skeleton_3d)
skeleton_3d.remove_child(child)
child.set_owner(null) # Clear owner since we're reparenting
# WEARABLE_NAME_PREFIX is used to identify non-bodyshape parts
Expand Down Expand Up @@ -1164,9 +967,9 @@ func async_load_wearables():

AvatarBuildProfiler.mark("mesh_duplicate")

apply_toon_material(body_shape_skeleton_3d)
_mesh_assembler.apply_toon_material(body_shape_skeleton_3d)
for child in body_shape_skeleton_3d.get_children():
apply_toon_material(child)
_mesh_assembler.apply_toon_material(child)

AvatarBuildProfiler.mark("toon")

Expand Down Expand Up @@ -1217,6 +1020,13 @@ func async_load_wearables():
remove_meta("pending_expression_trigger")
_async_play_expression_trigger(pending_emote)

# Replay a network emote that arrived while the avatar was loading (e.g. the
# stored announcement Pulse delivers at join for a peer already mid-emote).
if not _pending_network_emote.is_empty():
var pending_network_urn := _pending_network_emote
_pending_network_emote = ""
async_play_emote(pending_network_urn)


func apply_color_and_facial():
for child in body_shape_skeleton_3d.get_children():
Expand Down Expand Up @@ -1809,9 +1619,20 @@ func _play_emote_audio(file_hash: String):


func async_play_emote(emote_urn: String):
if not avatar_ready:
_pending_network_emote = emote_urn
return
await emote_controller.async_play_emote(emote_urn)


## Stop a looping emote on network request (rfc4 PlayerEmote.is_stopping /
## Pulse EmoteStopped). Called from Rust (AvatarScene::stop_emote).
func stop_emote_from_network():
_pending_network_emote = ""
if emote_controller:
emote_controller.stop_emote()


## Register scene emote content info for later retrieval.
## Called from Rust before async_play_emote for scene emotes.
func register_scene_emote_content(
Expand Down
Loading
Loading