In-VR menu: replace chunky prop-knob panel with Godot Control UI in a SubViewport

WorldPanel is now the project's one SubViewport screen: MainMenu (tabs Player /
World / System; Return to spawn, Recalibrate body, Reset props, Ping, click
volume slider, scrollable log, FPS, Close) rendered by MenuScreen into a
960x640 SubViewport on a thin 0.36x0.24 m panel.

Input = touch-first hybrid cursor injected as mouse events: both controller
rays are projected analytically onto the panel plane, converted to viewport
pixels and pushed via Viewport.PushInput (MouseMotion/MouseButton, wheel from
thumbstick). Stylus tip within 6 cm = touch cursor (press crossing 0.6 cm from
the front, release past 1.8 cm); farther = laser cursor with trigger edge. One
owning hand per viewport. Haptic tick + synthesized click on press.

Kept: ax_button summon/dismiss, XROrigin3D parenting, grip drag (direct/laser)
with head re-orient, IsBlocking/IsPanelCollider. Locomotion's beam now takes
its length from WorldPanel.TryGetLaserDistance (no beam while touching) and
starts at the stylus tip. Recalibrate calls Recalibrate() on the first node in
group "calibration" (disabled when absent).

New headless regression scenes/menu_spike.tscn ("MENU: overall PASS").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 05:49:13 +03:00
1 parent 3a990c6dbe
commit 661fe1089a
11 files changed
+1281 -316

No files matched your search

+84 -25
View File
@@ -38,8 +38,11 @@ TERMINOLOGY (owner-corrected — use precisely):
collapse spike takes seconds instead of minutes). Spikes and their pass lines:
`physics_spike` "SPIKE: overall PASS", `weld_spike` "WELD: …", `grab_spike` "GRAB: …",
`body_spike` "BODY: …", `collapse_spike` "COLLAPSE: …", `arm_spike` "ARM: overall PASS",
`sixdof_spike` "6DOF: overall PASS" (run FIRST with `weld_spike` whenever the addon binary changes).
All seven PASS 3× as of this handoff. Headless asset import:
`sixdof_spike` "6DOF: overall PASS" (run FIRST with `weld_spike` whenever the addon binary changes),
`menu_spike` "MENU: overall PASS" (in-VR menu input injection, no XR). All eight PASS as of this handoff.
**Fresh checkout / new worktree: run `godot-mono --headless --import` FIRST** (it may segfault at
exit — harmless) — without `.godot/extension_list.cfg` the Box3D extension silently doesn't load,
Godot falls back to its default physics and weld/grab/arm/6dof spikes report CHECK. Headless asset import:
`godot-mono --headless --import`.
- Engine logs: `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — **read after user
playtests instead of guessing**.
@@ -64,8 +67,11 @@ TERMINOLOGY (owner-corrected — use precisely):
Teleport: aim + trigger edge, fade to black 0.12 s in / 0.25 s out, near markers within
`MarkerNearDistance` (1.2 m) hide fully (disc/beam/label) and are not targetable. Lasers via
`UpdatePointer`: shown only when aiming at something interactive (marker or panel) AND beyond
`LaserMinDistance` (0.35 m). Also builds the stylus pointer tip per controller. `ReturnToSpawn()`
is public (panel Spawn knob calls it).
`LaserMinDistance` (0.35 m); for the menu the beam length comes from
`WorldPanel.TryGetLaserDistance` (beam only while that hand is in LASER mode — a hand touching the
menu shows no beam); the beam starts at the stylus tip (`LaserStart` 0.12 m) and ends at the hit.
Also builds the stylus pointer tip per controller. `ReturnToSpawn()` is public (menu "Return to
spawn" calls it).
- **AnchorMoveMode.cs** — grab-world movement, ANCHOR semantics. One-hand grip moves the player
OPPOSITE to hand motion (full 3D); both grips = anchor translation + in-place yaw turn around the
head. Grip is suppressed while that hand prop-grabs (`PropGrabber.IsGrabbing`) or the panel is
@@ -115,17 +121,55 @@ TERMINOLOGY (owner-corrected — use precisely):
its controller beyond `PhysicsHands.SnapDistance` (2 m) → "grip dragged off" instant break
(heavy props can drag the hand since PhysicsHands' snap is suspended while gripping).
- **PropShop.cs** — builds table (StaticBody3D) + spawns prop table from `Specs[]` (ping pong 2.7 g
→ anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = Reset knob.
- **WorldPanel.cs** — prop-UI panel, PARENTED UNDER `XROrigin3D` (fixed in player's room; revert =
reparent to Main + fix node paths). Summoned/dismissed via `ax_button` (A/X; menu_button belongs
to the runtime). Knobs: Spawn (return to spawn + recall panel in front of head), Ping, Reset
(PropShop.Respawn), Close. Panel/knob bodies: **CollisionLayer 2, CollisionMask 0** (props pass
through); all CollisionShapes disabled while hidden (`SetShapesEnabled`, deferred). Pointer tip is
a 12 cm stylus cone; knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, 400 ms
cooldown) — physical buttons; trigger still presses laser-hovered knobs. Click = code-generated
`AudioStreamWav` PCM (no asset). Draggable direct or via laser at fixed ray distance, re-orients
toward head while gripped. `IsBlocking(controller)` (within 0.45 m) makes Locomotion defer
grab/teleport; `IsPanelCollider(node)` identifies interactive colliders for lasers.
→ anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = menu "Reset props".
- **WorldPanel.cs** — the in-VR MENU (2026-10-02; replaced the chunky 3D knob panel). XR glue only:
PARENTED UNDER `XROrigin3D` (fixed in player's room; revert = reparent to Main + fix node paths).
Summoned/dismissed via `ax_button` (A/X; menu_button belongs to the runtime), appears
`SummonDistance` 0.3 m in front of the summoning hand facing the head; grip-drag direct (hand
within `DirectGrabRadius` 0.45 m) or along the laser at fixed ray distance, re-orients toward the
head while gripped; "Return to spawn" recalls it `HeadRecallDistance` 0.45 m ahead / 0.12 m below
the head. Feeds both controllers (ray = `-Basis.Z`, `trigger`, `primary` stick; a hand dragging
the panel doesn't point) into MenuScreen every frame, wires MainMenu events (deferred) to
`Locomotion.ReturnToSpawn`, `PropShop.Respawn`, ping tone, close; synthesized click + ping
(`AudioStreamWav` PCM, no asset), haptic tick (`"haptic"` action) on press. `IsBlocking(controller)`
(hand within 0.45 m of the shown panel) makes Locomotion/AnchorMoveMode defer grab/teleport;
`IsPanelCollider(node)` = the screen's StaticBody (**layer 2, mask 0**, props pass through; shapes
disabled while hidden); `TryGetLaserDistance(controller)` drives the laser beam.
- **MenuScreen.cs** — generic "Control UI on a 3D panel" + the touch-first hybrid cursor,
input-agnostic (spike drives it without XR). SubViewport 960×640 (`Disable3D`, MSAA 2D 4×,
update Always while shown / Disabled while hidden) on an explicit-UV quad 0.36×0.24 m (unshaded,
linear filter — viewport textures have no mipmaps, resolution chosen ≈ 1:1 with headset pixels at
~45 cm) + backing frame + StaticBody. Per hand, in panel-local space (analytic plane math, no
raycast): TOUCH if the stylus tip (`TipLength` 0.12 m ahead) is over the panel (+1.5 cm edge
margin) between `HoverDepth` 6 cm in front and `PushThroughDepth` 8 cm behind, controller in
front — press when the tip crosses `ContactDepth` 0.6 cm FROM ABOVE (armed only by hovering in
front: sideways/behind entry never presses), release past `ReleaseDepth` 1.8 cm; else LASER if
the ray hits the panel from the front — trigger rising edge presses, release on trigger up
(trigger state tracked even while hidden/reset, so a held trigger never clicks). One mouse per
viewport → one OWNER hand: the hand holding a press keeps it; else the touching hand (smallest
depth); else the previous laser owner; else any laser hand. Owner change / leaving the panel
releases the button at the last position (Godot then cancels the click, like dragging off a
button). Injection: `Viewport.PushInput(InputEventMouseMotion{Position, Relative, ButtonMask},
true)` on cursor move, `InputEventMouseButton{Left}` on press/release, `NotifyMouseEntered/Exited`
on owner gain/loss; owner's stick Y beyond 0.3 → wheel notches at 14/s (`WheelUp/Down`, press +
release). Cursor dot per hand (touch: blue, shrinks as the tip nears; laser: orange; non-owner
half-transparent). Buttons fire on release (Godot default) = tap semantics.
- **MainMenu.cs** — the menu content (`PanelContainer`, built in code, own `Theme`: 26 px font,
64 px buttons ≈ 24 mm, round 36 px slider grabber, wide scrollbar, hover/pressed styles, no focus
boxes). Header: title, FPS (4 Hz), Close. Tabs: **Player** (Return to spawn, Recalibrate body),
**World** (Reset props, Ping sound), **System** (click volume slider, Close menu, scrollable log).
**Calibration contract**: "Recalibrate body" calls `Call("Recalibrate")` on
`GetTree().GetFirstNodeInGroup("calibration")` if it `HasMethod("Recalibrate")`; otherwise the
button is disabled with "Not available" (re-checked on summon and at 4 Hz). The menu never creates
that node. Button actions are C# events invoked via `CallDeferred` (never from inside the
injected input event). `Log(line)` appends to the System log (last 40).
- **MenuSpike.cs** + `scenes/menu_spike.tscn` — headless menu regression without XR: MenuScreen +
MainMenu on a tilted panel; asserts pixel mapping round trip, touch path (hover, contact press,
Return-to-spawn fires once while the other hand idles as a laser = arbitration), side entry
behind the surface does NOT press, laser + trigger (tab switch, Reset props, laser distance),
held trigger arriving does NOT click, touch tab switch, laser slider drag, thumbstick scroll of
the log, Recalibrate disabled without / enabled + called with a stub "calibration" node, header
Close by touch. Must print "MENU: overall PASS".
- **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group
"teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors.
- **PhysicsTorso.cs** — the player's PHYSICAL CHEST (body-physics Stage 1). Implemented
@@ -227,7 +271,7 @@ plan-B if the patched-wrapper path is ever abandoned (owner pointed at it; adopt
leaving the standard RigidBody3D stack).
Scene wiring (`scenes/main.tscn`): Main → WorldEnvironment, Sun, Floor, XROrigin3D (XRCamera3D,
LeftHand, RightHand, WorldPanel), SpawnPoint, 3 TeleportMarkers, Locomotion, AnchorMoveMode,
LeftHand, RightHand, WorldPanel — builds its MenuScreen/SubViewport/MainMenu children in code), SpawnPoint, 3 TeleportMarkers, Locomotion, AnchorMoveMode,
PropShop, PropGrabber, PhysicsHands, PhysicsTorso, PhysicsArms, BodyRig, FadeLayer/Fade
(physics order matters: hands → chest → arms, so the arm IK reads this tick's bodies). Action map: `openxr_action_map.tres`.
Floor StaticBody mask 1|3|4|5 (collision_mask 29).
@@ -254,7 +298,7 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
patch, and rebuild recipe). Pin/hinge/slider paths untouched — grab v5.2.1 remains runnable
as fallback (git history). Stink-O's box3d-godot (custom components, more joint types) is
the plan-B.
- **Collision layers**: 1 = world, 2 = UI. Panel/knob bodies are layer 2 mask 0 (props pass through
- **Collision layers**: 1 = world, 2 = UI. The menu panel body is layer 2 mask 0 (props pass through
the menu); **1 = world+props, 2 = UI, 3 = player body (chest), 4 = player hands, 5 = player arms** —
no self-collision between any own body parts (props/table/floor mask 1|3|4|5, hands 4/1|4,
chest 3/1|3, arm segments 5/1). Layers/masks live on `CollisionObject3D`, never `CollisionShape3D`. The
@@ -318,8 +362,15 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
about the locomotion decision; don't over-invest in anchor-loco until that's decided.
- **Teleport + lasers are BROKEN** — owner report; both were experiments anyway and are
NOT current issues. Locomotion.cs's marker/laser machinery may be resurrected or deleted
when the locomotion direction is decided; WorldPanel's laser-hover knobs still reference
the laser system, so a deletion needs that decoupling first.
when the locomotion direction is decided. The menu's laser cursor itself lives in MenuScreen
(analytic, no raycast); only the drawn BEAM is in `Locomotion.UpdatePointer` — move the beam
drawing along if that file is deleted.
- **In-VR menu (MenuScreen/MainMenu, 2026-10-02) — awaiting owner playtest.** Things to check
in-headset: text sharpness/shimmer at normal distance (no mipmaps — if it shimmers when far,
lower the resolution or try `SubViewportCompositionLayer`; if blurry up close, raise
`SizePixels`), touch press depth/feel (`ContactDepth`/`ReleaseDepth`), accidental presses when
reaching through the panel, laser/touch hand-over near the panel, thumbstick scroll direction,
the stylus tip vs the drawn cursor dot alignment, and whether 0.3 m summon distance is in reach.
- **Locomotion direction undecided** — owner's lean: full physics thumbstick movement if
the body track goes the Nimso way (stick pushes the physics body; sway/jog detection
class of solutions). Anchor-grab locomotion was a scene-viewer-era design; the fix-or-
@@ -396,8 +447,15 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
| `LaserMinDistance` | Locomotion.cs | 0.35 m | laser hidden nearer than this (tip touch instead) |
| `MarkerNearDistance` | Locomotion.cs | 1.2 m | near markers hide + become untargetable |
| Fade times | Locomotion.cs | 0.12 s in / 0.25 s out | teleport fade |
| `PressContactDistance` / `PressCooldownMs` | WorldPanel.cs | 0.045 m / 400 ms | knob press on contact |
| `DirectGrabRadius` / `TipHoverRadius` / `TipLength` | WorldPanel.cs | 0.45 / 0.07 / 0.12 m | panel grab / tip hover / stylus reach |
| `LaserStart` | Locomotion.cs | 0.12 m | beam starts at the stylus tip; beam length = hit distance − this |
| `DirectGrabRadius` | WorldPanel.cs | 0.45 m | direct grip-drag range + `IsBlocking` range |
| `SummonDistance` / `HeadRecallDistance` / `HeadRecallDrop` | WorldPanel.cs | 0.3 / 0.45 / 0.12 m | menu placement on summon (ahead of hand) / on Return to spawn (ahead of + below head) |
| `SizeMeters` / `SizePixels` | MenuScreen.cs | 0.36×0.24 m / 960×640 | menu panel size / SubViewport resolution (~2.7 px/mm) |
| `TipLength` | MenuScreen.cs | 0.12 m | stylus tip ahead of the controller = touch cursor |
| `HoverDepth` / `PushThroughDepth` / `TouchEdgeMargin` | MenuScreen.cs | 0.06 / 0.08 / 0.015 m | touch-cursor zone in front of / behind / around the panel |
| `ContactDepth` / `ReleaseDepth` | MenuScreen.cs | 0.006 / 0.018 m | touch press / release hysteresis |
| `ScrollDeadzone` / `ScrollNotchesPerSecond` | MenuScreen.cs | 0.3 / 14 | thumbstick → wheel scrolling |
| `FontSize` / `ButtonHeight` | MainMenu.cs | 26 px / 64 px | menu text and button size |
| `MaximumRefreshRate` | Main.cs | 90 | physics tick = chosen refresh rate |
## 7. Next steps (in order)
@@ -408,8 +466,8 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
2-anchor based), simultaneous two-hand movement acts weird, teleport + lasers broken
(experiments, not current issues). Owner's candidate replacement: physics thumbstick
movement if the body track lands. Fix-or-replace rides on item 1's outcome; teleport/
laser machinery gets resurrected-or-deleted with it (WorldPanel's laser-hover knobs
must decouple first if deleted).
laser machinery gets resurrected-or-deleted with it (the menu's laser cursor is
self-contained in MenuScreen; only its drawn beam lives in `Locomotion.UpdatePointer`).
3. **Grab points on special objects** — grab is palm-proximity to the prop origin today;
special objects may want authored grab anchors (see `research/vr-grabbing.md` taxonomy).
4. **Body Stage 2b (feel-driven)** — after the owner feels the 2a arms: couple obstruction
@@ -420,8 +478,9 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
6. **Neck/head look system** — `LookAtModifier3D` head→neck distribution (needs a real skeleton).
7. **EMBODYING** — view-follow + avatar remapping (chest calibration transform); experiments only
after head/neck look override exists. Theory until proven.
8. **UI milestone** — deliberate full action-map pass + SubViewport/composition-layer decision for
dense screens (at most ONE such screen; everything else stays props).
8. **UI milestone** — the ONE SubViewport screen now exists (in-VR menu, `MenuScreen`); remaining:
owner playtest of it, deliberate full action-map pass, fingertip (hand tracking) feeding the same
`MenuScreen.PointerInput` as the stylus, `SubViewportCompositionLayer` only if sharpness demands.
## 8. Working agreements