diff --git a/README.md b/README.md index 199d356..4886f3d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ work around quirks of the Frame's [two graphical sessions](#two-sessions) (portal, KDE app menu, keyboard layout, clipboard, KDE wallet, Firefox), enable hardware video decoding in Jellyfin, run rootless Docker, and extend Steam's and SteamVR's UIs at runtime (VR keyboard, "+" menu, dashboard -windows, Steam close button, window curvature, window controls). +windows, Steam close button, window curvature, window controls, a VR pet). Everything is declarative: files are links into the Nix store, UI patches live in memory. The few things that have to be written elsewhere at runtime @@ -48,6 +48,7 @@ configuration, limitations and how it works. - [Steam close button](docs/steam-close-button.md) (`dashboard.steamCloseButton`): an X that hides the Steam window. - [Window curvature](docs/window-curvature.md) (`dashboard.windowCurvature`): adjustable curvature per window. - [Window control bar](docs/window-control-bar.md) (`dashboard.frameControls`): move controls between bar and three-dot menu. +- [VR pet](docs/pet.md) (`pet`): a cat (five coats), Shiba Inu, Fox or Dachshund in the scene that walks around you, can be picked up and petted; "Pet" in the "+" menu, the `vr-pet` command, your own models. - [SteamVR debugger](docs/steamvr-debugger.md) (`steamvrDebugger`, automatic): SteamVR's DevTools port for these, set only while SteamVR runs. **Apps:** @@ -194,6 +195,7 @@ these two files ([`template/`](template), with more comments): windowCurvature.enable = true; frameControls.enable = true; }; + pet.enable = true; # VR pet (bakes ~0.5 GB of models) firefox.enable = true; firefox.disableAv1 = true; firefox.defaultBrowser = true; @@ -217,7 +219,7 @@ home-manager switch --flake .#steamos # manual setup, from the flake's director In your own flake, add the input as above and `steam-frame-nix.homeManagerModules.default` to the modules. `default` imports all modules; single ones: -`homeManagerModules.{session,portal,applications-menu,keyboard-layout,vr-keyboard-extra-keys,vr-keyboard,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,cleanup,dashboard-windows,steam-close-button,window-curvature,frame-controls,clipboard-sync,firefox,jellyfin,launchers,docker,screenshots}` +`homeManagerModules.{session,portal,applications-menu,keyboard-layout,vr-keyboard-extra-keys,vr-keyboard,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,cleanup,dashboard-windows,steam-close-button,window-curvature,frame-controls,clipboard-sync,firefox,jellyfin,launchers,docker,screenshots,pet}` (`steam-keyboard-patch` and `keyring` still work as the former names of `vr-keyboard-extra-keys` and `launchers`). Every module imports `cleanup` (see [Changes outside Nix](#changes-outside-nix-exceptions)); which file is @@ -303,6 +305,13 @@ which: [Repository layout](docs/development.md). | `steamFrame.dashboard.frameControls.inBar` | list of control names | `[ ]` | Controls that start in the bar: `keyboard`, `float`, `dashboard`, `theater`, `dockLeft`, `dockRight`, `close`, `curvature`, `"icon:"`. | | `steamFrame.dashboard.frameControls.inMenu` | list of control names | `[ ]` | Controls that start in the three-dot menu. | | `steamFrame.dashboard.frameControls.floatInTheater` | bool | `false` | "Float" control on theater windows. | +| `steamFrame.pet.enable` | bool | `false` | A 3D pet in SteamVR's scene, the `vr-pet` command and "Pet" in the "+" menu, see [VR pet](docs/pet.md); the first switch bakes its models (~0.5 GB in the store). | +| `steamFrame.pet.defaultModel` | null or str | `null` | Model id shown until another is picked; `null`: the spec's `"default": true` (the Ginger cat). | +| `steamFrame.pet.extraModels` | attrs of path or attrs | `{ }` | Your own models by id: a folder with a `model.json`, or the spec as an attrset (files as paths), see [VR pet models](docs/pet-models.md). | +| `steamFrame.pet.options` | attrs | `{ }` | Behaviour options of `modules/pet/core.js` (`DEFAULTS` there), e.g. `{ walkSpeed = 0.3; follow = 3; }`. | +| `steamFrame.pet.fps` | int, 4-60 | `24` | Animation frames per second (baked frames and scene graph updates): smoother, but more frames loaded in vrcompositor. | +| `steamFrame.pet.mount` | `"dynamic"`, `"all"` | `"dynamic"` | Baked frames kept mounted in the scene graph: the clips needed now or next, or all of them. | +| `steamFrame.pet.debug.demo` | bool | `false` | Demo tour: cycles through all poses and activities. | | `steamFrame.steamvrDebugger.enable` | bool | automatic | SteamVR dashboard DevTools on `127.0.0.1:8087` (set only while SteamVR runs); on when a dashboard patch is, see [SteamVR debugger](docs/steamvr-debugger.md). | | `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. | | `steamFrame.clipboardSync.package` | package | built from `dnut/clipboard-sync` | The clipboard-sync package. | @@ -371,11 +380,12 @@ lives in memory (the UI patches). These are written at runtime: | `VRWebHelper.DebuggerEnabled` in `~/.config/openvr/config/steamvr.vrsettings` | [SteamVR debugger](docs/steamvr-debugger.md) | only while SteamVR runs | SteamVR stopping (runtime drop-in below); `steam-frame-nix-cleanup` while SteamVR is stopped | | `~/.local/state/steam-frame-nix/steamvr-debugger.armed` | SteamVR debugger: the key's previous value | while SteamVR runs; after a power loss until the next SteamVR start or cleanup | SteamVR stopping; `steam-frame-nix-cleanup` | | `/run/user/1000/systemd/user/steamvr.service.d/50-steam-frame-nix-debugger.conf`, `/run/user/1000/steam-frame-nix/steamvr-debugger-restore` | SteamVR debugger: puts the key back when SteamVR stops, without Nix | until reboot (tmpfs) | reboot; `steam-frame-nix-cleanup` while SteamVR is stopped and the debugger is off | -| `~/.local/state/steam-frame-nix/ui-patches/.json` | Saved choices of dashboard patches ([persistent state](docs/ui-patches.md#persistent-state)): window control bar placements (`frame-controls`), "Steam hidden" (`steam-close-button`). SteamOS's `steamvr.service` deletes `~/.cache/SteamVR` (the dashboard's own browser storage) on every SteamVR start. | until removed: kept when a patch is disabled (the choices come back when you enable it again) | `steam-frame-nix-cleanup --all`, `install.sh uninstall` | +| `~/.local/state/steam-frame-nix/ui-patches/.json` | Saved choices of dashboard patches ([persistent state](docs/ui-patches.md#persistent-state)): window control bar placements (`frame-controls`), "Steam hidden" (`steam-close-button`), the VR pet's spot, pose, model and whether it is hidden (`vr-pet`). SteamOS's `steamvr.service` deletes `~/.cache/SteamVR` (the dashboard's own browser storage) on every SteamVR start. | until removed: kept when a patch is disabled (the choices come back when you enable it again) | `steam-frame-nix-cleanup --all`, `install.sh uninstall` | | `/run/user/1000/steam-frame-nix/applications/.desktop` (with `..desktop.sum`, `.lock`) | [Launchers](docs/launchers.md) of Flatpaks and host files; `~/.local/share/applications/.desktop` is a Home Manager link to it | until reboot (tmpfs), written again at login | reboot; the next switch or rewrite when the launcher or its app is gone; `steam-frame-nix-cleanup --all` | | `/run/user/1000/steam-frame-nix/screenshots` | [SteamVR screenshots](docs/screenshots.md#how-it-works) without `steamUserId`: link to the current account's folder; `~/Pictures/` is a Home Manager link to it | until reboot (tmpfs), written again at login | reboot; `steam-frame-nix-cleanup` once unused | +| `/run/user/1000/steam-frame-nix/vr-pet/icon.png` (with `.lock`) | [VR pet](docs/pet.md#how-it-works): link to the current model's "+" menu icon; `~/.local/share/icons/hicolor/256x256/apps/vr-pet.png` is a Home Manager link to it | until reboot (tmpfs), written again at login | reboot; `steam-frame-nix-cleanup` once unused | | mtime of `~/.local/share/applications` | [Launchers](docs/launchers.md#how-it-works): a running Steam rescans the "+" menu | only the directory's timestamp | nothing to remove | -| mtime of `~/.local/share/icons/hicolor` | [icon fallbacks](docs/launcher-menu.md#icon-fallbacks): a running Steam rescans icons | only the directory's timestamp | nothing to remove | +| mtime of `~/.local/share/icons/hicolor` | [icon fallbacks](docs/launcher-menu.md#icon-fallbacks), [VR pet](docs/pet.md#how-it-works) icon: a running Steam rescans icons | only the directory's timestamp | nothing to remove | **`steam-frame-nix-cleanup`** (`install.sh cleanup`, `steamFrame.cleanup.package`) knows everything any version of @@ -478,6 +488,44 @@ steam-frame-nix and switch: its activation runs `cleanup --all` while Home Manager removes its files. (`home-manager uninstall` alone doesn't load steam-frame-nix's modules, so it can't clean up after them.) +## Credits + +The [VR pet](docs/pet.md)'s models are not in this repository: Nix fetches +them at build time from the URLs pinned (with their hashes) in +`modules/pet/package.nix` and `modules/pet/models//model.json`, and +bakes them into the store. + +- **Toon Cat FREE** by [Omabuarts Studio](https://sketchfab.com/omabuarts) + ([model](https://sketchfab.com/3d-models/toon-cat-free-b2bd1ee7858444bda366110a2d960386)), + [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/): the cat (mesh, + texture, rig, walk) and its coats. Modified: recoloured coats (Tuxedo, + Blue, Cream, Snow), retargeted and hand-keyed animations, baked to one OBJ + per frame. Fetched from a third-party GitHub mirror + ([DevTakao/threejs-cat](https://github.com/DevTakao/threejs-cat), pinned + commit and hash). +- **Tuxedo Cat Animated 2.0** by [DreamNoms](https://sketchfab.com/DreamNoms) + ([model](https://sketchfab.com/3d-models/tuxedo-cat-animated-20-783fcb78b55b4394a212c2b6392e1113)), + [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/): only its + SitDown, IdleSit and StandUp clips, retargeted onto the Toon Cat and + baked. Fetched from a third-party GitHub mirror + ([xialin-he/xialin-he.github.io](https://github.com/xialin-he/xialin-he.github.io), + pinned commit and hash). +- **Shiba Inu** and **Fox** from the + [Ultimate Animated Animal Pack](https://quaternius.com/packs/ultimateanimatedanimals.html) + by [Quaternius](https://quaternius.com), + [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) (credited + anyway). Modified: clips sampled per frame, sit, lie, sleep and the + held-by-the-scruff pose hand-keyed, a wagging tail and breathing added, + material colours turned into a texture. Fetched from the Poly Pizza + mirror ([Shiba Inu](https://poly.pizza/m/y4wdQpg767), + [Fox](https://poly.pizza/m/Bc97C66HKi), pinned hashes). +- **Dachshund:** the Quaternius Shiba Inu (CC0 1.0), reshaped (longer back + and ears, shorter legs) and recoloured black and tan. +- **three.js** (desktop preview only), [MIT](https://github.com/mrdoob/three.js/blob/dev/LICENSE), + fetched from npm. + +The baked cat's store output carries a short `CREDITS.md` pointing here. + ## License [Apache License 2.0](LICENSE). diff --git a/docs/cleanup.md b/docs/cleanup.md index 04e9ed2..7b0cc68 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -27,6 +27,7 @@ is reported as "left alone" and never touched: | `ui-state`: `~/.local/state/steam-frame-nix/ui-patches/.json` | the dashboard patches' saved choices; `--all` only, never `--orphans`; stray `*.json.tmp` files | | `launchers`: `/run/user/1000/steam-frame-nix/applications` | the entries of [launchers](launchers.md) (tmpfs); `--all` only (switches remove those of removed launchers themselves) | | `screenshots`: `/run/user/1000/steam-frame-nix/screenshots` | a link to `*/userdata/*/760/remote/250820/screenshots` ([SteamVR screenshots](screenshots.md)); kept by `--orphans --keep screenshots` (while `steamUserId` is unset) | +| `pet`: `/run/user/1000/steam-frame-nix/vr-pet` | `icon.png`, a link to `*-vr-pet-icons/*.png` in the store ([VR pet](pet.md#how-it-works)), its empty `.lock` and stray `.icon.tmp.*` links, then the directory if empty; kept by `--orphans --keep pet` (while `pet.enable`) | | `dirs` | `~/.local/state/steam-frame-nix` and `/run/user/1000/steam-frame-nix` when empty | ### Left by older versions diff --git a/docs/development.md b/docs/development.md index 1276c43..a9e3a21 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,7 +35,7 @@ Where things run: ## Repository layout ```text -flake.nix homeManagerModules, packages/apps (cleanup), checks, template +flake.nix homeManagerModules, packages/apps (cleanup, pet-*), checks, template install.sh install / uninstall / cleanup (curl | bash; also the steam-frame-nix-cleanup package and the debugger arm step) template/ `nix flake init -t` / installer config: flake.nix, home.nix @@ -129,6 +129,19 @@ modules/ steam-close-button.nix, steam-close-button/ SteamVR: X on the Steam window window-curvature.nix, window-curvature/ SteamVR: curvature wheel frame-controls.nix, frame-controls/ SteamVR: window control bar + pet.nix the VR pet (pet): patch, CLI, "+" menu entry, icon units + pet/ + package.nix build: bakes, catalog, patch, CLI, icons, preview, tests + core.js SteamVR+preview+test (in the patch): behaviour, interaction + systemui.js, unpatch.js SteamVR: draws the pet in the scene graph, grip bar, menu + cli.mjs app: `vr-pet` over DevTools (also the "+" menu entry) + icon.sh service steam-frame-nix-pet-icon (+ .path), switch: + /steam-frame-nix/vr-pet/icon.png -> model icon + bake.py, bake_gltf.py, rig.py build: the cat's / a glTF animal's frames (OBJ per frame) + index.py, skin.py, thumb.py build: catalog (models.json), coats, thumbnails, icons + models//model.json build: the built-in models (spec: docs/pet-models.md) + preview/ dev: desktop preview (`nix run .#pet-preview`) + check.nix, tests/ test: core, adapter, CLI, models, icons, private-model guard ``` ## Runtime names @@ -151,6 +164,7 @@ they are kept even where a file name says more: | `steamvr-debugger` | `steamvrDebugger` | | `steamvr-webhelper-debugger` | | `launchers` | `launchers` | | `steam-frame-nix-launchers` (`.service`, `.path`) | | `screenshots` | `screenshots` | | `steam-frame-nix-screenshots` (`.service`, `.path`) | +| `pet` | `pet` | `vr-pet` (SteamVR, state) | `steam-ui-patches`, `steam-frame-nix-pet-icon` (`.service`, `.path`) | | `docker` | `docker` | | `docker` | Log of all patches: diff --git a/docs/pet-models.md b/docs/pet-models.md new file mode 100644 index 0000000..f402af3 --- /dev/null +++ b/docs/pet-models.md @@ -0,0 +1,254 @@ +# VR pet models + +How to add a model to the [VR pet](pet.md): the spec's one description (the +scripts in `modules/pet/` point here). Credits of the built-in models: +[README, Credits](../README.md#credits). + +Every model is a folder with a `model.json` (its spec) and any files the +spec names (a texture, a `.glb`, a thumbnail, a Blender script). The +built-in ones are `modules/pet/models//`, found by `builtins.readDir` +(nothing else lists them). Your own go into `steamFrame.pet.extraModels`: +either such a folder (`corgi = ./pets/corgi;`) or the spec as an attrset, +with files as Nix paths (`calico = { name = "Calico"; png = ./calico.png; };`). +Rebuild, and the model shows up in the pet's ⋯ menu and in +`vr-pet models`. The id (folder or attribute name, `vr-pet model `): +letters, digits, `-` and `_`. + +An invalid spec fails the build with a message naming the model and the field +(index.py `validate()`, bake_gltf.py for the animal fields). + +## Fields (every kind) + +| field | meaning | +|-----------|---------| +| `name` | the text in the menu (required) | +| `kind` | `base`, `recolor`, `texture` or `gltf` (required; an extraModels attrset with a `png` defaults to `texture`) | +| `order` | the menu's order (the coats 0-4, the animals 10-19; default 100), then the name | +| `default` | `true` on the one model shown until another is picked (the Ginger cat); `steamFrame.pet.defaultModel = ""` overrides it | +| `group` | the menu puts a line between groups: `coats` (default for the cat's kinds) or `animals` (default for `gltf`) | +| `thumb` | a picture of the model's own (a file of the folder) for the menu, instead of a render (thumb.py: the sitting animal, else the standing one; 48 px) | +| `private` | `true`: personal use only, never published ([below](#private-models)); must be a boolean; models.json carries it for every model (`false` by default) | +| `rights` | who owns the character and why it is private (a text); a spec with `rights` must have `"private": true` | +| `extends` | another model's id: its spec with this one's fields on top ([below](#another-build-of-the-same-animal-extends-proportions-colors)) | + +## The cat and its coats + +- `base`: the Toon Cat, baked by bake.py (its sources are pinned in + package.nix). There is one. +- `recolor`: a coat of the cat, skin.py moving the ginger fur to another + ramp: `"fur": ["#rrggbb" (dark end), "#rrggbb" (light end)]`, optional + `"white": ["chest", "muzzle", "paws"]` markings. +- `texture`: a coat of the cat with a texture of its own: `"png": "coat.png"`, + a 64 x 64 PNG laid out like the cat's (start from an edited copy of + `nix build github:lhns/steam-frame-nix#pet-models`/cat.png: the body's texels are a + side view, columns along the length, each a dark-to-light ramp). + +Coats share the cat's frames and behaviour; they switch in place. + +## Other animals: kind `gltf` + +A rigged, animated glTF (`.glb`, or `.gltf` with its files), baked by +bake_gltf.py into one OBJ per frame (as the cat) with its own behaviour set: +the core (core.js) gives an animal what its clips allow. + +| field | meaning | +|-----------|---------| +| `src` | the glTF: a file of the folder (`"model.glb"`), `{ "url": ..., "hash": "sha256-..." }` (fetched, pinned) or `{ "path": ... }` (a file of the folder, e.g. downloaded by hand), optionally turned into a glTF (below) | +| `height` | m, the standing animal's height (top of the ears) in its first idle frame; the cat is 0.30 | +| `feet` | the four foot bones (their ends), for the walk speed: how fast the feet move back while on the floor | +| `zones` | bones for the interaction points: `scruff` (the top of the skin it moves: where it is held from), `head`, `back` (the top of the skin above the joint: petting), `chin`, `tailBase` (`{ "bone": ..., "at": "joint" }`: the joint itself) | +| `follow` | `{ bone: bone it moves with }`: bones outside the leg chains that the skin follows (IK targets, as the Quaternius feet); in hand-keyed poses they keep their place relative to that bone | +| `keys` | hand-keyed poses (below) | +| `clips` | the core's clip names -> how each is made (below); `idle` and `walk` are required | +| `species` | `{ "actions": { name: { "pose": "stand", "weight": 1, "cooldown": 30 } } }`: the animal's own idle actions (a clip of that name), chosen now and then in that pose like the cat's groom or stretch | +| `texture` | px, the width of a base colour texture in the atlas (default 256; 512 for a detailed face) | +| `proportions` | per bone `scale`, `rot`, `aim` on the rest pose: a reshaped animal (below) | +| `colors` | flat materials' colours: a recoloured animal (below) | +| `thumbFrame` | the baked frame the menu's render shows instead, e.g. `"idle_0"` (the Dachshund: its long body shows standing) | +| `credit` | the source and licence (a built-in model: also in [README, Credits](../README.md#credits)) | + +### Sources that are not a glTF yet + +`src` (`{url, hash}` or `{path}`) may also have (package.nix `gltfOf`); the +built-in models need none of them, a model from a `.blend` all three: + +- `"unzip": "Dir/*"`: it is a zip; the members matching the glob are + unpacked. For a fetched one the `hash` is of those unpacked files (a + recursive hash), not of the zip (a service may zip it on the fly). +- `"blend": "Dir/model.blend"`: the .blend to open (its path in the zip). +- `"blender": "convert.py"`: a Blender script of the folder, run at build + time as `blender -b --python convert.py -- `; it exports + the glTF (and may clean it up: drop meshes, add bones, key clips, + decimate). The build fails when the script does or writes nothing. + Blender (`pkgs.blender`) is then a build input of that model only. + +### Clips: how animations map + +The core asks for clips by name. Each pose has a loop, transitions join +poses, actions are one-shot clips that start and end in their pose: + +| pose | loop | into it (and back) | +|--------|-----------|-----------------------------------------------------| +| stand | `idle` | | +| walk | `walk` | `walkstart` (stand -> walk; played backwards to stop) | +| sit | `sit` | `sitdown` (stand -> sit), `standup` (sit -> stand) | +| lie | `lie` | `liedown` (stand -> lie; backwards to get up) | +| sleep | `sleep` | `curl` (lie -> sleep; backwards to wake) | +| petted | `purr`, `sitpurr`, `liepurr` | `purrin`, `sitpurrin`, `liepurrin` | +| held | `dangle` | (hangs from the scruff: `"hang": true`) | +| dropped| `fall` | `land` (an action: lands, ends standing) | + +Actions: `startle` (a fast hand close by; then it backs off), `land`, the +cat's `groom` (sitting), `stretch`, `shake`, and the animal's own +(`species.actions`). + +Whatever is missing falls back: a pose without its loop is not there +(commands and a saved pose take the nearest: sit, lie -> stand, sleep -> +lie); a transition without its clip is a cut; an action without its clip is +never chosen; petting without a purr loop does nothing; no `startle` / `land` +clip: it backs off / goes on at once; `dangle` / `fall` without their loops +show the nearest (fall, then stand). + +Each clip is one of: + +- `"Walk"`: that clip of the glTF, sampled at the baked fps (looping for a + pose loop). +- `{ "clip": "Jump_ToIdle", "start": 0.5, "end": 1.3, "fps": 12, "loop": false }`: + a part of it, at its own fps (12 for slow ones: fewer frames). +- `{ "clip": "Jump_ToIdle", "hold": 0.5 }` (or `"hold": "last"`): one frame + held (`"seconds"`, `"layers"` as below). +- `{ "key": "sit", "seconds": 3, "fps": 8, "layers": [...] }`: a hand-keyed + pose held; with layers a loop (at half the fps, or `fps`). +- `{ "blend": ["stand", "sit"], "seconds": 0.9 }`: a transition from the first + frame of one clip or key to another's. +- `{ "reverse": "sitdown" }`: another clip backwards. +- `"hang": true` on any: hung from the scruff (the dangle), not on the floor. +- `"lift": 0.12` on any: that many m above the floor (flying): a loop all + the time; a one-shot rises over its first quarter and settles over its + last (e.g. a flying animal's `hover` and `startle`). + +A layer is a sine on top: `{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4, "phase": 0.5 }` +(degrees about the body's axes: a wagging tail) or `"scale": [0.03, 0, 0.03]` +(breathing). + +### Keys: hand-keyed poses + +`{ "from": "Idle", "at": 0, "rot": { bone: [x, y, z] }, "aim": { bone: [x, y, z] } }`: +that frame of a glTF clip, with bones turned (degrees about the body's axes: +x its left, + = nose down; y up; z forward) and / or aimed (the bone, toward +its first child joint, pointed along a direction in the body's axes: `[0, -1, 0]` +straight down, `[0, -0.1, 1]` forward along the floor). Parents are done +first; a child turns with its parent. Every frame is put on the floor (its +lowest point), so fold the legs until the body rests where it should. + +Check the poses by rendering frames (thumb.py renders an OBJ with the +texture; `nix run github:lhns/steam-frame-nix#pet-preview` plays them all). + +Turn a head with the bone that carries it and its ears: in the Quaternius +rigs the ears hang from `Neck3`, not from `Head`, and the skull's top is +skinned to them, so turning `Head` alone twists the face under still ears. +The Shiba, Fox and Dachshund turn their heads at `Neck3` (with `Neck1` / +`Neck2` for the neck; the split keeps the head where a `Head` turn had put +it) and relax the ears on top at `Ear2` (sleep). + +### Budget and texture + +- **Faces:** at most 3000 triangles per frame (the cat has 2636): vrcompositor + draws every mounted frame, and a few hundred are mounted. The bake fails + above it: decimate the model first (e.g. Blender's Decimate modifier). +- **Texture:** a render model has one texture. bake_gltf.py makes it: + flat material colours become a palette (one 8-texel column per material, + a little darker toward the floor by the vertex's height when standing: + baked shading like the Toon Cat's), a material's base colour texture is + stacked below it (UVs in 0..1), `texture` px wide (default 256). + Everything is `cat.png` / `cat.mtl` next to the frames (the file names + are shared by all models). +- **Facing:** the glTF's +Z forward, +Y up (as Blender's glTF export). + +### A full example (the Shiba Inu, shortened) + +```json +{ + "name": "Shiba Inu", "kind": "gltf", "order": 10, + "credit": "Shiba Inu, Ultimate Animated Animal Pack by Quaternius, CC0 1.0", + "src": { "url": "https://static.poly.pizza/ba6d0ee3-bcc0-4ef0-9d3c-a3e245b41c77.glb", + "hash": "sha256-nL1PHhL4UCQSbqtqLFcGwJkZbfGhDbdObItnusP/9Uo=" }, + "height": 0.35, + "feet": ["FrontLowerLeg.L_end", "FrontLowerLeg.R_end", "BackLowerLeg.L_end", "BackLowerLeg.R_end"], + "follow": { "IKFrontLeg.L": "FrontLowerLeg.L", "IKFrontLeg.R": "FrontLowerLeg.R", + "IKBackLeg.L": "BackLowerLeg.L", "IKBackLeg.R": "BackLowerLeg.R" }, + "zones": { "scruff": "Neck2", "head": "Head", "back": "Torso2", + "chin": { "bone": "Head", "at": "joint" }, "tailBase": { "bone": "Tail1", "at": "joint" } }, + "keys": { + "stand": { "from": "Idle" }, + "sit": { "from": "Idle", + "rot": { "Back": [-38, 0, 0], "Neck1": [8, 0, 0], "Neck2": [6, 0, 0], "Neck3": [18, 0, 0] }, + "aim": { "FrontUpperLeg.L": [0.03, -1, 0.05], "BackLeg.L": [0.2, -0.3, 1], + "BackUpperLeg.L": [0.05, -0.35, -1], "BackLowerLeg.L": [0.05, -0.2, 1] } } + }, + "clips": { + "idle": { "clip": "Idle", "fps": 12 }, + "walk": "Walk", + "walkstart": { "blend": ["stand", "walk"], "seconds": 0.3 }, + "sit": { "key": "sit", "seconds": 3, "layers": [{ "bone": "Torso2", "scale": [0.025, 0, 0.025], "period": 3 }] }, + "sitdown": { "blend": ["stand", "sit"], "seconds": 0.9 }, + "standup": { "reverse": "sitdown" }, + "purr": { "key": "stand", "seconds": 1.2, "fps": 24, + "layers": [{ "bone": "Tail1", "rot": [0, 22, 0], "period": 0.4 }] }, + "fall": { "clip": "Jump_ToIdle", "hold": 0.5 }, + "land": { "clip": "Jump_ToIdle", "start": 0.5 }, + "startle": { "clip": "Idle_HitReact_Left", "loop": false }, + "sniff": { "clip": "Eating", "fps": 12, "loop": false } + }, + "species": { "actions": { "sniff": { "pose": "stand", "weight": 1.2, "cooldown": 45 } } } +} +``` + +The complete ones in `modules/pet/models/`: `shiba/model.json`, +`fox/model.json` (same rig), `dachshund/model.json` (the Shiba reshaped: +below). + +### Another build of the same animal: `extends`, `proportions`, `colors` + +`"extends": ""` starts from another model's spec: this one's fields go +on top (an object field entry by entry, so `"keys": { "sit": … }` replaces +only that key; the other's `default` and `thumb` are not taken). Files are +looked up in this folder, then in that model's. With these two fields +(bake_gltf.py) the same glTF and clips make a different animal: + +- `proportions`: `{ bone: { "scale": [x, y, z], "rot": [x, y, z], "aim": [x, y, z] } }`, + kept in every clip. `scale`: the skin the bone moves, in the bone's own + axes (y along the bone, toward its child), from its joint; its children's + joints move with it but are not scaled (a longer back keeps its legs). + `rot` / `aim`: the bone's rest pose turned / aimed as in a key (body + axes), on top of the clip's own motion (ears hanging, a straight tail). + `follow` bones (IK feet) stay where they were relative to their bone, + that offset scaled with it, turned as in the clip (flat paws). +- `colors`: `{ material name: "#rrggbb" }`: a flat material's colour (the + glTF's material names; the Quaternius dogs: `Main`, `Main_Light`, + `Black`, `Eyes_*`). + +The Dachshund (`dachshund/model.json`) is the Shiba Inu this way: back and +torso ×1.25-1.45 long, legs ×0.42-0.5, muzzle ×1.3, the ears longer and aimed +down and out, the tail straightened, black and tan; the Shiba's clips and +behaviour, with its own `sit` key (the Shiba's leans back too far for short +front legs to reach the floor). Short legs lift less, so a reshaped walk's +speed counts only the feet moving back. Check the face from the front too +when rendering frames (above): a bone's +`scale` / `rot` / `aim` also moves the skin weighted partly to it (the +Shiba's eyes and brow are about a fifth `Ear1`, so the Dachshund's ears +bend at `Ear2`, `Ear1` only tilted 20° out). + +## Private models + +A model of a character someone else owns (the licence of a fan model does +not cover the character) is for personal use only: its spec has +`"private": true` and a `rights` note saying whose it is. index.py checks +both (`private` a boolean; `rights` only with `"private": true`), and +models.json marks it (`"private": true`). + +Such a model goes into `steamFrame.pet.extraModels` from your own +configuration (a folder in your repository), never into +`modules/pet/models/`: a built-in model with `"private": true` fails +evaluation (package.nix `builtinOf`; the `pet` check tests the refusal and +that a private extra model builds). diff --git a/docs/pet.md b/docs/pet.md new file mode 100644 index 0000000..22b980a --- /dev/null +++ b/docs/pet.md @@ -0,0 +1,109 @@ +# VR pet + +`pet.*`, module `pet` ([options](../README.md#options)). + +A [SteamVR dashboard patch](ui-patches.md#steamvr-dashboard-patches). + +## What you get + +A 3D pet in SteamVR's scene, next to your windows: the Toon Cat in five +coats (Ginger, Tuxedo, Blue, Cream, Snow), a Shiba Inu, a Fox and a +Dachshund, plus your own models (`pet.extraModels`). It lives in the +dashboard's scene, so it is there in SteamVR Home and over games while the +dashboard runs. + +- **On its own** it walks around you, sits, lies down, sleeps, follows you + beyond 2.5 m and reappears in front of you beyond 8 m. The cat also + grooms, stretches and shakes; the dogs and the fox sniff and look around. +- **Pick it up:** point the laser just above it (a grip bar shows up, like + a window's), press and drag like a window (the thumbstick pushes and + pulls). It dangles by the scruff, turns with the controller and falls + where you let go. No controller button is read; a falling pet is not + caught. +- **Pet it:** rest or move a controller slowly (< 0.4 m/s) on its head or + back (within 10 cm) for 0.6 s: it purrs (a dog wags its tail) while the + strokes go on. A fast swipe (> 1 m/s) close by startles it. +- **Its controls**, below the grip bar: X hides it (kept across SteamVR + restarts); ⋯ opens its menu: the models (a check on the current one, kept + across restarts), then Summon (1 m in front of you), Sit, Lie down, + Sleep. Buttons act on a press and a release on them, not during a drag or + 0.3 s after. The menu closes on a pick, a press elsewhere, 1 s after the + laser left it, and when the pet hides. +- **Bring it back:** "Pet" in the dashboard's "+" menu (its icon is the + current model's), i.e. `vr-pet show`: at its spot while that is within + 3 m and in view, else 1 m in front of you. + +The `vr-pet` command: + +```text +vr-pet show [--summon] show it (--summon: always 1 m in front of you) +vr-pet hide | status hide it; its state as JSON +vr-pet models the models (* the current one) +vr-pet model switch (a hidden pet switches too) +``` + +## Configuration + +```nix +steamFrame.pet.enable = true; +# steamFrame.pet.defaultModel = "shiba"; +# steamFrame.pet.options = { walkSpeed = 0.3; follow = 3; }; # core.js DEFAULTS +# steamFrame.pet.extraModels.corgi = ./pets/corgi; # a model folder +``` + +The first switch bakes the models: a few minutes on the Frame and about +0.5 GB in the store (1.1 GB without `auto-optimise-store`, which hardlinks +the coats' copies of the cat's frames); the sources are fetched at build +time (no Blender needed for the built-in models). Adding your own model, the spec and the +animation mapping: [VR pet models](pet-models.md). A model of a character +someone else owns belongs in your own `extraModels` with `"private": true` +([private models](pet-models.md#private-models)), never in this repository. + +Without a headset: `nix run github:lhns/steam-frame-nix#pet-preview` plays +the pet in a browser (mouse as the hands, a debug panel). + +## Limitations + +- vrcompositor draws every mounted frame: a few hundred static OBJs per + model, mounted gradually (`pet.mount`); switching to another animal takes + a few seconds to load. +- The pet is drawn by the dashboard's page: it is gone while SteamVR's + dashboard process restarts, and the patch reattaches within 15 s. +- It walks on the floor of the standing space and knows nothing of your + room's furniture. +- The "+" menu icon changes when the model does; a running Steam picks it + up when it rescans its icons (a few seconds). + +## Credits + +The models' authors and licences: [README, Credits](../README.md#credits). + +## How it works + +`vr-pet`, with [persistent state](ui-patches.md#persistent-state) (the +spot, pose, model and whether it is hidden). Debugging in the `systemui` +page: `window.__sfuiPet.cat.command('sit' | 'summon' | …)`, +`.cat.state()`, `.stats()`; the CLI calls only `window.__sfuiPet.api`. +Journal: `journalctl --user -t vr-pet -u steam-ui-patches`. + +- **Build** (`modules/pet/package.nix`): bake.py retargets and hand-keys + the Toon Cat's clips and bakes one OBJ per animation frame; bake_gltf.py + does the same for a rigged glTF (the Quaternius animals); index.py makes + the catalog (coats as recoloured textures of the cat's frames, one + directory per model, 48 px thumbnails) and the "+" menu icons. Each + animal is a derivation of its own. +- **Behaviour** (`core.js`): poses, clips and transitions, wandering, + following, petting, the scruff drag and the fall, independent of where + it is drawn (also used by the preview and the tests). +- **Drawing** (`systemui.js`): every frame is a render model node under one + world-locked root in the dashboard's scene graph; the frames a clip needs + are mounted ahead, unused ones dropped. Each change resends the + dashboard's last scene graph message with only the pet's subtree + replaced. The grip bar is a panel like a window's handle; while dragged + the root is parented to the controller, so the compositor carries it. +- **"+" menu icon:** `~/.local/share/icons/hicolor/256x256/apps/vr-pet.png` + links to `/run/user/1000/steam-frame-nix/vr-pet/icon.png`, which the + oneshot user service `steam-frame-nix-pet-icon` points to the current + model's icon: on switch, at login and when the state file changes (path + unit); it bumps hicolor's mtime so a running Steam rescans. Lifetime and + cleanup: [Changes outside Nix](../README.md#changes-outside-nix-exceptions). diff --git a/docs/ui-patches.md b/docs/ui-patches.md index 9100121..1976973 100644 --- a/docs/ui-patches.md +++ b/docs/ui-patches.md @@ -47,15 +47,15 @@ and `sudo systemctl disable` them. [Dashboard windows](dashboard-windows.md), [Steam close button](steam-close-button.md), -[window curvature](window-curvature.md) and -[window control bar](window-control-bar.md) patch SteamVR's dashboard while -it runs. What they have in common: +[window curvature](window-curvature.md), +[window control bar](window-control-bar.md) and the [VR pet](pet.md) patch +SteamVR's dashboard while it runs. What they have in common: - Each turns on the [SteamVR debugger](steamvr-debugger.md) (**the first time, restart SteamVR once**). - They depend on SteamVR UI internals: each is found by signature (its entry in `modules/steam-ui-patches/lib/signatures.json` has the patch's - name); after an update that changes them the dashboard stays stock (see + name; the VR pet's are inline in `modules/pet/systemui.js`); after an update that changes them the dashboard stays stock (see [after a Steam update](#after-a-steam-update)). Tested with SteamVR build 11008059. - All are laser-only: gamepad navigation sees the stock dashboard. @@ -102,8 +102,8 @@ per such patch in `~/.local/state/steam-frame-nix/ui-patches/.json` service writes the file atomically, only on change, only for that page's `state` patches, at most 64 KiB. -Used by the [window control bar](window-control-bar.md) and the -[Steam close button](steam-close-button.md). The file is user data, not +Used by the [window control bar](window-control-bar.md), the +[Steam close button](steam-close-button.md) and the [VR pet](pet.md). The file is user data, not generated by Nix: it is kept when the patch is disabled or removed and removed only by `steam-frame-nix-cleanup --all` or `install.sh uninstall`; see [changes outside Nix](../README.md#changes-outside-nix-exceptions). diff --git a/template/home.nix b/template/home.nix index 56a79fa..ad068d2 100644 --- a/template/home.nix +++ b/template/home.nix @@ -54,6 +54,9 @@ # # move it between the bar and the three-dot menu: # frameControls.enable = true; # }; + # # A 3D cat or dog in SteamVR's scene; "Pet" in the "+" menu (the + # # first switch bakes its models, about 0.5 GB): + # pet.enable = true; # firefox.enable = true; # launcher for the Firefox Flatpak # # the Frame has no AV1 decoder: sites send VP9/H.264 (hardware): # firefox.disableAv1 = true;