class_name SyncController extends Area3D @export var shape: CollisionShape3D var peer_id: int var module: copyMultiplayer var model_controller: ModelController var nickname: String var version: int = -1 var bone_lookup: Array[String] var blendshape_lookup: Array[NodePath] var model_name : String var model : Node var skeleton : Skeleton3D var anim_player : AnimationPlayer var anim_root : Node var settings : PlayerSettings var is_dragging := false var drag_current: Vector3 # Reusable buffer to write data for synchronizing models. static var write_stream: StreamBuffer = StreamBuffer.with_capacity(2048) func _ready() -> void: peer_id = name.to_int() module = get_parent() model_controller = ModelController.new() model_controller.name = "ModelController" add_child(model_controller) func _process(_delta: float) -> void: update_collision_shape_position() handle_dragging() func _exit_tree() -> void: if settings: settings.queue_free() func get_display_name() -> String: if nickname: return "Player '%s' (%d)" % [ nickname, peer_id ] else: return "Player (%d)" % peer_id func change_nickname(new_nickname: String) -> void: new_nickname = new_nickname.strip_edges() if new_nickname == "": return # Ignore empty nicknames. if new_nickname == nickname: return # Ignore unchanged nicknames. module.print_log("%s is now known as '%s'" % [ get_display_name(), new_nickname ]) nickname = new_nickname if !settings: settings = module.new_player_settings() settings.on_transform_changed(transform) settings.value_changed.connect(func(value): transform = value) settings.set_nickname(nickname) ## Attempts to change the model of this player. func change_model( new_version: int, new_bone_lookup: Array[String], new_blendshape_lookup: Array[NodePath], filename: String, ) -> void: # These should be safe to update even if the model doesn't load. # We just need to stay in sync with the lookup tables. version = new_version bone_lookup = new_bone_lookup blendshape_lookup = new_blendshape_lookup if not filename.is_valid_filename(): module.print_log("ERROR: '%s' is not a valid file name!" % filename) return var full_path := module.cache.path_join(filename) if not FileAccess.file_exists(full_path): module.print_log("%s wanted to switch to '%s', but it doesn't exist, skipping" % [ get_display_name(), filename ]) return if not model_controller.load_vrm(full_path): module.print_log("ERROR: Model '%s' could not be loaded!" % filename) return model_name = filename model = model_controller.get_node_or_null("Model") skeleton = model_controller._get_model_skeleton() anim_player = model.find_child("AnimationPlayer", false, false) anim_root = anim_player.get_node(anim_player.root_node) recalculate_collision_shape() module.print_log("%s switched to '%s'" % [ get_display_name(), filename ]) func sync_model_animation( current_version: int, uncompressed_length: int, buffer: PackedByteArray, ) -> void: if version != current_version: return if (not model) or (not skeleton) or (not anim_root): return var uncompressed_buffer := buffer.decompress(uncompressed_length, FileAccess.COMPRESSION_ZSTD); var stream := StreamBuffer.from_buffer(uncompressed_buffer) model.transform = stream.read_transform16() var all_bones: Array[Dictionary] = [] var all_blendshapes: Array[Dictionary] = [] # We filter out bones at rest and blendshapes at zero, so this # is initializing all_bones/blendshapes to the default values. for bone_name in bone_lookup: var bone_idx := skeleton.find_bone(bone_name) var bone_rest := skeleton.get_bone_rest(bone_idx) all_bones.append({ name = bone_name, idx = bone_idx, pose = bone_rest }) for anim_path in blendshape_lookup: all_blendshapes.append({ path = anim_path, value = 0.0 }) # 256 bones (and blendshapes) should be enough, right? for i in stream.read_uint8(): var lookup := stream.read_uint8() all_bones[lookup].pose = stream.read_bone_pose(all_bones[lookup].pose) for i in stream.read_uint8(): var lookup := stream.read_uint8() all_blendshapes[lookup].value = stream.read_range16() # Apply all the values to bones / blendshapes. for bone in all_bones: if bone.idx == -1: continue # Different model might not have this bone. skeleton.set_bone_pose(bone.idx, bone.pose) for blendshape in all_blendshapes: var anim_node := anim_root.get_node_or_null(blendshape.path) if not anim_node: continue # Different model might not have this node. anim_node.set("blend_shapes/" + blendshape.path.get_subname(0), blendshape.value) @warning_ignore("shadowed_variable") static func send_model_animation(module: copyMultiplayer) -> void: # Check if there's other players we're connected to. if module.multiplayer.get_peers().size() == 0: return var model := module.get_model() var skeleton := module.get_skeleton() var anim_player := model.find_child("AnimationPlayer", false, false) var anim_root := anim_player.get_node_or_null(anim_player.root_node) if (not model) or (not skeleton) or (not anim_root): return write_stream.write_transform16(model.transform) # Pre-filter any bones that are at rest / blendshapes that are at zero. # Unless most bones / blendshapes are active, this should reduce packet size. var restless_bones := [] var active_blendshapes := [] for i in module.bone_lookup.size(): var bone_name := module.bone_lookup[i] var bone_idx := skeleton.find_bone(bone_name) var bone_pose := skeleton.get_bone_pose(bone_idx) var bone_rest := skeleton.get_bone_rest(bone_idx) if not bone_pose.is_equal_approx(bone_rest): restless_bones.append({ lookup = i, pose = bone_pose, rest = bone_rest }) for i in module.blendshape_lookup.size(): var anim_path := module.blendshape_lookup[i] var anim_node := anim_root.get_node_or_null(anim_path) var value: float = anim_node.get("blend_shapes/" + anim_path.get_subname(0)) if not is_zero_approx(value): active_blendshapes.append({ lookup = i, value = value }) write_stream.write_uint8(restless_bones.size()) for bone in restless_bones: write_stream.write_uint8(bone.lookup) write_stream.write_bone_pose(bone.pose, bone.rest) write_stream.write_uint8(active_blendshapes.size()) for blendshape in active_blendshapes: write_stream.write_uint8(blendshape.lookup) write_stream.write_range16(blendshape.value) # The compression still helps, so we'll keep it for now. var compressed_buffer := write_stream.slice().compress(FileAccess.COMPRESSION_ZSTD); # Uncomment this to see packet size. Can we hit < 256 bytes? # module.set_status("Packet size: %d bytes (%d uncompressed)" % [ compressed_buffer.size(), write_stream.size ]) module.sync_model_animation.rpc(module.version, write_stream.size, compressed_buffer) write_stream.clear() # Updates the collision shapes used for allowing the player model to be # repositioned based on the current model's skeleton. This functionality # is only enabled when the "Players" tab is selected. func recalculate_collision_shape() -> void: var head_y := _get_bone_position("Head").y var left_foot_y := _get_bone_position("LeftFoot").y var right_foot_y := _get_bone_position("RightFoot").y var avg_foot_y := (left_foot_y + right_foot_y) / 2 var shoulder_x := _get_bone_position("LeftUpperArm").x var capsule := CapsuleShape3D.new() capsule.height = (head_y - avg_foot_y) * 1.5 capsule.radius = shoulder_x * 1.75 shape.shape = capsule func update_collision_shape_position() -> void: if not skeleton: return var head := _get_bone_position("Head") var left_foot := _get_bone_position("LeftFoot") var right_foot := _get_bone_position("RightFoot") var avg_foot := (left_foot + right_foot) / 2 shape.global_position = skeleton.global_position + (head + avg_foot) / 2 # FIXME: This doesn't consider rotation or scale. func _get_bone_position(bone_name: String) -> Vector3: var idx := skeleton.find_bone(bone_name) var pose := skeleton.get_bone_global_pose(idx) return pose.origin func _input_event(_camera: Node, event: InputEvent, pos: Vector3, _normal: Vector3, _idx: int) -> void: if (module.can_move_players && event is InputEventMouseButton && event.button_index == MOUSE_BUTTON_LEFT && event.pressed): is_dragging = true drag_current = pos func _unhandled_input(event: InputEvent) -> void: if (is_dragging && event is InputEventMouseButton && event.button_index == MOUSE_BUTTON_LEFT && not event.pressed): is_dragging = false get_viewport().set_input_as_handled() func handle_dragging() -> void: if not is_dragging: return var camera := get_viewport().get_camera_3d() var mouse := get_viewport().get_mouse_position() var origin := camera.project_ray_origin(mouse) var dir := camera.project_ray_normal(mouse) if dir.z == 0: return var distance := (drag_current.z - origin.z) / dir.z var target := origin + dir * distance position += target - drag_current drag_current = target sortof_face_the_camera() settings.on_transform_changed(transform) # FIXME: Kind of a hack, find a better way. ## Rotates the model a little bit towards the camera ## so it doesn't appear to be staring into nowhere. func sortof_face_the_camera() -> void: var camera := get_viewport().get_camera_3d() var from_2d := Vector2(global_position.x, global_position.z) var to_2d := Vector2(camera.global_position.x, camera.global_position.z) rotation.y = -from_2d.angle_to_point(to_2d) + TAU/4