feat: third-person camera toggle (V) + fix first-person showing inside the model

The first-person head-shrink (SkeletonModifier3D) left the camera looking at
the inside of the neck/torso/hair. Replaced it: the owner's model now renders
shadows-only in first person (clean FPS view, still fully animated and visible
to others and in shadows).

Added a third-person toggle (V / toggle_camera_view) so you can actually see
your own animated model — the easiest way to verify a new skin's animations:
- over-the-shoulder SpringArm3D camera (wall-collision aware) on the local
  player; press V to swap, press again to return
- firing still uses the first-person camera, so aim is unchanged
- reveals the owner's model via set_owner_visible() on both SkinnedPlayerModel
  and HumanoidModel; hides the first-person weapon viewmodel while in TP
- death forces first person so the toggle can't fight the death cam

Smoke test extended to cover the toggle (camera boom created, becomes current,
round-trips) — 28/28 checks pass for both color and GLB skins.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-06 17:18:16 -04:00
co-authored by Claude Fable 5
parent 64bbbf93c6
commit e2fbc424a7
6 changed files with 124 additions and 45 deletions
+13
View File
@@ -197,6 +197,19 @@ func _set_procedural_visible(on: bool) -> void:
if part:
part.visible = on
## Show/hide the model to its OWNER (third-person toggle). off = shadows-only so
## the local first-person camera doesn't see the body; on = fully visible.
func set_owner_visible(on: bool) -> void:
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_owner_shadow_recursive(self, mode)
func _set_owner_shadow_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_owner_shadow_recursive(child, mode)
func _apply_color(col: Color) -> void:
var mat = StandardMaterial3D.new()
mat.albedo_color = col
+23 -39
View File
@@ -9,9 +9,12 @@ class_name SkinnedPlayerModel
## - set_weapon(script_path) — third-person weapon in hand
## - shadows_only — legacy local-player mode
##
## View modes:
## - first_person_mode = true → full body visible to the OWNER, head hidden
## (shrunk via a SkeletonModifier3D so hair/face follow), fully animated.
## View modes (for the LOCAL player only):
## - first_person_mode = true → model renders shadows-only for the owner, so
## the camera (which sits inside the head) never shows the inside of the
## mesh. Still fully animated; still visible to other players and in shadows.
## - Press the third-person toggle → set_owner_visible(true) makes the full
## animated model visible to the owner too (over-the-shoulder camera).
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@@ -54,7 +57,6 @@ var loaded: bool = false
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D
var _head_hider: SkeletonModifier3D
var is_holding_weapon: bool = false
@@ -91,10 +93,11 @@ func load_model(path: String) -> void:
else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
if shadows_only:
# The local owner renders shadows-only (the camera is inside the head, so
# showing the mesh would show the inside of it). Other players see it fully.
# The third-person toggle calls set_owner_visible(true) to reveal it.
if shadows_only or first_person_mode:
_set_shadows_recursive(self)
if first_person_mode:
_setup_first_person()
loaded = true
_play_clip("Idle")
@@ -132,40 +135,21 @@ func _find_clip(available: PackedStringArray, wanted: String) -> String:
# ── View modes ────────────────────────────────────────────────────────────────
func _setup_first_person() -> void:
if not skeleton:
return
var head_idx := _find_bone(["Head"])
if head_idx < 0:
# No head bone — fall back to shadows-only so the local player at least
# isn't staring at the inside of a face.
_set_shadows_recursive(self)
return
_head_hider = HeadHider.new()
_head_hider.name = "HeadHider"
_head_hider.head_bone = head_idx
skeleton.add_child(_head_hider)
## Show or hide the model to its OWNER. In first person we render shadows-only
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
## toggle calls this with on=true to reveal the full animated model. Either way
## the model keeps casting shadows and stays visible to other players.
func set_owner_visible(on: bool) -> void:
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_shadow_mode_recursive(self, mode)
func set_first_person(enabled: bool) -> void:
first_person_mode = enabled
if enabled and not _head_hider:
_setup_first_person()
elif not enabled and _head_hider:
_head_hider.queue_free()
_head_hider = null
## Shrinks the head bone after each animation update so the owner's camera
## never sees their own face/hair, while shadows and other players see the
## full head (the modifier only runs on this local instance).
class HeadHider extends SkeletonModifier3D:
var head_bone: int = -1
func _process_modification() -> void:
var skel := get_skeleton()
if skel and head_bone >= 0:
skel.set_bone_pose_scale(head_bone, Vector3(0.001, 0.001, 0.001))
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_shadow_mode_recursive(child, mode)
# ── Animation state ───────────────────────────────────────────────────────────
+13
View File
@@ -87,6 +87,19 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
skinned.update_state(state, 9.0, false)
await process_frame
_check(true, "state cycling did not crash")
# Third-person toggle: camera swap + owner model visibility.
if player.has_method("set_third_person"):
var tp_cam = player.get_node_or_null("HeadPivot/ThirdPersonBoom/ThirdPersonCamera")
_check(tp_cam != null, "third-person camera boom created")
player.set_third_person(true)
await process_frame
_check(player.third_person, "toggled into third person")
if tp_cam:
_check(tp_cam.current, "third-person camera is current when toggled on")
player.set_third_person(false)
await process_frame
_check(not player.third_person, "toggled back to first person")
else:
var humanoid = player.get_node_or_null("HumanoidModel")
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
+10 -5
View File
@@ -100,11 +100,16 @@ animates in every movement state.
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
names, sets loop modes, blends between clips (0.15 s), and scales
locomotion playback speed to actual movement speed.
- **First person (owner):** full body visible and animated; the head bone is
shrunk via a `SkeletonModifier3D` so you never see your own face — you DO
see your legs, arms and shadow.
- **Third person (everyone else):** the full model, driven by the synced
movement state, with the current weapon attached to the right-hand bone.
- **First person (owner):** the model renders shadows-only for the owner (the
camera sits inside the head, so drawing the mesh would show its inside). It's
still fully animated and casts a real shadow; other players see the full body.
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
over-the-shoulder `SpringArm3D` camera and reveal your own animated model —
the easiest way to eyeball that a new skin's animations look right. Firing
still uses the first-person camera, so aim is unchanged. Press V again to
return to first person.
- **Other players** always see the full third-person model, driven by the
synced movement state, with the current weapon attached to the hand bone.
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
CC-BY models require crediting the author — keep these files and surface
them in your credits screen before shipping.
+60 -1
View File
@@ -11,6 +11,12 @@ var camera: Camera3D = null
var flashlight: SpotLight3D = null
var _damage_layer: CanvasLayer = null
# Third-person view toggle (local player only). The first-person camera keeps
# handling aim/firing; the toggle only changes which camera renders and whether
# the owner sees their own model.
var third_person: bool = false
var _tp_camera: Camera3D = null
# Health and Shield
var max_health: float = 100.0
var health: float = 100.0
@@ -132,10 +138,56 @@ func _ready() -> void:
if skin_mgr:
synced_skin_id = skin_mgr.active_skin_id
synced_position = position
_setup_third_person_camera()
# Build the visual model for whatever skin is selected (remote peers get
# the id via the synchronizer and rebuild in _process when it arrives).
_apply_skin_model(synced_skin_id)
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
## clip through walls. The first-person camera still exists and handles firing;
## this one only renders when third_person is on.
func _setup_third_person_camera() -> void:
if not head_pivot or not is_instance_valid(camera):
return
var boom := SpringArm3D.new()
boom.name = "ThirdPersonBoom"
boom.spring_length = 3.2
boom.margin = 0.3
boom.collision_mask = 1 # environment only
boom.add_excluded_object(get_rid())
# Aim the arm up-and-back from the head so the camera sits behind/above.
boom.rotation_degrees = Vector3(20, 0, 0)
head_pivot.add_child(boom)
_tp_camera = Camera3D.new()
_tp_camera.name = "ThirdPersonCamera"
_tp_camera.fov = camera.fov
_tp_camera.current = false
boom.add_child(_tp_camera)
## Toggle between first- and third-person. Firing keeps using the first-person
## camera, so aim is unchanged; only rendering and self-visibility change.
func set_third_person(on: bool) -> void:
if not is_multiplayer_authority():
return
third_person = on
if is_instance_valid(_tp_camera):
_tp_camera.current = on
if is_instance_valid(camera):
camera.current = not on
# Reveal / hide the owner's own model.
var visual = get_visual_model()
if visual and visual.has_method("set_owner_visible"):
visual.set_owner_visible(on)
# Hide the first-person weapon viewmodel in third person (it would float in
# the middle of the screen); the third-person weapon on the model shows.
if is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
wman.canvas_layer.visible = not on
## Returns the node that visually represents this player (skinned GLB model
## if the active skin has one, otherwise the procedural HumanoidModel).
func get_visual_model() -> Node3D:
@@ -760,6 +812,9 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
var machine := _ensure_machine()
if machine:
machine.input_dir = raw_input
@@ -1026,7 +1081,11 @@ func _setup_hud() -> void:
func die(impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: return
is_dead = true
# Return to first person so the toggle state doesn't fight the death cam.
if is_multiplayer_authority() and third_person:
set_third_person(false)
# Disable movement state machine inputs
if _machine:
_machine.process_mode = Node.PROCESS_MODE_DISABLED
+5
View File
@@ -108,6 +108,11 @@ scoreboard={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
toggle_camera_view={
"deadzone": 0.0,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]