Compare commits

...

4 Commits

Author SHA1 Message Date
copygirl 0cd92b8010 Serialize model transform as 16-bit floats 2 weeks ago
copygirl 9052c473ef Add StreamBuffer functions for "ranges" 2 weeks ago
copygirl 0984148ee5 Add StreamBuffer functions for reading / writing vectors 2 weeks ago
copygirl f8163caee1 Use lookup table instead of strings 2 weeks ago
  1. 49
      copyMultiplayer.gd
  2. 79
      stream_buffer.gd
  3. 109
      sync_controller.gd

@ -11,6 +11,12 @@ var main_controller: ModelController
## Hardcoded list of bone names that will get syncronized.
var tracked_bones: Array[String]
var version: int = -1
var bone_lookup: Array[String] = []
var bone_to_lookup: Dictionary = {}
var blendshape_lookup: Array[String] = []
var blendshape_to_lookup: Dictionary = {}
# Temporary positioning system.
# TODO: Add a setting that allows syncing model positions, as an alternative
# to letting the local player choose where each model is going to appear.
@ -43,6 +49,7 @@ func _ready() -> void:
# FIXME: Hardcoded way to get the main model controller.
main_controller = $"/root/SnekStudio_Main/ModelController"
main_controller.child_entered_tree.connect(on_model_controller_child_added)
on_model_controller_child_added(main_controller.get_node_or_null("Model"))
setup_setting_widget("cache" , "Cache/LineEdit", true )
setup_setting_widget("nickname", "Name/LineEdit" , false)
@ -137,7 +144,7 @@ func on_peer_connected(id: int) -> void:
# (Technically this doesn't need to be relayed through the server, but oh well.)
if nickname: change_nickname.rpc_id(id, nickname)
var filename = main_controller._last_loaded_vrm.get_file()
if filename.is_valid_filename(): change_model.rpc_id(id, filename)
if filename.is_valid_filename(): change_model.rpc_id(id, version, bone_lookup, blendshape_lookup, filename)
update_status()
print_log("%s connected" % sync_controller.get_display_name())
@ -216,11 +223,30 @@ func _process(_delta: float) -> void:
SyncController.send_model_animation(self)
func on_model_controller_child_added(child: Node) -> void:
if child.name != "Model": return
if (child == null) or (child.name != "Model"): return
var skeleton: Skeleton3D = main_controller._get_model_skeleton()
if not skeleton: return # Do nothing, I guess? Unsure if potential bug.
version = (version + 1) % 256
bone_lookup.clear()
bone_to_lookup.clear()
for bone_name in tracked_bones:
bone_lookup.append(bone_name)
var bone_idx := skeleton.find_bone(bone_name)
if bone_idx == -1: continue # Need bone in bone_lookup, but not in bone_to_lookup!
bone_to_lookup[bone_name] = bone_lookup.size() - 1
blendshape_lookup.clear()
blendshape_to_lookup.clear()
# TODO: Redo the blendshapes. Consider grabbing data from animations directly?
# Wait for one frame, then "_last_loaded_vrm" is updated.
await get_tree().process_frame
var filename = main_controller._last_loaded_vrm.get_file()
if filename.is_valid_filename(): change_model.rpc(filename)
change_model.rpc(version, bone_lookup, blendshape_lookup, filename)
## Gets the SyncController for the player with the specified peer id.
@ -236,13 +262,22 @@ func change_nickname(new_nickname: String) -> void:
if controller: controller.change_nickname(new_nickname)
@rpc("any_peer", "reliable")
func change_model(filename: String) -> void:
func change_model(
new_version: int,
new_bone_lookup: Array[String],
new_blendshape_lookup: Array[String],
filename: String,
) -> void:
var peer_id := multiplayer.get_remote_sender_id()
var controller := get_sync_controller(peer_id)
if controller: controller.change_model(filename)
if controller: controller.change_model(new_version, new_bone_lookup, new_blendshape_lookup, filename)
@rpc("any_peer", "unreliable_ordered")
func sync_model_animation(uncompressed_length: int, buffer: PackedByteArray) -> void:
func sync_model_animation(
current_version: int,
uncompressed_length: int,
buffer: PackedByteArray,
) -> void:
var peer_id := multiplayer.get_remote_sender_id()
var controller := get_sync_controller(peer_id)
if controller: controller.sync_model_animation(uncompressed_length, buffer)
if controller: controller.sync_model_animation(current_version, uncompressed_length, buffer)

@ -77,6 +77,16 @@ func write_float16(value: float) -> void: ensure_capacity(2); buffer.encode_half
func write_float32(value: float) -> void: ensure_capacity(4); buffer.encode_float(size, value); size += 4; bit = 0
func write_float64(value: float) -> void: ensure_capacity(8); buffer.encode_double(size, value); size += 8; bit = 0
func write_vector16(value: Vector3) -> void: write_float16(value.x); write_float16(value.y); write_float16(value.z)
func write_vector32(value: Vector3) -> void: write_float32(value.x); write_float32(value.y); write_float32(value.z)
func write_transform16(value: Transform3D) -> void: write_vector16(value.basis.x); write_vector16(value.basis.y); write_vector16(value.basis.z); write_vector16(value.origin)
func write_transform32(value: Transform3D) -> void: write_vector32(value.basis.x); write_vector32(value.basis.y); write_vector32(value.basis.z); write_vector32(value.origin)
func write_range8(value: float) -> void: write_uint8(roundi(value * 255))
func write_range16(value: float) -> void: write_uint16(roundi(value * 65535))
func write_range32(value: float) -> void: write_uint32(roundi(value * 4294967295))
func read_int8() -> int: assert(remaining_bytes() >= 1); var result := buffer.decode_s8(cursor); cursor += 1; bit = 0; return result
func read_int16() -> int: assert(remaining_bytes() >= 2); var result := buffer.decode_s16(cursor); cursor += 2; bit = 0; return result
@ -93,19 +103,24 @@ func read_float16() -> float: assert(remaining_bytes() >= 2); var result := buff
func read_float32() -> float: assert(remaining_bytes() >= 4); var result := buffer.decode_float(cursor); cursor += 4; bit = 0; return result
func read_float64() -> float: assert(remaining_bytes() >= 8); var result := buffer.decode_double(cursor); cursor += 8; bit = 0; return result
func read_vector16() -> Vector3: return Vector3(read_float16(), read_float16(), read_float16())
func read_vector32() -> Vector3: return Vector3(read_float32(), read_float32(), read_float32())
func read_transform16() -> Transform3D: return Transform3D(Basis(read_vector16(), read_vector16(), read_vector16()), read_vector16())
func read_transform32() -> Transform3D: return Transform3D(Basis(read_vector32(), read_vector32(), read_vector32()), read_vector32())
func read_range8() -> float: return float(read_uint8()) / 255
func read_range16() -> float: return float(read_uint16()) / 65535
func read_range32() -> float: return float(read_uint32()) / 4294967295
func write_bit(value: bool) -> void:
if bit == 0:
ensure_capacity(1)
buffer[size] = 0
size += 1
if bit == 0: ensure_capacity(1); buffer[size] = 0; size += 1
buffer[size - 1] = buffer[size - 1] | (int(value) << bit)
bit = (bit + 1) % 8
func read_bit() -> bool:
if bit == 0:
assert(remaining_bytes() >= 1)
cursor += 1
if bit == 0: assert(remaining_bytes() >= 1); cursor += 1
var result := bool((buffer[cursor - 1] >> bit) & 1)
bit = (bit + 1) % 8
return result
@ -138,21 +153,6 @@ func read_string() -> String:
return bytes.get_string_from_utf8()
func write_transform32(value: Transform3D) -> void:
write_float32(value.basis.x.x); write_float32(value.basis.x.y); write_float32(value.basis.x.z)
write_float32(value.basis.y.x); write_float32(value.basis.y.y); write_float32(value.basis.y.z)
write_float32(value.basis.z.x); write_float32(value.basis.z.y); write_float32(value.basis.z.z)
write_float32(value.origin.x); write_float32(value.origin.y); write_float32(value.origin.z)
func read_transform32() -> Transform3D:
var result := Transform3D.IDENTITY
result.basis.x = Vector3(read_float32(), read_float32(), read_float32())
result.basis.y = Vector3(read_float32(), read_float32(), read_float32())
result.basis.z = Vector3(read_float32(), read_float32(), read_float32())
result.origin = Vector3(read_float32(), read_float32(), read_float32())
return result
# Optimized way to write a bone transform, since bones are likely to not contain offset or scale.
func write_bone_pose(value: Transform3D) -> void:
@ -163,26 +163,11 @@ func write_bone_pose(value: Transform3D) -> void:
var has_rot := !rot.is_zero_approx()
var has_scale := !scale.is_equal_approx(Vector3.ONE)
var has_scale3 := !is_equal_approx(scale.x, scale.y) or !is_equal_approx(scale.x, scale.y)
write_bit(has_pos)
write_bit(has_rot)
write_bit(has_scale)
write_bit(has_scale3)
if has_pos:
write_float16(pos.x)
write_float16(pos.y)
write_float16(pos.z)
if has_rot:
# TODO: Could optimize this by using 16-bit fixed-point values.
# Since these values can only be in the range 0 to 1.
write_float16(rot.x)
write_float16(rot.y)
write_float16(rot.z)
if has_scale3:
write_float16(scale.x)
write_float16(scale.y)
write_float16(scale.z)
elif has_scale:
write_float16((scale.x + scale.y + scale.z) / 3)
write_bit(has_pos); write_bit(has_rot); write_bit(has_scale); write_bit(has_scale3)
if has_pos: write_vector16(pos)
if has_rot: write_range16(rot.x / TAU); write_range16(rot.y / TAU); write_range16(rot.z / TAU)
if has_scale3: write_vector16(scale)
elif has_scale: write_float16((scale.x + scale.y + scale.z) / 3)
func read_bone_pose() -> Transform3D:
var pos := Vector3.ZERO
@ -192,11 +177,9 @@ func read_bone_pose() -> Transform3D:
var has_rot := read_bit()
var has_scale := read_bit()
var has_scale3 := read_bit()
if has_pos: pos = Vector3(read_float16(), read_float16(), read_float16())
if has_rot: rot = Vector3(read_float16(), read_float16(), read_float16())
if has_scale3: scale = Vector3(read_float16(), read_float16(), read_float16())
elif has_scale:
var s := read_float16()
scale = Vector3(s, s, s)
if has_pos: pos = read_vector16()
if has_rot: rot = Vector3(read_range16() * TAU, read_range16() * TAU, read_range16() * TAU)
if has_scale3: scale = read_vector16()
elif has_scale: var s := read_float16(); scale = Vector3(s, s, s)
var basis := Basis.from_scale(scale) * Basis.from_euler(rot)
return Transform3D(basis, pos)

@ -6,6 +6,11 @@ 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
@ -32,7 +37,18 @@ func change_nickname(new_nickname: String) -> void:
nickname = new_nickname
## Attempts to change the model of this player.
func change_model(filename: String) -> void:
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
@ -43,34 +59,52 @@ func change_model(filename: String) -> void:
if not model_controller.load_vrm(full_path):
module.print_log("ERROR: Model '%s' could not be loaded!" % filename)
return
module.print_log("%s switched to '%s'" % [ get_display_name(), filename ])
model_name = filename
model = model_controller.get_node_or_null("Model")
skeleton = model_controller._get_model_skeleton()
func sync_model_animation(uncompressed_length: int, buffer: PackedByteArray) -> void:
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_transform32()
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 := {}
# 256 blendshapes (and bones) should be enough, right?
var num_shapes := stream.read_uint8()
var num_shapes := stream.read_uint8() # 256 blendshapes (and bones) should be enough, right?
for i in num_shapes:
var shape_name := stream.read_string()
var shape_alpha := stream.read_float16()
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)
var num_bones := stream.read_uint8()
for i in num_bones:
var bone_name := stream.read_string()
var bone_pose := stream.read_bone_pose()
var bone_idx := skeleton.find_bone(bone_name)
if bone_idx != -1: skeleton.set_bone_pose(bone_idx, bone_pose)
@warning_ignore("shadowed_variable")
static func send_model_animation(module: copyMultiplayer) -> void:
# Check if there's other players we're connected to.
@ -81,34 +115,33 @@ static func send_model_animation(module: copyMultiplayer) -> void:
var media_pipe = module.get_node("../MediaPipeController")
if (not model) or (not skeleton) or (not media_pipe): return
write_stream.write_transform32(model.transform)
write_stream.write_transform16(model.transform)
# TODO: Do not write full strings. Use a lookup table!
# TODO: Only write non-default blendshapes. Anything missing = default.
# 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(shape_dict.size())
for shape_name in shape_dict:
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_string(shape_name)
write_stream.write_uint8(module.blendshape_to_lookup[shape_name])
write_stream.write_float16(shape_alpha)
var bone_poses = {}
for bone_name in module.tracked_bones:
var bone_idx = skeleton.find_bone(bone_name)
if bone_idx == -1: continue
var bone_pose = skeleton.get_bone_pose(bone_idx)
bone_poses[bone_name] = bone_pose
write_stream.write_uint8(bone_poses.size())
for bone_name in bone_poses:
var bone_pose: Transform3D = bone_poses[bone_name]
write_stream.write_string(bone_name)
write_stream.write_bone_pose(bone_pose)
# TODO: Ideally, compression won't be needed once we remove strings.
# The compression still helps, so we'll keep it for now.
var compressed_buffer := write_stream.slice().compress(FileAccess.COMPRESSION_ZSTD);
# DEBUG: Uncomment this to see packet size (ideally < 1024).
# module.set_status("Packet size: %d (%d uncompressed)" % [compressed_buffer.size(), write_stream.size])
module.sync_model_animation.rpc(write_stream.size, compressed_buffer)
# 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()

Loading…
Cancel
Save