class_name SyncController extends Node var module: copyMultiplayer var model_controller: ModelController var peer_id: int var nickname: String var version: int = -1 var bone_lookup: Array[String] var blendshape_lookup: Array[String] var model_name: String var model: Node var skeleton: Skeleton3D # Reusable buffer to write data for synchronizing models. static var write_stream: StreamBuffer = StreamBuffer.with_capacity(2048) # Allows us to use the "apply_animations" function to apply blendshapes to a model. static var BlendShapes: Script = load("res://Mods/MediaPipe/MediaPipeController_BlendShapes.gd") func _ready() -> void: module = get_parent().get_parent() model_controller = get_parent() peer_id = model_controller.name.to_int() 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. module.print_log("%s is now known as '%s'" % [ get_display_name(), new_nickname ]) nickname = new_nickname ## Attempts to change the model of this player. func change_model( new_version: int, new_bone_lookup: Array[String], new_blendshape_lookup: Array[String], 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() 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): return var uncompressed_buffer := buffer.decompress(uncompressed_length, FileAccess.COMPRESSION_ZSTD); var stream := StreamBuffer.from_buffer(uncompressed_buffer) model.transform = stream.read_transform16() # We skipped some bones, so reset the skipped ones to the rest pose. var all_bones := {} for bone_name in module.bone_to_lookup: var bone_idx := skeleton.find_bone(bone_name) var bone_rest := skeleton.get_bone_rest(bone_idx) all_bones[bone_name] = { idx = bone_idx, pose = bone_rest } # Override rest poses with ones from the packet. var num_bones := stream.read_uint8() for i in num_bones: var bone_name := bone_lookup[stream.read_uint8()] all_bones[bone_name].pose = stream.read_bone_pose() # Apply bone poses to skeleton. for bone_name in all_bones: var bone: Dictionary = all_bones[bone_name] skeleton.set_bone_pose(bone.idx, bone.pose) var shape_dict := {} var num_shapes := stream.read_uint8() # 256 blendshapes (and bones) should be enough, right? for i in num_shapes: var shape_name := blendshape_lookup[stream.read_uint8()] var shape_alpha := stream.read_float16() shape_dict[shape_name] = shape_alpha BlendShapes.apply_animations(model, shape_dict) @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 media_pipe = module.get_node("../MediaPipeController") if (not model) or (not skeleton) or (not media_pipe): return write_stream.write_transform16(model.transform) # Pre-filter any bones that are in rest pose. var restless_bones := {} for bone_name in module.bone_to_lookup: 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[bone_name] = bone_pose write_stream.write_uint8(restless_bones.size()) for bone_name in restless_bones: write_stream.write_uint8(module.bone_to_lookup[bone_name]) write_stream.write_bone_pose(restless_bones[bone_name]) # TODO: Only write non-default blendshapes. Anything missing = default. var shape_dict: Dictionary = media_pipe.blend_shape_last_values write_stream.write_uint8(module.blendshape_to_lookup.size()) for shape_name in module.blendshape_to_lookup: var shape_alpha: float = shape_dict[shape_name] write_stream.write_uint8(module.blendshape_to_lookup[shape_name]) write_stream.write_float16(shape_alpha) # 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()