-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathavatar.gd
More file actions
1699 lines (1409 loc) · 64 KB
/
Copy pathavatar.gd
File metadata and controls
1699 lines (1409 loc) · 64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
class_name Avatar
extends DclAvatar
signal avatar_loaded
# LOD state: FULL (close), MID (15-25m), CROSSFADE (25-30m), FAR (>=30m)
enum LODState { FULL, MID, CROSSFADE, FAR }
# Debug to store each avatar loaded in user://avatars
const DEBUG_SAVE_AVATAR_DATA = false
# Collision layers (mirrors decentraland.sdk.components.ColliderLayer)
# CL_PLAYER (4) is set on every avatar; CL_MAIN_PLAYER (8) is added on top for the
# local player so scenes can distinguish the main player from remote avatars.
const CL_PLAYER = 4
const CL_MAIN_PLAYER = 8
# Useful to filter wearable categories (and distinguish between top_head and head)
const WEARABLE_NAME_PREFIX = "__"
# AABB for the off-screen freeze notifier — sized to cover the avatar including
# arms-out emote poses. Erring large is the safe direction: a too-eager "on
# screen" only animates an avatar that might be off-screen, whereas a too-eager
# "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))
# 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.
const _ANCHOR_BONE_NAMES: Dictionary[int, String] = {
2: "Avatar_LeftHand",
3: "Avatar_RightHand",
4: "Avatar_Head",
5: "Avatar_Neck",
6: "Avatar_Spine",
7: "Avatar_Spine1",
8: "Avatar_Spine2",
9: "Avatar_Hips",
10: "Avatar_LeftShoulder",
11: "Avatar_LeftArm",
12: "Avatar_LeftForeArm",
13: "Avatar_LeftHandIndex1",
14: "Avatar_RightShoulder",
15: "Avatar_RightArm",
16: "Avatar_RightForeArm",
17: "Avatar_RightHandIndex1",
18: "Avatar_LeftUpLeg",
19: "Avatar_LeftLeg",
20: "Avatar_LeftFoot",
21: "Avatar_LeftToeBase",
22: "Avatar_RightUpLeg",
23: "Avatar_RightLeg",
24: "Avatar_RightFoot",
25: "Avatar_RightToeBase",
}
# Per-anchor state held by the Avatar's _anchors dict. One instance per
# currently-attached anchor (lazily created in register_anchor_use). Stores
# the resolved bone index, the cached bone pose (basis pre-scaled by 100 to
# cancel the Skeleton3D's 0.01 unit-conversion scale), and the set of
# AvatarAttach instances currently using this anchor.
class AnchorState:
extends RefCounted
var bone_name: String
var bone_idx: int = -1
var cached_transform: Transform3D
var users: Array[Node] = []
func _init(p_bone_name: String) -> void:
bone_name = p_bone_name
func resolve(skeleton: Skeleton3D) -> void:
bone_idx = skeleton.find_bone(bone_name)
if bone_idx != -1:
refresh(skeleton)
func refresh(skeleton: Skeleton3D) -> void:
if bone_idx == -1:
return
var t := skeleton.get_bone_global_pose(bone_idx)
t.basis = t.basis.scaled(100.0 * Vector3.ONE)
cached_transform = t
@export var skip_process: bool = false
@export var hide_name: bool = false:
set(value):
hide_name = value
_apply_nickname_visibility()
@export var non_3d_audio: bool = false
# Entity info for trigger area detection
var dcl_entity_id: int = -1
var is_local_player: bool = false
# Public
var avatar_id: String = ""
var hidden: bool = false
var passport_disabled: bool = false
var nametag_hidden: bool = false
var avatar_ready: bool = false
var has_connected_web3: bool = false # Whether the user has connected a web3 wallet (not a guest)
# AvatarShape-specific state (NPCs from scene SDK)
var is_avatar_shape: bool = false
var last_expression_trigger_timestamp: int = -1
var last_expression_trigger_id: String = ""
var finish_loading = false
var wearables_by_category: Dictionary = {}
var emote_controller: AvatarEmoteController # Rust binded. Don't change this variable name
var voice_chat_audio_player: AudioStreamPlayer = null
var voice_chat_audio_player_gen: AudioStreamGenerator = null
var mask_material = preload("res://assets/avatar/mask_material.tres")
# Signal-based wearable loader for threaded loading
var wearable_loader: WearableLoader = null
# anchor_point_id -> AnchorState for currently-attached anchors only. Entries
# are added lazily by register_anchor_use() and removed when the last
# AvatarAttach using them is unregistered.
var _anchors: Dictionary[int, AnchorState] = {}
# Session-level override (e.g. "Hide UI" setting). This should not persist into avatar state.
var _force_hide_name: bool = false
# Previous-frame jump_count for rising-edge detection of double-jump SFX.
var _last_jump_count: int = 0
# #b2: first _process tick should not treat wire-provided jump_count>=2 as a
# rising edge — otherwise a remote avatar first seen mid-double-jump plays the
# SFX from nothing. Cleared after the first frame where we seed _last_jump_count.
var _jump_count_sync_pending: bool = true
# Latched so we don't spam Close audio / Glider_End restart / hide-timer scheduling.
var _glider_close_initiated: bool = false
# Previous glide_state for _update_glider_prop's edge detection.
var _prop_last_glide_state: int = 0
# #b1/#b12: first call to _update_glider_prop should adopt whatever curr_state
# came in on the wire (OPENING/GLIDING/CLOSING) without spamming audio, instead
# of staying invisible because prev_state==0 doesn't match any branch.
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 = {}
# 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
# NameplateLayer; these cache the hide-flag/FAR gate and the depth-occlusion result.
var _use_2d_nameplate: bool = false
var _nametag_gate_visible: bool = true
var _nameplate_occluded: bool = false
# False until a real profile has been applied (async_update_avatar_from_profile). Until
# then a remote avatar still shows the NicknameUI scene-default placeholder
# ("nickname#xxxx"), which we hide in production / replace with a status in dev.
var _profile_ready: bool = false
# Comms-side profile-fetch state (pushed from message_processor.rs via AvatarScene). Used
# for the dev pending nameplate: banned => "Failed", otherwise "Loading".
var _profile_request_failures: int = 0
var _profile_request_banned: bool = false
var _impostor_layer: int = -1
var _lod_phase: int = 0
var _mesh_lod_visibility_captured: bool = false
# Written by AvatarLODCoordinator each tick. Caps the natural distance LOD so
# only the N closest avatars stay FULL, the next M MID/CROSSFADE, rest FAR.
var _lod_rank_cap: int = LODState.FULL
# Set by AvatarLODCoordinator: true when this avatar's rank is beyond the real
# impostor layer cap. Such avatars borrow another slot's texture and render
# fully tinted (black silhouette). Tracks the active slot's mode so a flip
# triggers a clean reallocation.
var _use_overflow_impostor: bool = false
var _impostor_layer_is_overflow: bool = false
# Set by AvatarLODCoordinator: true when the avatar's bounding sphere is fully
# outside the camera frustum. Off-frustum avatars release their impostor slot
# entirely — no multimesh instance, no real layer, no capture. Disk cache makes
# re-entry fast (texture rehydrates from PNG without recapture).
var _off_frustum: bool = false
# Driven by _screen_notifier's screen_entered/screen_exited signals: Godot's
# exact draw state for this avatar. The animation freeze keys off this, NOT the
# coordinator's approximate _off_frustum sphere test — otherwise an avatar near
# the screen edge (still drawn, but flagged off-frustum) or one sweeping into
# view during the coordinator's 6-frame update lag freezes on a stale pose
# instead of throttling. Default true so a freshly spawned avatar animates until
# the notifier reports its real state.
var _on_screen: bool = true
var _screen_notifier: VisibleOnScreenNotifier3D = null
# Latched while frozen off-screen: the AnimationTree was paused regardless of LOD
# state, so when we come back on-screen we know we have to restore the
# state-driven anim setup (active/manual/throttle).
var _anim_frozen_off_screen: bool = false
# Wall-clock ms when the freeze started. Used to advance the AnimationTree by
# the elapsed time on re-entry so the emote phase matches what it would have
# been had we not paused — single one-shot recompute, not a frame-by-frame
# catch-up, so the CPU saving from the freeze is preserved.
var _anim_freeze_start_ms: int = 0
# Skinning throttle (MID/CROSSFADE only): drive AnimationTree manually and
# advance every N frames so the skeleton bones update at ~20fps instead of
# ~60fps. Imperceptible at 15-30m distance and a sizeable CPU saving when
# many avatars share the screen.
var _anim_throttle_acc: float = 0.0
var _anim_throttle_counter: int = 0
var _anim_throttle_active: bool = false
@onready var animation_tree = $AnimationTree
@onready var animation_player = $AnimationPlayer
@onready var nickname_ui = %NicknameUI
@onready var nickname_quad = %NicknameQuad
@onready var nickname_viewport = %NicknameViewport
@onready var timer_hide_mic = %Timer_HideMic
@onready var body_shape_skeleton_3d: Skeleton3D = $Armature/Skeleton3D
@onready var bone_attachment_3d_name = $Armature/Skeleton3D/BoneAttachment3D_Name
@onready var audio_player_emote = $AudioPlayer_Emote
@onready var avatar_modifier_area_detector = $avatar_modifier_area_detector
@onready var click_area = $ClickArea
@onready var trigger_detector = %TriggerDetector
@onready var glider_prop: Node3D = %GliderProp
@onready var audio_player_double_jump: AudioStreamPlayer3D = %AudioPlayer_DoubleJump
func _ready():
_mesh_assembler = AvatarMeshAssembler.new(body_shape_skeleton_3d)
var billboard_mode = (
BaseMaterial3D.BillboardMode.BILLBOARD_FIXED_Y
if Global.is_xr()
else BaseMaterial3D.BillboardMode.BILLBOARD_ENABLED
)
nickname_quad.billboard = billboard_mode
# Personal-space dissolve for any avatar near the world camera (issue #1814).
var proximity_fade := AvatarProximityFade.new()
proximity_fade.name = "AvatarProximityFade"
add_child(proximity_fade)
wearable_loader = WearableLoader.new()
emote_controller = AvatarEmoteController.new(self, animation_player, animation_tree)
body_shape_skeleton_3d.skeleton_updated.connect(self._attach_point_skeleton_updated)
avatar_modifier_area_detector.set_avatar_modifier_area.connect(
self._on_set_avatar_modifier_area
)
avatar_modifier_area_detector.unset_avatar_modifier_area.connect(
self._unset_avatar_modifier_area
)
if non_3d_audio:
var audio_player_name = audio_player_emote.get_name()
remove_child(audio_player_emote)
audio_player_emote.queue_free()
audio_player_emote = AudioStreamPlayer.new()
audio_player_emote.bus = &"AvatarAndEmotes"
add_child(audio_player_emote)
audio_player_emote.name = audio_player_name
# Hide mic when the avatar is spawned
nickname_ui.mic_enabled = false
Global.on_chat_message.connect(on_chat_message)
_use_2d_nameplate = not Global.is_xr()
if _use_2d_nameplate:
NameplateLayer.attach(self)
_apply_nickname_visibility()
_lod_phase = int(self.unique_id) % AvatarImpostorConfig.DISTANCE_CHECK_PERIOD_FRAMES
AvatarLODCoordinator.register(self)
_setup_screen_notifier()
# Setup metadata for raycast detection (same as DCL entities)
click_area.set_meta("is_avatar", true)
click_area.set_meta("avatar_id", avatar_id)
func _exit_tree() -> void:
AvatarLODCoordinator.unregister(self)
# The 2D nameplate lives in the shared NameplateLayer, not under this avatar, so it is
# NOT auto-freed with the subtree. We must NOT free it here either: _exit_tree also
# fires on a transient reparent (e.g. lobby.gd moves avatar_preview between containers),
# and since _ready() only runs once it would never be re-created — leaving nickname_ui
# permanently freed while the avatar stays alive (the avatar.gd:539 "previously freed"
# bug). Instead just hide it while out of the tree; NameplateLayer.update() (driven by
# our _process) is paused meanwhile, so a last-visible tag would otherwise linger frozen.
# The real free happens in _notification(NOTIFICATION_PREDELETE) when the avatar dies.
if _use_2d_nameplate and is_instance_valid(nickname_ui):
nickname_ui.modulate.a = 0.0
nickname_ui.hide()
# For local player and remote avatars, trigger detection is setup later via setup_trigger_detection()
# For AvatarShapes (scene NPCs), remove_trigger_detection() is called from avatar_shape.rs
func _notification(what: int) -> void:
# Free the reparented nickname_ui only when the avatar object is actually deleted (not
# on transient tree exits) — see _exit_tree for why. NameplateLayer.detach() guards
# is_instance_valid, so a full-teardown double-free is harmless.
if what == NOTIFICATION_PREDELETE and _use_2d_nameplate:
NameplateLayer.detach(self)
## Setup trigger detection for this avatar (local player and remote avatars only).
## - For local player: entity_id=SceneEntityId.PLAYER (0x10000)
## - For remote avatars: entity_id=assigned entity from avatar_scene.rs
func setup_trigger_detection(p_entity_id: int) -> void:
dcl_entity_id = p_entity_id
# Set metadata on TriggerDetector so trigger_area.rs can identify this avatar
trigger_detector.set_meta("dcl_entity_id", dcl_entity_id)
# The local (main) player also lives on the CL_MAIN_PLAYER layer so scenes can
# tell it apart from remote avatars. Remote avatars stay on CL_PLAYER only.
if is_local_player:
trigger_detector.collision_layer = CL_PLAYER | CL_MAIN_PLAYER
# Enable the collision shape
trigger_detector.get_node("CollisionShape3D").disabled = false
## Remove trigger detection for this avatar (AvatarShapes/scene NPCs only).
## Called from avatar_shape.rs after the avatar is added to the scene.
func remove_trigger_detection() -> void:
if trigger_detector != null:
trigger_detector.queue_free()
trigger_detector = null
func on_chat_message(address: String, message: String, _timestamp: float):
if avatar_id != address:
return
nickname_ui.async_show_message(message)
_request_nickname_redraw()
func _input(event):
if event.is_action_pressed("ia_pointer"):
# Only handle input if this avatar is currently selected and not blocked/hidden
var selected = Global.get_selected_avatar()
if selected and selected == self and avatar_id and not hidden and not passport_disabled:
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var explorer = Global.get_explorer()
if (
is_instance_valid(explorer)
and explorer.is_session_hide_main_hud()
and explorer.is_session_hide_view_profile()
):
return
Global.open_profile_by_avatar.emit(self)
func try_show():
avatar_modifier_area_detector.check_areas()
func _on_set_avatar_modifier_area(area: DclAvatarModifierArea3D):
_unset_avatar_modifier_area() # Reset state
if AvatarExcludeIdMatcher.is_excluded(avatar_id, area.exclude_ids):
return # the avatar is not going to be modified
for modifier in area.avatar_modifiers:
if modifier == 0: # hide avatar
hide()
_hide_impostor_render()
_set_click_area_enabled(false)
elif modifier == 1: # disable passport
passport_disabled = true
elif modifier == 2: # hide nametag
nametag_hidden = true
_apply_nickname_visibility()
func set_hidden(value):
hidden = value
if hidden:
hide()
_hide_impostor_render()
# Disable click detection so blocked/hidden avatars can't be interacted with
_set_click_area_enabled(false)
else:
try_show()
# Re-enable click detection
_set_click_area_enabled(true)
# Blocked/hidden avatars must also hide their nameplate. The 2D nameplate is
# reparented out of this node into a shared screen-space layer, so hide() above
# doesn't reach it — re-evaluate the gate (which now includes `hidden`) explicitly.
_apply_nickname_visibility()
# The impostor MultiMesh lives on AvatarScene (parent), not on the Avatar node,
# so hide()/visible=false on the avatar doesn't affect it. Force its slot's
# fade_alpha to 0 so the GPU discards the fragment until LOD recomputes.
func _hide_impostor_render() -> void:
if _impostor_layer >= 0 and Global.avatars != null:
Global.avatars.set_impostor_state(get_instance_id(), 0.0, 0.0, 0.0)
# Stable identity key for the disk-backed impostor texture cache. The hash
# combines eth address (for cross-session stability) with the visual identity
# (body + wearables + colors), so a user that changes outfit gets a fresh
# capture instead of pulling stale pixels from the previous look's PNG. NPCs
# fall through the same path with an empty eth segment, so visually-identical
# NPCs share a cache entry.
func _get_impostor_cache_key() -> String:
if avatar_data == null:
return ""
var parts := PackedStringArray()
parts.append(avatar_id.to_lower() if avatar_id != "" else "")
parts.append(avatar_data.get_body_shape())
var wearables = avatar_data.get_wearables()
if wearables is Array:
var sorted_wearables: Array = wearables.duplicate()
sorted_wearables.sort()
for w in sorted_wearables:
parts.append(w)
parts.append(str(avatar_data.get_skin_color()))
parts.append(str(avatar_data.get_eyes_color()))
parts.append(str(avatar_data.get_hair_color()))
return "|".join(parts).sha1_text()
func _set_click_area_enabled(enabled: bool) -> void:
if click_area:
var collision_shape = click_area.get_node_or_null("CollisionShape3D")
if collision_shape:
collision_shape.disabled = not enabled
func _unset_avatar_modifier_area():
if not hidden:
show()
_set_click_area_enabled(true)
passport_disabled = false
nametag_hidden = false
_apply_nickname_visibility()
func async_update_avatar_from_profile(profile: DclUserProfile):
_profile_ready = true
var avatar = profile.get_avatar()
var new_avatar_name: String = profile.get_name()
if not profile.has_claimed_name():
new_avatar_name += "#" + profile.get_ethereum_address().right(4)
if is_instance_valid(nickname_ui):
nickname_ui.name_claimed = profile.has_claimed_name()
var avatar_id_changed := avatar_id != profile.get_ethereum_address()
avatar_id = profile.get_ethereum_address()
has_connected_web3 = profile.has_connected_web3()
prints("Async update avatar from profile", avatar_id)
# Update metadata with the new avatar_id
if click_area:
click_area.set_meta("avatar_id", avatar_id)
# Re-evaluate AvatarModifierArea exclusion: the area may have triggered
# before the profile arrived (issue #2166 race), leaving the avatar hidden
# even though its id is in excludeIds.
if avatar_id_changed:
try_show()
await async_update_avatar(avatar, new_avatar_name)
func async_update_avatar(
new_avatar: DclAvatarWireFormat, new_avatar_name: String, avatar_shape_config: Dictionary = {}
):
if new_avatar == null:
printerr("Trying to update an avatar with an null value")
return
# Handle AvatarShape-specific config (NPCs from scene SDK)
is_avatar_shape = avatar_shape_config.get("is_avatar_shape", false)
# Adopt the AvatarShape.id when it's an eth address so the impostor capturer
# can route through the catalyst body-texture path
# (`avatar_id.begins_with("0x")`) instead of an off-screen render.
if is_avatar_shape:
var shape_id: String = avatar_shape_config.get("id", "")
if shape_id.begins_with("0x") and avatar_id != shape_id:
avatar_id = shape_id
if click_area:
click_area.set_meta("avatar_id", avatar_id)
try_show()
# Update metadata for raycast detection
if click_area:
click_area.set_meta("is_avatar_shape", is_avatar_shape)
# Handle expression_trigger for AvatarShape emotes
if is_avatar_shape:
var expression_trigger_id = avatar_shape_config.get("expression_trigger_id", "")
var expression_trigger_timestamp: int = avatar_shape_config.get(
"expression_trigger_timestamp", -1
)
# Determine if we should trigger the emote:
# 1. If timestamp is valid (>= 0) and greater than last timestamp, OR
# 2. If no timestamp (-1) but the expression_trigger_id changed
var should_trigger = false
if not expression_trigger_id.is_empty():
if expression_trigger_timestamp >= 0:
# Timestamp-based triggering (Lamport timestamp pattern)
should_trigger = expression_trigger_timestamp > last_expression_trigger_timestamp
else:
# No timestamp - trigger when id changes
should_trigger = expression_trigger_id != last_expression_trigger_id
if should_trigger:
last_expression_trigger_timestamp = expression_trigger_timestamp
last_expression_trigger_id = expression_trigger_id
# Defer emote play to after avatar is loaded if needed
if avatar_ready:
_async_play_expression_trigger(expression_trigger_id)
else:
# Store pending emote to play after avatar loads
set_meta("pending_expression_trigger", expression_trigger_id)
# Skip redundant updates - if avatar data hasn't changed and avatar is already loaded,
# no need to re-duplicate all meshes and materials (saves Vulkan descriptor sets)
if finish_loading and avatar_data != null and avatar_data.equal(new_avatar):
# Only update the name if it changed
if get_avatar_name() != new_avatar_name:
set_avatar_name(new_avatar_name)
if is_instance_valid(nickname_ui):
var splitted_nickname = new_avatar_name.split("#", false)
if splitted_nickname.size() > 1:
nickname_ui.nickname = splitted_nickname[0]
nickname_ui.tag = splitted_nickname[1]
else:
nickname_ui.nickname = new_avatar_name
nickname_ui.tag = ""
nickname_ui.nickname_color = DclAvatar.get_nickname_color(new_avatar_name)
# Re-trigger UPDATE_ONCE so the SubViewport repaints with the new text
_apply_nickname_visibility()
return
set_avatar_data(new_avatar)
set_avatar_name(new_avatar_name)
var wearable_to_request := []
var splitted_nickname = new_avatar_name.split("#", false)
if splitted_nickname.size() > 1:
nickname_ui.nickname = splitted_nickname[0]
nickname_ui.tag = splitted_nickname[1]
elif is_instance_valid(nickname_ui):
nickname_ui.nickname = new_avatar_name
nickname_ui.tag = ""
if is_instance_valid(nickname_ui):
nickname_ui.nickname_color = DclAvatar.get_nickname_color(new_avatar_name)
nickname_ui.mic_enabled = false
_apply_nickname_visibility()
wearable_to_request.append_array(avatar_data.get_wearables())
for emote_urn in avatar_data.get_emotes():
if emote_urn.begins_with("urn"):
wearable_to_request.push_back(emote_urn)
wearable_to_request.push_back(avatar_data.get_body_shape())
# Enable to store a bunch of avatar of a session
if DEBUG_SAVE_AVATAR_DATA:
DirAccess.make_dir_absolute("user://avatars")
var file_path = (
"user://avatars/"
+ (
(
avatar_id
+ "_"
+ new_avatar_name
+ "_"
+ str(Time.get_unix_time_from_system())
+ ".json"
)
. validate_filename()
)
)
var dict: Dictionary = {
"userId": avatar_id,
"name": new_avatar_name,
"time": Time.get_unix_time_from_system(),
"wearables": avatar_data.get_wearables(),
"bodyShape": avatar_data.get_body_shape(),
"forceRender": avatar_data.get_force_render(),
"emotes": avatar_data.get_emotes()
}
var file = FileAccess.open(file_path, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(dict))
file.close()
# TODO: Validate if the current profile can own this wearables
# tracked at https://github.com/decentraland/godot-explorer/issues/244
# wearable_to_request = filter_owned_wearables(wearable_to_request)
finish_loading = false
var promise = Global.content_provider.fetch_wearables(
wearable_to_request, Global.realm.get_profile_content_url()
)
await PromiseUtils.async_all(promise)
await async_fetch_wearables_dependencies()
func set_force_hide_name(value: bool) -> void:
if _force_hide_name == value:
return
_force_hide_name = value
if is_inside_tree():
_apply_nickname_visibility()
## Legacy XR-only: bump the nickname SubViewport to redraw one frame (UPDATE_ONCE
## auto-resets). 2D nameplates are live Controls so content setters suffice.
func _request_nickname_redraw() -> void:
if _use_2d_nameplate:
return
if nickname_viewport == null or nickname_quad == null:
return
if not nickname_quad.visible:
return
nickname_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func _apply_nickname_visibility() -> void:
if nickname_quad == null:
return
# Profile not received yet: the NicknameUI still shows its scene-default placeholder
# ("nickname#xxxx"). Hide it in production; in dev/staging surface the request state.
var profile_pending: bool = not _profile_ready and not is_avatar_shape
if profile_pending and not Global.is_production():
_apply_pending_profile_nameplate()
# Hide nickname for AvatarShapes only when the scene didn't set a real name
# (the proto default is "NPC", which is noise). Also hide on FAR LOD —
# unreadable at impostor distance and each quad is an extra draw call.
var current_name: String = get_avatar_name()
var avatar_shape_has_no_name: bool = (
is_avatar_shape and (current_name.is_empty() or current_name == "NPC")
)
var far_lod: bool = _lod_state == LODState.FAR
var should_hide := (
avatar_shape_has_no_name
or hide_name
or _force_hide_name
or far_lod
or nametag_hidden
or hidden
or (profile_pending and Global.is_production())
)
if _use_2d_nameplate:
# _update_nameplate_2d() positions/shows when allowed; hide now if gated off.
_nametag_gate_visible = not should_hide
if should_hide and nickname_ui != null:
# Hard reset alpha too: NameplateLayer.update() recomputes visibility from
# move_toward()'d alpha every frame, so a plain hide() would be undone and the
# stale tag would linger as a ~6-frame fade-out. Zeroing alpha makes the hide
# instant; the gate reopening fades it back in cleanly from 0.
nickname_ui.modulate.a = 0.0
nickname_ui.hide()
return
if should_hide:
nickname_quad.hide()
if nickname_viewport != null:
nickname_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
else:
nickname_quad.show()
if nickname_viewport != null:
# UPDATE_ONCE: redraw one frame here, then the SubViewport idles until
# something nickname-related changes (see _request_nickname_redraw).
nickname_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
## Dev/staging only: while the profile is still pending, replace the meaningless
## "nickname#xxxx" placeholder with the request state so we can see what's happening.
## (In production the tag is hidden instead — see _apply_nickname_visibility.)
func _apply_pending_profile_nameplate() -> void:
if nickname_ui == null:
return
nickname_ui.name_claimed = false
# banned (>= 2 consecutive fetch failures) => the comms layer gave up for now.
var failed: bool = _profile_request_banned
nickname_ui.nickname = "Failed" if failed else "Loading"
nickname_ui.tag = avatar_id.right(4) if not avatar_id.is_empty() else "…"
nickname_ui.nickname_color = Color(0.85, 0.4, 0.4) if failed else Color(0.7, 0.7, 0.7)
_request_nickname_redraw()
## Pushed from comms (message_processor.rs -> AvatarScene) when a profile fetch fails or
## gets banned. Refresh the dev pending nameplate so it flips "Loading" -> "Failed".
func set_profile_request_state(failures: int, banned: bool) -> void:
_profile_request_failures = failures
_profile_request_banned = banned
if not _profile_ready:
_apply_nickname_visibility()
func update_colors(eyes_color: Color, skin_color: Color, hair_color: Color) -> void:
avatar_data.set_eyes_color(eyes_color)
avatar_data.set_skin_color(skin_color)
avatar_data.set_hair_color(hair_color)
if finish_loading:
apply_color_and_facial()
if _impostor_layer >= 0 and not _impostor_layer_is_overflow and Global.avatars != null:
Global.avatars.invalidate_impostor_texture(get_instance_id(), _get_impostor_cache_key())
ImpostorCapturer.request_capture(self)
func async_fetch_wearables_dependencies():
var wearables_dict: Dictionary = {}
# Fill data
var body_shape_id := avatar_data.get_body_shape()
wearables_dict[body_shape_id] = Global.content_provider.get_wearable(body_shape_id)
for item in avatar_data.get_wearables():
wearables_dict[item] = Global.content_provider.get_wearable(item)
var async_calls_info: Array = []
var async_calls: Array = []
for emote_urn in avatar_data.get_emotes():
if emote_urn.begins_with("urn"):
var emote_promises = emote_controller.async_fetch_emote(emote_urn, body_shape_id)
for emote_promise in emote_promises:
async_calls.push_back(emote_promise)
async_calls_info.push_back(emote_urn)
# Use signal-based wearable loading with threaded ResourceLoader
# Safety check: avatar may have been freed during async operations
if not is_instance_valid(wearable_loader) or not is_inside_tree():
return
await wearable_loader.async_load_wearables(wearables_dict.keys(), body_shape_id)
var promises_result: Array = await PromiseUtils.async_all(async_calls)
for i in range(promises_result.size()):
if promises_result[i] is PromiseError:
printerr("Error loading ", async_calls_info[i], ":", promises_result[i].get_error())
await async_load_wearables()
func async_try_to_set_body_shape(body_shape_hash):
# Safety check: avatar may have been freed during async operations
if not is_instance_valid(wearable_loader) or not is_inside_tree():
return
var body_shape: Node3D = await wearable_loader.async_get_wearable_node(body_shape_hash)
if body_shape == null:
printerr("Avatar: Failed to load body shape ", body_shape_hash)
return
var new_skeleton = body_shape.find_child("Skeleton3D")
if new_skeleton == null:
body_shape.queue_free()
return
for child in body_shape_skeleton_3d.get_children():
if child is MeshInstance3D:
body_shape_skeleton_3d.remove_child(child)
child.queue_free()
# Recycle any extra bones merged in the previous assembly so the upcoming
# 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)
for child in new_skeleton.get_children():
new_skeleton.remove_child(child)
child.set_owner(null) # Clear owner since we're reparenting
child.name = "bodyshape_" + child.name.to_lower()
body_shape_skeleton_3d.add_child(child)
# Free the now-empty body shape container
body_shape.queue_free()
_reresolve_active_anchors()
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():
return
AvatarBuildProfiler.begin()
# Hide skeleton immediately if show_only_wearables to prevent flash of default body
var show_only_wearables = avatar_data.get_show_only_wearables()
if show_only_wearables:
body_shape_skeleton_3d.visible = false
var curated_wearables := Wearables.get_curated_wearable_list(
avatar_data.get_body_shape(),
avatar_data.get_wearables(),
avatar_data.get_force_render(),
avatar_data.get_show_only_wearables()
)
if curated_wearables.wearables_by_category.is_empty():
printerr("couldn't get curated wearables")
return
wearables_by_category = curated_wearables.wearables_by_category
var body_shape_wearable = wearables_by_category.get(Wearables.Categories.BODY_SHAPE)
if body_shape_wearable == null:
printerr("body shape not found")
return
# If some wearables are needed but they weren't included in the first request (fallback wearables)
if not curated_wearables.need_to_fetch.is_empty():
var need_to_fetch_promise = Global.content_provider.fetch_wearables(
Array(curated_wearables.need_to_fetch), Global.realm.get_profile_content_url()
)
await PromiseUtils.async_all(need_to_fetch_promise)
# Safety check: avatar may have been freed during async operations
if not is_instance_valid(wearable_loader) or not is_inside_tree():
return
# Use signal-based wearable loading with threaded ResourceLoader
await wearable_loader.async_load_wearables(
curated_wearables.need_to_fetch, body_shape_wearable.get_id()
)
for wearable_id in curated_wearables.need_to_fetch:
var wearable = Global.content_provider.get_wearable(wearable_id)
if wearable != null:
wearables_by_category[wearable.get_category()] = wearable
await async_try_to_set_body_shape(
Wearables.get_item_main_file_hash(body_shape_wearable, avatar_data.get_body_shape())
)
wearables_by_category.erase(Wearables.Categories.BODY_SHAPE)
var has_own_skin = false
var has_own_upper_body = false
var has_own_lower_body = false
var has_own_feet = false
var has_own_hands = false
var has_own_head = false
for category in wearables_by_category:
# Safety check: avatar may have been freed during async operations
if not is_instance_valid(wearable_loader) or not is_inside_tree():
return
var wearable = wearables_by_category[category]
# Skip texture-based wearables (eyes, eyebrows, mouth)
if Wearables.is_texture(category):
continue
var file_hash = Wearables.get_item_main_file_hash(wearable, avatar_data.get_body_shape())
var obj = await wearable_loader.async_get_wearable_node(file_hash)
if obj == null:
printerr("Avatar: Failed to load wearable ", category, " hash: ", file_hash)
continue
# Reparent wearable meshes directly (no need to duplicate since wearable_loader
# returns a fresh instantiated scene that we'll discard anyway)
var wearable_skeletons = obj.find_children("Skeleton3D")
for skeleton_3d in wearable_skeletons:
# 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.
_mesh_assembler.merge_extra_bones(skeleton_3d)
for child in skeleton_3d.get_children():
if child is MeshInstance3D:
_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
child.name = child.name.to_lower() + WEARABLE_NAME_PREFIX + category
body_shape_skeleton_3d.add_child(child)
# Free the now-empty wearable container
obj.queue_free()
match category:
Wearables.Categories.UPPER_BODY:
has_own_upper_body = true
Wearables.Categories.LOWER_BODY:
has_own_lower_body = true
Wearables.Categories.FEET:
has_own_feet = true
Wearables.Categories.HANDS:
has_own_hands = true
Wearables.Categories.HEAD:
has_own_head = true
Wearables.Categories.SKIN:
has_own_skin = true
AvatarBuildProfiler.mark("load_reparent")
# Here hidings is an alias
var hidings = curated_wearables.hidden_categories
# When show_only_wearables is true, hide all body parts (skin, hair, facial features)
var base_bodyshape_hidings = {
"ubody_basemesh":
show_only_wearables or has_own_skin or has_own_upper_body or hidings.has("upper_body"),
"lbody_basemesh":
show_only_wearables or has_own_skin or has_own_lower_body or hidings.has("lower_body"),
"feet_basemesh": show_only_wearables or has_own_skin or has_own_feet or hidings.has("feet"),
"hands_basemesh":
show_only_wearables or has_own_skin or has_own_hands or hidings.has("hands"),
"head_basemesh": show_only_wearables or has_own_skin or has_own_head or hidings.has("head"),
"mask_eyes":
(
show_only_wearables
or has_own_skin
or has_own_head
or hidings.has("eyes")
or hidings.has("head")
),
"mask_eyebrows":
(
show_only_wearables
or has_own_skin
or has_own_head
or hidings.has("eyebrows")
or hidings.has("head")
),
"mask_mouth":
(
show_only_wearables
or has_own_skin
or has_own_head
or hidings.has("mouth")
or hidings.has("head")
),
}
# Final computation of hidings
hidings = Dictionary()
hidings.merge(base_bodyshape_hidings)
for category in curated_wearables.hidden_categories:
hidings[WEARABLE_NAME_PREFIX + category] = true
for child in body_shape_skeleton_3d.get_children():
var should_hide = false
for ends_with in hidings:
if child.name.ends_with(ends_with) and hidings[ends_with]:
should_hide = true
break
if should_hide:
child.hide()
AvatarBuildProfiler.mark("hide")
AvatarBuildProfiler.mark("mesh_duplicate")
_mesh_assembler.apply_toon_material(body_shape_skeleton_3d)
for child in body_shape_skeleton_3d.get_children():
_mesh_assembler.apply_toon_material(child)
AvatarBuildProfiler.mark("toon")
apply_color_and_facial()
AvatarBuildProfiler.mark("color_facial")
# For show_only_wearables, reset skeleton to T-pose so wearable doesn't animate
if show_only_wearables:
for i in range(body_shape_skeleton_3d.get_bone_count()):
body_shape_skeleton_3d.reset_bone_pose(i)
body_shape_skeleton_3d.visible = true
finish_loading = true
# Emotes - get from cached emote scenes
for emote_urn in avatar_data.get_emotes():
if not emote_urn.begins_with("urn"):
# Default (utility emotes)
continue
var emote = Global.content_provider.get_wearable(emote_urn)
if emote == null:
continue
var file_hash = Wearables.get_item_main_file_hash(emote, avatar_data.get_body_shape())
if file_hash.is_empty():
continue
# Use emote_loader from emote_controller to get the cached emote (threaded loading)
var obj = await emote_controller.emote_loader.async_get_emote_gltf(file_hash)