diff --git a/CHANGELOG.md b/CHANGELOG.md index 28734b3..eaeb436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ - Vendors Levy's UO_Model3D at upstream e9544f6 (was 3eaae69): 54-bone symmetric rig (no twist, toe, skirt or cloak chains), action 17 arm fix, narrower shoulders. `blender_build.py` checks the bones it needs by name instead of a bone count. Mappings use `foot.L/R` where they used `toe.L/R` (#27). Client data is not vendored. +- Vendors UO_Model3D at upstream b212cee (v13.5, was e9544f6): the cloth simulation pins the cloth to the moving + target (it stayed on the first pose), fabric presets, adaptive item render, pose keys re-fitted for natural motion. + `pipeline.py setup` copies `bpy_compat.py` and checks `README.md` (upstream dropped `README_EN.md`). ### Launchers - Every `.bat` has a one-sentence description, calls `_shared/common.bat` and has a `.sh` twin; new `dev/` launchers diff --git a/CLAUDE.md b/CLAUDE.md index 324befd..6025044 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ headless Blender run, not only a syntax check. ## Key facts -- Canonical body: UO_Model3D by Levy at upstream e9544f6 (`workspace/ultima-online/canonical-model/model/UO_Body_0x190.blend`), +- Canonical body: UO_Model3D by Levy at upstream b212cee (`workspace/ultima-online/canonical-model/model/UO_Body_0x190.blend`), 54 bones (no twist, toe, skirt or cloak chains; `foot.L/R` is the last leg bone; weapon bones `weapon1h.R`, `axe2h.L`, `bow.L`, `polearm.L`), rig `UO_Rig`, direction = driver `-d*pi/4` on the rig's Z rotation, UO frame i = scene frame 1 + 3i. - In item renders the body is a holdout: body poking through clothing cuts holes in the item sprite. Asset-pack diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 26cdaed..c5eebf8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,7 +17,7 @@ included with his permission. ## UO_Model3D v13 -`third_party/UO_Model3D_v13/` holds UO_Model3D by Levy (upstream commit e9544f6), shared with the +`third_party/UO_Model3D_v13/` holds UO_Model3D by Levy (upstream commit b212cee), shared with the artist's permission (the model, its fitting pipeline and `vdtool`). It is not under the MIT license. It contains no data extracted from the Ultima Online client; see its [NOTICE](third_party/UO_Model3D_v13/NOTICE.md). diff --git a/docs/guides/blender-render-end-to-end.md b/docs/guides/blender-render-end-to-end.md index aed5b2f..235bd94 100644 --- a/docs/guides/blender-render-end-to-end.md +++ b/docs/guides/blender-render-end-to-end.md @@ -29,7 +29,7 @@ workspace\venv\Scripts\python.exe tools/uo-content/pipeline.py setup --source th This copies the model, its render, bind and VD tools into `workspace/ultima-online/canonical-model/` and records source hashes. It prints that folder and exits 0. Facts about the body you are now rendering with: -- Levy's UO_Model3D at upstream `e9544f6` (see `third_party/UO_Model3D_v13/provenance.json`), 54 bones, rig `UO_Rig`, +- Levy's UO_Model3D at upstream `b212cee` (see `third_party/UO_Model3D_v13/provenance.json`), 54 bones, rig `UO_Rig`, 35 actions. - Direction is a driver, `-d*pi/4`, on the rig's Z rotation. UO frame `i` is scene frame `1 + 3i`. - Do not run setup while a build is running. Re-running it later changes the renderer fingerprint, and then `rebuild` diff --git a/docs/handoff.md b/docs/handoff.md index bbf172f..0d4e8a8 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -65,6 +65,27 @@ in `jobs/` (listed as a partial job by the studio); the fit lab's render index i jobs started outside the lab need `fit_item` when the saved adjustments hold non-zero item offsets or non-pack corrections. +## UO_Model3D b212cee (2026-10-20, v13.5, vendored) + +`third_party/UO_Model3D_v13/` now holds Levy's repo at b212cee (was e9544f6; through b2d22af and 9db841d, about 140 +commits), stripped the same way (the .blend saved by Blender 4.2 through `strip_originals.py`; texts and images as +before, no original atlas, no `.vd`, `.npz`, `.pkl`, `body13` or `test_data`). For SpriteMotion: + +- `uo_cloth_sim.py` drives the pinned target with animated shape keys: the old frame handler left the pin on the first + pose and the whole robe drifted 7-12 cm off the body. It gained `thick`, `in_max`, `smooth` (needs scipy, which + Blender does not ship), fabric presets (`fabric=silk|cotton|wool|denim|leather`) and imports `bpy_compat.py`, which + `pipeline.py setup` now copies and the render fingerprint includes. +- `render_uo_layer.py`: 512 adaptive samples for items, `DESPECKLE` 0, `OUTLINE_TARGET` 0.38 (one rim factor per + render, measured on the original items) and optional robe shading (`uo_contrast`, `uo_shade_sleeve/body/width`). +- 9db841d re-fitted the pose keys for natural motion and added the mounted head/neck yaw; b212cee leaves the model + unchanged. A/B on a one-handed katana preview (`weapon1h.R`) between b2d22af and 9db841d: 0.14% of the review sheet + changed. +- Upstream made `README.md` the English README and `README_PL.md` the Polish one (`README_EN.md` is gone); + `pipeline.py setup` now checks `README.md`. +- Measured on a robe built with the cloth pass: `thick` 0.02 (the upstream default) cracked the pleats and left a dark + ring under the hem; 0.005 with `in_max` -1 stayed clean. Upstream measured more leg pixels through the robe in a run + at 0.005, so it is a per-garment choice. + ## UO_Model3D e9544f6 (2026-10-09, vendored, SM-MODEL3) `third_party/UO_Model3D_v13/` now holds Levy's repo at e9544f6 (was 3eaae69), stripped as in its NOTICE. The rig is 54 diff --git a/third_party/UO_Model3D_v13/NOTICE.md b/third_party/UO_Model3D_v13/NOTICE.md index fe0672c..7513138 100644 --- a/third_party/UO_Model3D_v13/NOTICE.md +++ b/third_party/UO_Model3D_v13/NOTICE.md @@ -3,12 +3,12 @@ UO_Model3D v13 by Levy, shared with the artist's permission. A rigged 3D rebuild of the Ultima Online male body (body 0x190) with its fitting pipeline. SpriteMotion uses it as the -canonical body (rig `UO_Rig`, 54 bones since the 2026-10-09 update). Start with `README_EN.md` (English) or -`README.md` (Polish). +canonical body (rig `UO_Rig`, 54 bones since the 2026-10-09 update). Start with `README.md` (English) or +`README_PL.md` (Polish). ## What is here -Upstream commit: https://github.com/Kserian/UO_Model3D at `e9544f6` (2026-10-09; the previous copy was `3eaae69`). +Upstream commit: https://github.com/Kserian/UO_Model3D at `b212cee` (2026-10-20, v13.5; the previous copy here was `e9544f6`). `model/` (the `.blend` and the albedo PNGs), `pipeline/` (scripts, `cloak_pitch.json`, `weapon_motion.json`), `vdtool/`, and the two READMEs. The `.blend` is stored with git LFS (`git lfs pull` after cloning). Upstream publishes no `.fbx` or `.glb` and SpriteMotion reads none, so none is shipped. diff --git a/third_party/UO_Model3D_v13/README.md b/third_party/UO_Model3D_v13/README.md index 09e92b5..42221de 100644 --- a/third_party/UO_Model3D_v13/README.md +++ b/third_party/UO_Model3D_v13/README.md @@ -1,75 +1,46 @@ -# UO Body 0x190: model 3D z animacji Ultima Online - -Nagi mężczyzna (body 0x190 / 400) odtworzony w 3D z pliku animacji klienta UO `anim1_0x0190.vd`: -35 akcji × 5 kierunków, 210 klatek na kierunek, razem 1050 obrazków. Realistyczne ciało (MakeHuman, CC0) w proporcjach -postaci UO, szkielet 19 kości UO plus palce rąk i kości broni (54 kości), wszystkie 35 animacji dopasowane do oryginalnych klatek. -Służy do projektowania nowych warstw ubrań, zbroi, włosów i broni: plik `.blend` renderuje nowe klatki UO -i od razu zapisuje je do `.vd`. - -English version: [`README_EN.md`](README_EN.md). - -**Spis treści** -1. [Zawartość](#1-zawartość) -2. [Jak działa model](#2-jak-działa-model) -3. [Modelowanie przedmiotu](#3-modelowanie-przedmiotu) -4. [Render do klatek i pliku `.vd`](#4-render-do-klatek-i-pliku-vd) -5. [Docinanie](#5-docinanie) -6. [Narzędzie `vdtool`](#6-narzędzie-vdtool) -7. [Import do gry](#7-import-do-gry) -8. [Dokładność](#8-dokładność) -9. [Jak powstał model](#9-jak-powstał-model) -10. [Odtworzenie modelu (pipeline)](#10-odtworzenie-modelu-pipeline) -11. [Ograniczenia i częste problemy](#11-ograniczenia-i-częste-problemy) +**[Polska wersja (Polish version) → README_PL.md](README_PL.md)** + +# UO Body 0x190: a 3D model from Ultima Online animations + +A naked male body (body 0x190 / 400) rebuilt in 3D from the UO client animation `anim1_0x0190.vd`: 35 actions × 5 directions, 1050 images. +A realistic body (MakeHuman, CC0) in the UO character's proportions, a 54-bone rig and all 35 animations fitted to the original frames. +It is meant for designing clothing, armour, hair and weapon layers: the `.blend` renders new UO frames and writes them straight into a `.vd` file. + +**Contents:** [1 Files](#1-files) · [2 The model](#2-how-the-model-works) · [3 Modelling an item](#3-modelling-an-item) · [3a Fast path](#3a-the-fast-path-a-free-model---an-item-in-one-command) · [4 Rendering](#4-rendering-frames-and-the-vd-file) · [5 Cutting](#5-cutting) · [6 vdtool](#6-the-vdtool-tool) · [7 Importing into the game](#7-importing-into-the-game) · [8 Accuracy](#8-accuracy) · [9 History and tools](#9-history-and-tools) · [10 Problems](#10-limitations-and-common-problems) · [11 Notes for AI assistants](#11-notes-for-ai-assistants) --- -## 1. Zawartość +## 1. Files -| Plik / folder | Co to jest | +| File / folder | What it is | |---|---| -| `UO_Body_0x190.blend` | Główny plik (Blender 4.2 – 5.2): ciało, szkielet, 35 akcji, kamera UO, scena warstw ubrań, bryły konia, skrypty. | -| `UO_Body_Texture.png`, `UO_Body_Albedo_dir0..4.png` | Tekstura albedo (szara jak skóra w UO, bez światła) i jej warianty dla 5 kierunków UO. | -| `example_clothing/` | Przykładowa warstwa (koszula) w `Example_Shirt_layer.vd` i jej podgląd na oryginalnym ciele. | -| `vdtool/vdtool.py` | Narzędzie do rozpakowywania i pakowania `.vd` (rozdział 6). | -| `pipeline/` | Skrypty potoku: render, dopasowanie przedmiotów, testy, pomiary (rozdział 10). | -| `pipeline/body400.vd`, `pipeline/horse200.vd` | Oryginalne pliki klienta: ciało 0x190 (`anim1_0x0190.vd`) i koń 0xC8. | -| `client/body_0x190_frames/`, `client/horse_0xC8_frames/` | Oryginalne klatki ciała (1050) i konia (300) jako PNG + `meta.json` (`vdtool extract`). | - -**Oryginalne klatki.** Plik `.blend` zawiera oryginalne klatki ciała z klienta UO (do trybów dokładnych, rozdział 2). -Jest tylko do własnego użytku. Nie udostępniaj go publicznie. Żeby je dodać -z własnego klienta: skopiuj `anim1_0x0190.vd` jako `pipeline/body400.vd`, a w `pipeline/` uruchom -`python build_originals.py` oraz `python pack_originals.py --blend ../model/UO_Body_0x190.blend`. - -## 2. Jak działa model - -**Siatka `UO_Body`** -- Realistyczne ciało na bazie MakeHuman (CC0): 13 380 wierzchołków, UV, palce, stopy z palcami, twarz. - Proporcje (grubość rąk, nóg, tułowia, głowa) dopasowane do oryginalnych klatek z ograniczeniami anatomicznymi. -- Pozycja spoczynkowa to A-pose. Jednostki to metry, postać patrzy w −Y, oś Z w górę. **Podłoga to z = 0.** -- Nie ma korekt kształtu (shape keys): sylwetkę w każdej klatce ustawiają same kości, więc przedmiot po prostu idzie - za kośćmi. - -**Szkielet `UO_Rig`** (54 kości: 19 kości UO, 30 palców i 5 kości broni i tarczy, sufiksy `.L`/`.R`; kości wyłącznie od ruchu postaci): -- `pelvis → spine → chest → neck → head`, `chest → clavicle → upper_arm → forearm → hand`, `pelvis → thigh → shin → foot`. - `pelvis` jest korzeniem i niesie przesunięcie postaci. Obojczyki unoszą bark; nie deformują siatki (Deform wyłączony). -- `finger1-1` … `finger5-3` (15 kości na dłoń, `finger1` to kciuk, oś X kości = oś zgięcia). W każdej klatce dłoń jest zaciśnięta tak jak na oryginale - (zgięcie palców i kciuka dopasowane do klatek UO). Rękawice (`"gloves"`) idą za palcami. -- Kości ruchu przedmiotów: `polearm.L`, `axe2h.L`, `bow.L` (dzieci `hand.L`), `weapon1h.R` (dziecko `hand.R`), `shield.L` (dziecko `forearm.L`), z kluczami - w 35 akcjach (przywrócone na prośbę użytkownika; zależą tylko od dłoni i przedramienia, więc palce ich nie zmieniają). -- Nie ma kości skrętu, palców stóp ani łańcuchów materiału (usunięte w sesji 14; wagi palców stóp przeszły na stopę). - Przed uproszczeniem (commit `b86c314`) szkielet miał 112 kości. -- Wagi pochodzą z MakeHuman (gładkie stawy, bez „cukierków” w łokciach i barkach). -- **Grubość w klatce:** skala X/Z kości `upper_arm`, `forearm`, `hand`, `thigh`, `shin`, `foot`, `head` (Y = 1) lekko - pogrubia lub wyszczupla kończynę tam, gdzie oryginał tego wymaga. Następna kość łańcucha tej skali nie dziedziczy - (Inherit Scale = None), więc długość kończyn się nie zmienia. Przedmiot przypięty do tych kości pogrubia się razem ze skórą. - -**Dopasowanie do klatek.** Poza każdej z 210 klatek jest dopasowana do 5 kierunków oryginału naraz (1050 obrazków), -bez żadnych korekt kształtu. Broń, tarcza i włosy są sztywne na kości dłoni, przedramienia i głowy. - -**Animacje.** Każda akcja UO to akcja Blendera `NN_nazwa` (z fake userem). 1 klatka UO = 3 klatki sceny (24 fps), -z płynną interpolacją. Chód i bieg są zapętlone. Właściwości akcji: `uo_action` (numer) i `uo_frames` (liczba klatek). - -| # | akcja | # | akcja | # | akcja | +| `model/UO_Body_0x190.blend` | Main file (Blender 4.2 – 5.2): body, rig, 35 actions, UO camera, clothing-layer scene, horse proxies, scripts. | +| `model/UO_Body_Texture.png`, `UO_Body_Albedo_dir0..4.png` | Albedo (grey like UO skin, lighting removed) and its variants for the 5 directions. | +| `model/example_clothing/` | Example layer (a shirt) as `.vd` with a preview over the original body. | +| `model/items/horn/` | Recipes of a horn item for the right and left hand. | +| `vdtool/` | `vdtool.py` unpacks and repacks `.vd` (section 6), `mul2vd.py` converts client `anim.mul`. | +| `pipeline/` | Render, item fitting, tests, measurements (section 9). `body400.vd`, `horse200.vd` = original client files (body 0x190, horse 0xC8). | +| `client/` | Original body (1050) and horse (300) frames as PNG + `meta.json`; `extract/` = data extracted from the client. | +| `docs/qa/` | `baselines.md` (QA thresholds and last values), measurement JSONs, reports on the robe variants (section 3a). | + +**Original frames.** The `.blend` contains the original UO frames (for the exact modes, section 2). Own use only; do not share them. To add them from your client: copy `anim1_0x0190.vd` to `pipeline/body400.vd`, then in `pipeline/` run `python build_originals.py` and `python pack_originals.py --blend ../model/UO_Body_0x190.blend`. + +## 2. How the model works + +**Mesh `UO_Body`:** MakeHuman (CC0), 13,380 vertices, UVs, fingers, face; proportions fitted to the original frames. Rest pose: A-pose. Metres, the character faces −Y, Z up, **floor z = 0**. No shape keys: the bones alone set the silhouette in every frame, so an item simply follows the bones. + +**Rig `UO_Rig`** (54 bones: 19 UO bones, 30 finger bones, 5 weapon / shield bones; `.L`/`.R`): +- `pelvis` (root, carries the translation) → `spine → chest → neck → head`; `chest → clavicle → upper_arm → forearm → hand`; `pelvis → thigh → shin → foot`. Clavicles raise the shoulder but do not deform the mesh; they have location keys only in 49 of 210 frames where the silhouette needs them. +- `finger1-1` … `finger5-3` (15 per hand, `finger1` = thumb, X = curl axis): the hand is clenched as on the original. Gloves follow the fingers. +- Item bones: `polearm.L`, `axe2h.L`, `bow.L` (on `hand.L`), `weapon1h.R` (on `hand.R`), `shield.L` (on `forearm.L`), keyed in all 35 actions. +- No twist, toe or cloth-chain bones. Weights from MakeHuman (no candy-wrapper joints). +- **Girth per frame:** X/Z scale of `upper_arm`, `forearm`, `hand`, `thigh`, `shin`, `foot`, `head` (Y = 1, the next bone does not inherit it) makes a limb thicker or thinner where the original needs it; items bound to these bones follow. The head has one scale (1, 1, 0.942) in all frames, so a helmet does not pulse. + +**Fitted to the frames.** The pose of each of the 210 frames is fitted to all 5 directions of the original at once, then re-fitted for **natural motion with the same silhouettes** (`pipeline/pose_natural_fit.py`): smooth in time, no head zig-zag, no twisting or sliding shoulder, elbow a hinge; no frame worse than before. In the area spell (action 17) the arms are symmetric and the torso only tilts. Weapons, shields and hair are rigid on hand, forearm and head. + +**Animations.** Each UO action is a Blender action `NN_name` (fake user), properties `uo_action` and `uo_frames`; 1 UO frame = 3 scene frames (24 fps), smooth interpolation. Left and right match the original. In the mounted actions the rider sits in the air; the horse is a separate animation. + +| # | action | # | action | # | action | |---|---|---|---|---|---| | 0 | walk_unarmed | 12 | attack_2h_bash | 24 | mounted_run | | 1 | walk_armed | 13 | attack_2h_slash | 25 | mounted_stand | @@ -84,335 +55,277 @@ z płynną interpolacją. Chód i bieg są zapętlone. Właściwości akcji: `uo | 10 | attack_1h_pierce | 22 | die_backward | 34 | eat | | 11 | attack_1h_bash | 23 | mounted_walk | | | -Lewa i prawa strona zgadza się z oryginałem (kończyn nigdy nie zamieniano). W akcjach konnych postać siedzi w powietrzu, -bo koń jest w UO osobną animacją. - -**Kamera `UO_Camera`** patrzy dokładnie jak kamera gry. -- Rzut ortograficzny, elewacja 28,45°, 36 px/m, obraz 136×120, punkt zaczepienia w środku piksela (68, 86). Tak są zapisane oryginalne - klatki ciała. `render_uo_layer.py` renderuje na większym płótnie (`CANVAS`, domyślnie 256×256 z zaczepem (128, 192), 36 px/m bez zmian), - bo 136×120 ucina broń dwuręczną, włócznie i wysokie nakrycia głowy (rozdział 4). -- Punkt zaczepienia jest 7 cm nad podłogą (`uo_anchor_height`), bo tak wynika ze wszystkich klatek. -- Kierunek UO ustawia właściwość `uo_direction` (0–4) na `UO_Rig`: 0 przodem, 2 profilem w lewo, 4 tyłem. - Kierunki 5–7 to lustra 3–1, które robi klient. - -**Materiał i światło UO.** Światło UO (jedno, przy kamerze, prawie od przodu) wyznaczyłem z klatek i oddzieliłem od koloru ciała. -- `uo_look = 1` (domyślnie): „wygląd UO”, albedo × (0,08 otoczenia + 0,92 światła UO). Render wygląda jak klatki z gry. -- `uo_look = 0`: zwykłe PBR (Principled BSDF), do edycji i silników gier. Tej wersji używa `export.py` (generuje `.glb`/`.fbx` na żądanie, nie ma ich w repo). -- Grupa węzłów **`UO_Look`** daje to samo światło przedmiotom. Węzeł „Skin hue” opcjonalnie barwi skórę. -- **Tryby dokładne** (wymagają oryginalnych klatek w pliku i silnika Cycles): - - `EXACT_COLORS`: ciało jest „pomalowane” oryginalną klatką UO rzutowaną z kamery, więc ma dokładnie oryginalne kolory. - - `EXACT_BODY`: warstwa ciała jest identyczna z oryginałem, a w warstwie ubrania ciało zasłania przedmiot dokładnie - po oryginalnym obrysie. - -**Koń (akcje 23–29).** Kolekcja `Horse_Proxy` zawiera przybliżoną bryłę konia (body 0xC8) dla każdej klatki konnej. -Bryła decyduje, **co** jest za koniem, a **gdzie** koń jest, wyznacza dokładny obrys z jego klatek (tekst -`uo_horse_masks.json`). Dzięki temu koń zasłania jeźdźca i przedmioty co do piksela. - -**Skrypty w pliku `.blend`** (Text Editor; wybierz je z listy tekstów w nagłówku edytora): - -| Tekst | Rola | +**`UO_Camera`** sees like the game camera: orthographic, elevation 28.45°, 36 px/m, original frames 136×120 with the anchor on pixel (68, 86), 7 cm above the floor. `render_uo_layer.py` renders on a bigger canvas (`CANVAS` 256×256, anchor (128, 192)), because 136×120 cuts off two-handed weapons, spears and tall headgear. Direction = property `uo_direction` (0–4) of `UO_Rig`: 0 faces the camera, 2 is a profile facing left, 4 faces away; 5–7 are mirrors of 3–1 made by the client. + +**Material and UO light.** One light at the camera, estimated from the frames and separated from the body colour. `uo_look = 1` (default): albedo × (0.08 ambient + 0.92 UO light), looks like the game frames; `uo_look = 0`: plain PBR for editing and game engines (`export.py` makes `.glb`/`.fbx` on demand, not in the repo). The node group **`UO_Look`** gives items the same light; "Skin hue" tints the skin. **Exact modes** (need the original frames and Cycles): `EXACT_COLORS` paints the body with the original frame projected from the camera; `EXACT_BODY` makes the body layer identical to the original and lets the body hide an item exactly along the original outline. + +**Horse (actions 23–29).** The `Horse_Proxy` collection holds an approximate volume of the horse (0xC8) per mounted frame; it decides **what** is behind the horse, while **where** the horse is comes from the exact outline of its frames (`uo_horse_masks.json`), so the horse hides the rider and items to the pixel. + +**Scripts inside the `.blend`** (Text Editor, pick from the list in the header). Always work in `UO_Body_0x190.blend` and bring your items into it: + +| Text | Purpose | +|---|---| +| `render_uo_layer.py` | Renders a layer to frames and `.vd` (Alt+P). | +| `uo_import_item.py` | Brings a foreign model (`.glb` `.fbx` `.obj` `.dae`) onto the body: bakes the mesh, scales it to the height of the original item of that `KIND`, puts it in `Clothing`. | +| `uo_materials.py` | Foreign materials into the UO look: colour into `UO_Look`, alpha >= 0.5 = pixel, PBR parameters dropped, **metal gets a UO-style highlight** (measured on plate and helm sprites); `SATURATION = 0` = grey item for in-game dyeing. After `uo_import_item.py`. | +| `uo_densify_item.py` | Subdivides the mesh without changing the shape so that a low-poly item bends smoothly (edge 2 cm gloves, 3-5 cm the rest). | +| `uo_autofit_item.py` | Units, size and place from the shape of the skin of the slot (needs `scipy`). | +| `uo_fit_item.py` | Turns sleeves onto the arms and pushes the item out of the skin. | +| `uo_prepare_item.py` | One step per slot (`KIND`): autofit → densify → fit → bind. | +| `uo_bind_item.py` | Binds the item to the body: parent, Armature, weights (section 3). | +| `uo_hood_cover.py` | Brings a hood onto the skull and the cowl onto the shoulders. | +| `cloth_lib.py` | Loose garments: hull of the legs and tent from the waist, per frame (numpy); leg push of the renderer and target of the cloth simulation. | +| `uo_orient_weapon.py`, `uo_place_weapon.py`, `uo_place_shield.py` | Stand a weapon upright, put it on the grip line of its class (turned about its shaft like the original `REF_ANIM`, then the bone turns it by the measured roll), put a shield on the left forearm. Before `uo_bind_item.py`. | +| `uo_weapon_bones.py`, `uo_shield_keys.py`, `weapon_motion.json` | Weapon bones and their motion (grip and 210 poses of 4 weapon classes); load the motion into an older file. | +| `uo_vd_writer.py`, `uo_job.py` | `.vd` writer and a runner that keeps Blender's window alive during long renders (not run directly). | +| `uo_horse_masks.json`, `uo_original_frames.json` | Data: horse outlines and original frames. | + +**Tools outside the file (`pipeline/`):** + +| Group | Scripts | |---|---| -| `render_uo_layer.py` | Render warstwy do klatek i `.vd` (uruchamiasz Alt+P). | -| `uo_materials.py` | Materiały obcego modelu (PBR, tekstury) → „UO look”: podpina kolor (tekstura, kolor, atrybut koloru) pod węzeł `UO_Look`, zachowuje przezroczystość (alpha ≥ 0,5 = piksel w sprite'cie), wyrzuca parametry PBR (roughness, mapy normalnych, odbicia otoczenia), ale **metal dostaje odblask w stylu UO** (zmierzony na sprite'ach napierśnika i hełmu: `SPEC_STRENGTH` 1,0 × albedo, `SPEC_POWER` 16; płyta 2,0/19, kolczuga 0,7/11, patrz `docs/qa/light_shadow.md`); `SATURATION = 0` daje szary przedmiot do farbowania w grze. Uruchom po `uo_import_item.py`. | -| `uo_import_item.py` | Wprowadza obcy model (darmowy, `.glb` `.fbx` `.obj` `.dae`…) na ciało: wypieka siatkę bez obcego szkieletu i wag, skaluje i ustawia na wysokości oryginalnego przedmiotu UO danego typu (`KIND`), wrzuca do `Clothing` (przed `uo_fit_item.py`, rozdział 3). | -| `uo_fit_item.py` | Obraca rękawy zaznaczonego przedmiotu na ręce i wypycha go ze skóry (przed `uo_bind_item.py`, rozdział 3). | -| `uo_densify_item.py` | Zagęszcza siatkę zaznaczonego przedmiotu (podział bez zmiany kształtu, opcjonalnie `SMOOTH`), żeby low-poly przedmiot zginał się gładko w łokciach, kolanach i biodrach, a nie łamał wzdłuż kilku długich krawędzi; docelowa długość krawędzi zależy od slotu (`EDGE_BY_KIND`, rękawice 2 cm, reszta 3–5 cm). Przed `uo_fit_item.py`. | -| `uo_prepare_item.py` | Jeden krok dla slotu (`KIND`): `uo_densify_item.py` → `uo_fit_item.py` → `uo_bind_item.py` z ustawieniami tego slotu (tabela `SLOTS`). Wyniki i uzasadnienie: `docs/qa/slot_geometry.md`. | -| `uo_autofit_item.py` | Jednostki, skala i miejsce zaznaczonego przedmiotu z kształtu skóry slotu (nie z jednej wysokości na slot): `docs/qa/autofit.md`. Wywołuje go `uo_prepare_item.py` (`AUTOFIT`). | -| `uo_orient_weapon.py` | Stawia obcą broń pionowo (czubek w górę, płaska strona na +X, długość klasy: miecz 1 m, muszkiet 1,3 m...), przed `uo_place_weapon.py`. | -| `cloth_lib.py` | Luźne ubrania (szata, spódnica): powłoka nóg i namiot od pasa, per klatka, numpy (`docs/qa/robe_physics.md`); używa go `render_uo_layer.py` dla przedmiotów z własnością `uo_cloth`. | -| `uo_bind_item.py` | Podpina zaznaczony przedmiot do ciała jednym uruchomieniem: parent, Armature i wagi (rozdział 3). | -| `uo_weapon_bones.py` | Dodaje kości broni (lewa dłoń: `polearm.L`, `axe2h.L`, `bow.L`; prawa: `weapon1h.R`) i klucze ich ruchu z `weapon_motion.json` (uruchamiane już w pliku; do ponownego wgrania ruchu). | -| `uo_place_weapon.py` | Stawia zaznaczoną broń (trzon po osi +Z, czubek w górę) na linii chwytu jej klasy, przed `uo_bind_item.py`. Głowicę (ostrze, topór) modeluj jako płaską płytkę w płaszczyźnie XZ, szeroką stroną na +X; skrypt obraca broń wokół trzonu tak jak w oryginalnej broni `REF_ANIM` (np. 624 halabarda, 613 topór kata, 623 szabla), a kość kręci nią dalej wg zmierzonego rollu (`weapon_motion.json`, pole `roll`). Łuki: roll nieokreślony. | -| `weapon_motion.json` | Dane: chwyt (punkt, kierunek, położenie dolnego końca) i ruch 4 klas broni (3 w lewej dłoni, 1H w prawej), 210 póz każda. | -| `uo_place_shield.py` | Stawia zaznaczoną tarczę na lewym przedramieniu jak w UO i dosuwa ją do ręki (przed `uo_bind_item.py`, rozdział 3). | -| `uo_shield_keys.py` | Podmienia ruch kości tarczy `shield.L` w starszym pliku na najnowszy (tarcza i jej podpięcie zostają). | -| `weapon_roll_fit.py`, `weapon_roll_lib.py`, `weapon_roll_apply.py`, `weapon_classify.py`, `test_weapon_roll.py` | Pomiar rollu (obrotu wokół własnej osi) wszystkich oryginalnych broni z `anim`..`anim5`, klasyfikacja broni do kości, zapis do `weapon_motion.json`, test renderu płytki ze sprite'em. Wyniki: `docs/qa/weapon_roll.md`. | -| `uo_vd_writer.py` | Zapis `.vd`, używany przez render (nie uruchamiaj go ręcznie). | -| `uo_job.py` | Uruchamia długie skrypty (render) krok po kroku z modalnego operatora: okno Blendera nie „wiesza się” (pasek postępu na dole, ESC przerywa). Używany przez `render_uo_layer.py` (nie uruchamiaj go ręcznie). | -| `uo_horse_masks.json`, `uo_original_frames.json` | Dane: obrysy konia i oryginalne klatki. | - -Skrypty narzędziowe poza plikiem (`pipeline/`): `uo_make_item.py` (darmowy model -> przedmiot jednym poleceniem, rozdział 3a), `uo_cloth_sim.py` (symulacja tkaniny luźnych ubrań, wynik `cloth_sim.npz` obok `item.blend`), `item_vs_original.py` (sylwetka przedmiotu vs oryginał), `robe_hull_eval.py` / `robe_cloth_sim_test.py` (porównania powłoki nóg i symulacji na oryginalnych szatach), `item_sheet.py` / `arm_mirror_action.py` / `arm_sym_fit.py` / `arm_refit.py` / `arm_grid.py` (lewa ręka jako odbicie prawej w akcji, dopasowanie ręki do oryginalnych klatek; `docs/qa/elbow_spell.md`), `item_gif.py` (arkusz klatek i animowany GIF przedmiotu na oryginalnym ciele), `item_qa.py` (przenikanie i grubość przedmiotu w ruchu), `uo_conform_item.py` / `item_clearance.py` (koszula jako powłoka ciała i pomiar przebić w każdej klatce, `docs/qa/jedi_tunic.md`), `item_thickness.py` (grubość na klatkach względem oryginalnych sprite'ów), `pose_capture.py` / `robe_calib.py` / `cloak_calib.py` / `cloak_fit_frames.py` (kalibracja fizyki luźnych ubrań na oryginałach, `docs/qa/robe_physics.md`), `test_autofit.py` / `test_robe.py` / `test_cloak.py` / `run_qa.py` (testy autodopasowania i szat oraz jedna regresja z progami: `python pipeline/run_qa.py [--quick]`), `sync_blend_scripts.py` (wgranie skryptów do `.blend`), `run_render_headless.py` (render bez GUI), `test_*.py` (testy), `body_part_raster.py` / `body_part_qa.py` (sylwetka ciała vs oryginał), `light_*.py`, `layer_analysis*.py`, `slot_dynamics.py` (analizy klatek z klienta), `build_originals.py` / `pack_originals.py` (oryginalne klatki do `.blend`), `export.py` (glb/fbx), `rig_simplify.py`, `rig_restore_fingers.py`, `rig_restore_weapons.py` (jednorazowe zmiany szkieletu), `weapon_*.py` i `test_weapons.py` / `test_weapon_roll.py` (kalibracja i testy broni; potrzebują sprite'ów z klienta, tylko katana 627 jest w repo). - -Skrypty są w pliku `.blend`, nie w obiektach. Pracuj więc zawsze w `UO_Body_0x190.blend` (File → Open) i dołączaj -do niego swoje przedmioty, a nie odwrotnie. - -## 3. Modelowanie przedmiotu - -1. **Otwórz `UO_Body_0x190.blend`** i zezwól na skrypty: Preferences → Save & Load → *Auto Run Python Scripts* - albo „Allow Execution” w żółtym pasku. -2. **Pozycja spoczynkowa:** zaznacz `UO_Rig` → Object Data Properties (ikona ludzika) → Pose → *Rest Position*. - Modeluj na ciele w A-pose, a na koniec wróć do *Pose Position*. -3. **Dodaj przedmiot:** wymodeluj go albo zaimportuj (File → Import / Append) i wrzuć do kolekcji **`Clothing`**. - **Model z zewnątrz (np. darmowy):** ustaw w tekście **`uo_import_item.py`** `FILE` (ścieżka do `.glb`, `.fbx`, `.obj`, `.dae`…) - i `KIND` (`shirt`, `plate`, `arms`, `pants`, `legs`, `boots`, `gloves`, `helm`, `neck`, `hair`, `beard`, `hat`) i uruchom (Alt+P). Skrypt - wypieka siatkę (bez szkieletu i wag z pliku), wyrzuca z pliku wszystko poza siatką, skaluje i ustawia przedmiot tak, żeby - jego wysokość pokryła się z oryginalnym przedmiotem UO tego typu na ciele (zakresy z oryginalnych sprite'ów), i wypisuje - `PART` do `uo_bind_item.py`. Siatki w pliku są wylistowane: oczy, ciało modelu i kształty pomocnicze wyłącz przez `SKIP`. - Jeśli przedmiot patrzy tyłem, ustaw `TURN = 180`. Potem **`uo_materials.py`** (materiały obcego modelu w „UO look”; bez tego błyszczący metal - z modelu wyjdzie w renderze o ok. 55/255 inny niż światło UO; metal dostaje odblask jak w UO), `uo_fit_item.py` i `uo_bind_item.py` (punkty niżej). Sprawdź licencję - modelu. Testy: `pipeline/test_import_item.py` (obcy plik), `pipeline/test_materials.py` (materiały). - Wszystko w tej kolekcji trafia do renderowanej warstwy. `Example_Shirt` usuń albo wyłącz w renderze. -4. **Ubranie, zbroja, hełm, buty, rękawice** (rzeczy, które się uginają): skrypt **`uo_bind_item.py`**. - 1. Każdy przedmiot, który w grze jest osobny (napierśnik, naramienniki, rękawice, buty, hełm), rób jako osobny - obiekt. W klatce 136×120 px widać niewiele szczegółów, więc siatka do ok. 20 tys. wierzchołków w zupełności - wystarczy (za gęstą zmniejsz modyfikatorem *Decimate*). - 2. **Dopasowanie do ciała** (opcjonalnie, w pozycji spoczynkowej): zaznacz przedmiot i uruchom **`uo_fit_item.py`**. - Najpierw rękawy (`MATCH_ARMS`): jeśli przedmiot był robiony pod ręce ustawione inaczej (niżej, bardziej do - przodu, zgięte w łokciu), skrypt sam znajduje ten kąt i obraca rękawy na ręce postaci. Przedmioty bez rękawów - zostają bez zmian. Potem części bliżej skóry niż `MIN_GAP` (15 mm) albo w ciele zostają wypchnięte. Pchnięcie - przesuwa cały obszar wokół w jedną stronę i wygasa płynnie na max(`RADIUS` = 4 cm, `SPREAD` × pchnięcie), więc - zamiast guzów rękaw poszerza się albo przesuwa w całości, a fałdy, nity i wzory idą razem z nim. Duże ściany - podziel wcześniej (Tryb edycji, A, PPM → Pod podziel, Liczba cięć 2), bo mogą przecinać ciało między - wierzchołkami. Ponowne uruchomienie nic już nie zmienia. `MAX_GAP > 0` dodatkowo dociąga odstające miejsca - (zmienia wygląd, domyślnie wyłączone). Położenie i rozmiar ustaw sam. - 3. Zaznacz przedmiot, otwórz tekst **`uo_bind_item.py`**, ustaw `PART` (typ przedmiotu) i uruchom (Alt+P). - Każdy wierzchołek przedmiotu idzie za skórą, która leży **pod nim** (wzdłuż normalnej, `MAP = "under"`), - a wagi są wygładzane na przedmiocie (`SMOOTH = 4`). - - | `PART` | Przedmiot | Za czym idzie | +| Item in one command | `uo_make_item.py` (3a), `uo_conform_item.py`, `uo_cloth_sim.py`, `item_gif.py` (frame sheet and GIF of an item on the original body), `item_sheet.py` | +| Item measurements | `item_qa.py` (clipping, thickness), `item_clearance.py`, `item_thickness.py`, `item_body_holes.py` (what the client shows over the original body 400), `item_skin_check.py` (skin through the item vs in holes, feet apart) | +| Body and poses | `pose_lib.py` (rig in numpy, pixel-identical to Blender; `qa`), `pose_natural_fit.py` + `apply_pose_keys.py`, `pose_naturalness.py`, `body_part_raster.py` + `body_part_qa.py`, `arm_*.py` (arm fit, mirror of the left arm in action 17) | +| Tests | `run_qa.py [--quick]` (regression with thresholds, `docs/qa/baselines.md`), `test_items.py`, `test_canvas.py`, `test_autofit.py`, `test_robe.py`, `test_cloak.py`, `test_materials.py`, `test_import_item.py`, `test_weapons.py`, `test_weapon_roll.py` (weapon tests need client sprites; only katana 627 is in the repo) | +| Calibration and analysis | `weapon_*.py`, `robe_calib.py`, `cloak_calib.py`, `cloak_fit_frames.py`, `layer_analysis*.py`, `light_*.py`, `slot_dynamics.py` | +| Environment | `sync_blend_scripts.py` (scripts into the `.blend`), `run_render_headless.py`, `build_originals.py` / `pack_originals.py`, `export.py`, `rig_*.py` (one-off rig changes) | + +## 3. Modelling an item + +1. **Open `UO_Body_0x190.blend`**, allow scripts (Preferences → Save & Load → *Auto Run Python Scripts*). +2. **Rest pose:** select `UO_Rig` → Object Data Properties → Pose → *Rest Position*; model on the A-pose, then back to *Pose Position*. +3. **Add the item** (model, or import / append) into the **`Clothing`** collection; everything in it is rendered. Delete `Example_Shirt` or disable it. + **A foreign model:** in `uo_import_item.py` set `FILE` and `KIND` (`shirt`, `plate`, `arms`, `pants`, `legs`, `boots`, `gloves`, `helm`, `neck`, `hair`, `beard`, `hat`) and run it (Alt+P). It drops the file's skeleton and everything but the mesh (`SKIP` for eyes, the model's body, helpers; `TURN = 180` if it faces backwards), places the item at the height of the original UO item of that kind and prints the `PART`. Then `uo_materials.py` (without it shiny metal renders about 55/255 off the UO light), `uo_fit_item.py`, `uo_bind_item.py`. Check the licence. +4. **Things that bend (clothing, armour, helmet, boots, gloves):** + 1. Make every piece that is a separate game item a separate object. 136×120 px shows little detail: up to ~20k vertices is plenty (*Decimate*). + 2. **`uo_fit_item.py`** (optional, in Rest Position): turns the sleeves onto the arms (`MATCH_ARMS`) and pushes parts closer to the skin than `MIN_GAP` (15 mm) out. A push moves its neighbourhood the same way, fading over max(`RADIUS` 4 cm, `SPREAD` × push), so folds and rivets go with it. Subdivide big faces first. Running it again changes nothing; `MAX_GAP > 0` also pulls standing-off parts in. + 3. In **`uo_bind_item.py`** set `PART` and run it (Alt+P): every item vertex follows the skin right under it (`MAP = "under"`), weights smoothed (`SMOOTH = 4`). Run it again after every shape change. + + | `PART` | Item | What it follows | |---|---|---| - | `"chest"` | napierśnik, kamizelka, tunika | skóra pod spodem; udo przy biodrze w 70% za miednicą, rękawy za ręką | - | `"torso"` | coś tylko na tułowiu | pelvis, spine, chest, neck | - | `"shoulders"` | naramienniki | chest, upper_arm | - | `"arms"` | rękawy, osłony ramion | upper_arm, forearm | - | `"gloves"` | rękawice, karwasze | forearm, hand | - | `"legs"` | spodnie, nagolenniki do pasa | pelvis, thigh, shin | - | `"boots"` | buty, nagolenniki | shin, foot | - | `"helm"` | hełm, kaptur, maska | head | - | `"neck"` | obojczyk zbroi, kołnierz | neck, chest, head | - | `"all"` | cała zbroja w jednym obiekcie | skóra pod spodem, wszystkie kości | - | `"cloak"` | peleryna | wisi od klatki piersiowej i **odchyla się do tyłu wokół ramion** zależnie od akcji (bieg, jazda: leci za plecami; tabela z oryginału 468, `docs/qa/cloak_physics.md`) | - | `"robe"`, `"skirt"` | szata, sukienka, spódnica, kilt | do bioder jak skóra; poniżej **miednica**, a nogi wypychają tkaninę per klatka (`cloth_lib.py`, własność `uo_cloth`; `docs/qa/robe_physics.md`) | - | `"hair"`, `"beard"`, `"hat"` | włosy, broda, czapka | sztywno na `head` (w UO włosy i brody są sztywne) | - | `"weapon1h"` | miecz, maczuga, młot, topór 1H, kryss, kilof | kość `weapon1h.R` na prawej dłoni, ruch dopasowany do 13 oryginalnych broni (0,7–1,3 px zamiast 1,4–2,1 px); najpierw `uo_place_weapon.py` z `PART = "weapon1h"` | - | `"weapon"` / `"weapon.L"` | broń bez kalibracji | sztywno na `hand.R` / `hand.L` | - | `"polearm"` (`"staff"`, `"weapon2h"`) | kij, włócznia, oszczep, widły, halabarda, berdysz, laska, kostur | kość `polearm.L` na lewej dłoni, ruch dopasowany do oryginalnych broni (0,6–1,1 px zamiast 5–6 px); najpierw `uo_place_weapon.py` | - | `"axe2h"` | topór dwuręczny, siekiera i młot w lewej dłoni | kość `axe2h.L` (jak wyżej, błąd 1,2–1,7 px) | - | `"shield"` | tarcza | sztywno na `shield.L` (kość tarczy na przedramieniu, ruch jak tarcza z UO) | - | `"bow"` / `"crossbow"` | łuk / kusza | kość `bow.L` na lewej dłoni (błąd 1,6–2,1 px zamiast 3 px) | - | `"quiver"` | kołczan | sztywno na `chest` | - - Dla `"chest"` w linii tego typu ustawiasz, ile wagi kości zostaje na niej: `"thigh": (0.3, 0.1, 0.4)` = przy biodrze 30% za udem - (reszta za miednicą), od 40% długości uda 100%, pomiędzy płynnie. - - 4. Skrypt robi parent do `UO_Rig`, modyfikator *Armature* i wagi, więc przedmiot rusza się razem ze skórą pod nim. - Uruchom go ponownie po każdej zmianie kształtu przedmiotu (stare wagi zostaną zastąpione). - 5. Wersja ręczna (gdy chcesz własne wagi): Ctrl+P → *Armature Deform → With Empty Groups*, wagi pomaluj - albo skopiuj modyfikatorem *Data Transfer* (Vertex Groups, *Nearest Face Interpolated*) i usuń grupy kości, - których przedmiot nie zakrywa. - 6. Skóra przebijająca przedmiot o kilka mm (w podglądzie 3D) nie robi dziur w klatkach: przy renderze ciało zasłania - przedmiot dopiero wtedy, gdy jest przed nim o więcej niż `HOLDOUT_MARGIN` (1 cm, rozdział 4). -5. **Broń, tarcza, włosy** (rzeczy sztywne): `uo_bind_item.py` z `PART = "weapon"`, `"shield"`, `"hair"` itd. - (tabela wyżej). Miecz ustaw w pozycji spoczynkowej tak, żeby rękojeść była w zaciśniętej prawej dłoni, a klinga - wychodziła po stronie kciuka: tak leży broń na oryginalnych klatkach UO. Tarczę postaw pionowo, licem do widoku z - przodu (Numpad 1), zaznacz i uruchom **`uo_place_shield.py`**: sama stanie na zewnętrznej stronie lewego - przedramienia (jak tarcza heater z UO) i dosunie się do ręki na `GAP` = 1 cm; potem `PART = "shield"`. W - renderze tarczy ustaw `BODY_GAP = 0`, żeby się nie wyginała. -6. **Materiał:** Add → Group → **`UO_Look`**, kolor lub teksturę podepnij na wejście *Albedo*. Rzeczy, które w grze mają - przyjmować kolor (hue), rób w odcieniach szarości. -7. **Sprawdź ruch:** Dope Sheet → Action Editor → wybieraj akcje `NN_nazwa` i odtwarzaj (Spacja). Widok z kamery gry: - Numpad 0, kierunek zmieniasz `uo_direction`. -8. **Wskazówki:** - - Ubranie rób ok. 1–2 cm nad skórą. - - Sprawdzaj zwłaszcza ataki, czary i upadki. - - Koszule, tuniki, kurtki z innego manekina (rękawy obok rąk UO, ciało przebija przód): `"conform": {...}` w przepisie `uo_make_item.py` (`pipeline/uo_conform_item.py`): rękawy przestawione na osie rąk, materiał owinięty jak membrana 1,5 cm od skóry (fałdy i UV modelu zostają), wagi jak skóra pod spodem, ale przód i tył piersi (dalej niż 2 cm od skóry ręki) zostawiają 35% wagi ramienia, a render w każdej klatce trzyma go 8 mm od całej skóry (`cloth_lib.conform_push`). Pomiar: `pipeline/item_clearance.py` (przebicia po pchnięciu renderu, rozciągnięcie), `docs/qa/jedi_tunic.md`. - - Szaty, sukienki i spódnice: `PART = "robe"` / `"skirt"` (tkanina wisi od miednicy, nogi ją wypychają, a na to symulacja tkaniny Blendera z kolizją z ciałem: `pipeline/uo_cloth_sim.py`, w `uo_make_item.py` włączona domyślnie dla `robe` / `skirt`, `--no-sim` wyłącza; bez łańcuchów kości). Peleryny: `PART = "cloak"` (`kind: cloak`), odchylenie do tyłu per akcja i klatka z tabeli `cloak_pitch.json` (IoU z oryginałem 468: 0,376 -> 0,507, `docs/qa/cloak_physics.md`); bez falowania boków. - -## 3a. Najszybsza ścieżka: darmowy model -> przedmiot jednym poleceniem - -Bez okna Blendera (`pip install numpy pillow scipy "bpy==4.2.*"`), z katalogu repo: + | `"chest"` | breastplate, vest, tunic | skin under it; thigh near the hip 70% on the pelvis, sleeves on the arms | + | `"torso"` | something on the torso only | pelvis, spine, chest, neck | + | `"shoulders"` | pauldrons | chest, upper_arm | + | `"arms"` | sleeves, arm armour | upper_arm, forearm | + | `"gloves"` | gloves, gauntlets, bracers | forearm, hand | + | `"legs"` | trousers, leg armour | pelvis, thigh, shin | + | `"boots"` | boots, greaves | shin, foot | + | `"helm"` | helmet, mask, stiff hood | head | + | `"hood"` | hood with a cowl | hood: head, cowl: neck and chest; then `uo_hood_cover.py` (recipe `"hood_cover"`) | + | `"neck"` | gorget, collar | neck, chest, head | + | `"all"` | full suit in one object | skin under it, every bone | + | `"cloak"` | cloak | hangs from the chest and **swings back about the shoulders** per action (table `cloak_pitch.json` from the original 468; no side waving) | + | `"robe"`, `"skirt"` | robe, dress, skirt, kilt | skin down to the hips; below, the pelvis and the leg push (`cloth_lib.py`, property `uo_cloth`), then the cloth simulation (3a) | + | `"hair"`, `"beard"`, `"hat"` | hair, beard, cap | rigid on `head` | + | `"weapon1h"` | sword, mace, hammer, 1H axe, pickaxe | bone `weapon1h.R`, fitted to 13 original weapons (0.7-1.3 px error); run `uo_place_weapon.py` first | + | `"weapon"` / `"weapon.L"` | uncalibrated weapon | rigid on `hand.R` / `hand.L`; `weapon.L` puts a 1H item (horn, lantern) in the left hand on the mirrored grip | + | `"polearm"` (`"staff"`, `"weapon2h"`) | staff, spear, halberd | bone `polearm.L` (0.6-1.1 px); `uo_place_weapon.py` first | + | `"axe2h"` | two-handed axe, hatchet in the left hand | bone `axe2h.L` (1.2-1.7 px) | + | `"shield"` | shield | rigid on `shield.L` | + | `"bow"` / `"crossbow"` | bow / crossbow | bone `bow.L` (1.6-2.1 px) | + | `"quiver"` | quiver | rigid on `chest` | + + For `"chest"` a line like `"thigh": (0.3, 0.1, 0.4)` sets how much of a bone's weight stays on it: 30% on the thigh at the hip, 100% from 40% of its length. + 4. Manual way: Ctrl+P → *Armature Deform → With Empty Groups*, paint or copy weights (*Data Transfer*), delete groups of bones the item does not cover. + 5. Skin poking a few mm through the item in the 3D view cuts no holes: the body hides the item only where it is more than `HOLDOUT_MARGIN` (1 cm) in front of it. +5. **Weapon, shield, hair** (rigid): `uo_bind_item.py` with the matching `PART`. Put a sword in Rest Position with the grip inside the right hand and the blade on the thumb side (as on the original frames). Stand a shield upright, face to the front view (Numpad 1), run `uo_place_shield.py` (outside of the left forearm, `GAP` 1 cm), then `PART = "shield"`; render it with `BODY_GAP = 0`. +6. **Material:** Add → Group → **`UO_Look`**, plug the colour or texture into *Albedo*. Make anything that should take a hue in-game greyscale. +7. **Check the motion:** Dope Sheet → Action Editor → pick `NN_name` and play (Space); game view = Numpad 0, direction = `uo_direction`. Check attacks, spells and deaths in particular. Keep clothing 1-2 cm above the skin. + +## 3a. The fast path: a free model -> an item in one command + +No Blender window (`pip install numpy pillow scipy "bpy==4.2.*"`), from the repo folder: ``` -python pipeline/uo_make_item.py --list model.glb # co jest w pliku: siatki, wierzchołki, rozmiar (do wyboru "skip" / "keep") -python pipeline/uo_make_item.py przepis.json --preview # item.blend + preview.png (6 akcji x 3 kierunki x 3 momenty animacji) -python pipeline/uo_make_item.py przepis.json --vd # to samo + wszystkie 35 akcji do clothing.vd (15-30 min) +python pipeline/uo_make_item.py --list model.glb # meshes, vertices, size (to choose "skip" / "keep") +python pipeline/uo_make_item.py recipe.json --preview # item.blend + preview.png (6 actions x 3 directions x 3 moments) +python pipeline/uo_make_item.py recipe.json --vd # the same + all 35 actions into clothing.vd (15-30 min) ``` -Przepis `przepis.json` (wymagane `file` i `kind`; opis wszystkich pól w nagłówku `pipeline/uo_make_item.py`): +Recipe (`file` and `kind` required; every field is described in the header of `uo_make_item.py`): ```json {"name": "gambeson", "file": "models/medieval_shirt.glb", "kind": "shirt", "skip": ["guy"]} -{"name": "miecz", "file": "models/miecz.glb", "weapon": {"class": "sword", "part": "weapon1h"}} +{"name": "sword", "file": "models/miecz.glb", "weapon": {"class": "sword", "part": "weapon1h"}} ``` -Co się dzieje (to są te same skrypty, co w oknie Blendera): import (wyrzuca siatki z `skip`, zostawia `keep`, zmniejsza siatkę ponad 30 tys. wierzchołków, gładkie cieniowanie) -> `uo_materials.py` (wygląd UO) -> -`uo_prepare_item.py` = **`uo_autofit_item.py`** (jednostki, skala i miejsce z kształtu skóry, nie z jednej wysokości na slot; krótkie kurtki i szerokie naramienniki nie są rozciągane; `docs/qa/autofit.md`) -> -zagęszczenie (gęste siatki, żeby się gładko zginało) -> `uo_fit_item.py` (wypchnięcie ze skóry, **miękki limit grubości** `LIMIT`: naramienniki i kołnierze nie są grubsze niż oryginały UO) -> `uo_bind_item.py`. -Broń (`weapon`): `uo_orient_weapon.py` stawia ją pionowo (czubek w górę, płaska strona na +X, długość klasy), potem `uo_place_weapon.py` i `uo_bind_item.py`. -Obok `item.blend` powstaje `qa.json` (`pipeline/item_qa.py`, kilka sekund): ile wierzchołków jest w ciele w ruchu (przenikanie przed wypchnięciem z ciała przez render) i jak daleko przedmiot stoi od skóry (grubość). Skrypt pisze w raporcie `AMBIGUOUS`, gdy dwa rozwiązania (np. przód/tył) pasują prawie tak samo: wtedy sprawdź `preview.png` i ustaw `turn` albo `scale` w przepisie. +Steps (the same scripts as in the Blender window): import (drops `skip`, keeps `keep`, reduces over 30k vertices) → `uo_materials.py` → `uo_prepare_item.py` (**autofit**: units, size and place from the shape of the skin, short jackets and wide pauldrons not stretched; densify; `uo_fit_item.py` with a **soft thickness limit** `LIMIT`; bind). Weapon: `uo_orient_weapon.py` (tip up, flat side on +X, class length), `uo_place_weapon.py`, bind. Next to `item.blend` is `qa.json` (`item_qa.py`): vertices inside the body in motion and the stand-off from the skin. `AMBIGUOUS` means two solutions (front / back) fit almost equally well: check `preview.png`, set `turn` or `scale`. In the Blender window `uo_autofit_item.py` needs `scipy` (install it in Blender's Python console or run `uo_make_item.py` from outside); without it the old placement from one height per slot is used. -W oknie Blendera `uo_autofit_item.py` wymaga `scipy` (Blender go nie ma): `import subprocess, sys; subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'scipy'])` w konsoli Pythona Blendera albo po prostu `uo_make_item.py` z zewnątrz. Bez scipy `uo_import_item.py` wraca do starego ustawiania z jednej wysokości na slot (z ostrzeżeniem), a `uo_prepare_item.py` pomija dopasowanie. +**Special recipe fields:** +- `"conform"` (`uo_conform_item.py`): shirts, tunics and jackets made on another mannequin: sleeves moved onto the arm axes, the item wrapped like a membrane 1.5 cm off the skin (folds and UVs kept), the render keeps it 8 mm off the skin in every frame. Measure with `item_clearance.py`. +- A model of several items with a mannequin: `"reference": {"keep": ["=mannequin mesh"], "kind": "shirt"}` fits the mannequin to the body and every entry of `"parts"` gets its transform (`{"name": "straps", "keep": [...], "kind": "harness"}`, `{"name": "sword", "keep": [...], "rigid": "quiver"}`; `=name` = exact mesh name). Things on the back get `uo_behind_torso` and `uo_no_body_gap`. Thin parts vanish at 36 px/m: `"widen": {"width": 0.07}` (straps), `"skin_wrap": {"GAP": 0.008}` (straps laid on the skin), `"thicken": {"width": 0.085}` (a flat scabbard seen edge-on). +- `"scene": {...}` = per-item render options (section 4), `"sim": {...}` = cloth simulation settings, `"prepare"`, `"materials"`, `"holes"`, `"bake_normal"` / `"bake_ao"` as in the header of `uo_make_item.py`. -Model z kilku przedmiotów i manekinem w pliku (np. uprząż z mieczem na plecach): `"reference": {"keep": ["=nazwa siatki manekina"], "kind": "shirt"}` dopasowuje manekina do ciała, a jego przekształcenie dostają wszystkie `"parts"` -(`{"name": "paski", "keep": [...], "kind": "harness"}`, `{"name": "miecz", "keep": [...], "rigid": "quiver"}`; `=nazwa` = dokładna nazwa siatki). Rzeczy sztywne na plecach dostają własności `uo_behind_torso` (z przodu zasłania je tułów) i `uo_no_body_gap` (nie są odginane od kończyn). +**Loose garments (`kind`: `robe`, `skirt`).** The cloth hangs from the pelvis and Blender's cloth simulation (`pipeline/uo_cloth_sim.py`, on by default in `uo_make_item.py`; `"sim": {...}` = its settings, `--no-sim` = off) lets the legs push it out like real fabric. Every vertex is held by a weak spring (pin `goal`) to a kinematic target = the item as the skeleton skins it + `pushw` × the leg push (`cloth_lib.hull_push`); the cloth collides with the posed body; the result is stored as the difference to the full target (`cloth_sim.npz`) and added by `render_uo_layer.py`. The target need not match the original robe sprite 469: the judge is physics (collisions, hem) and the look. (The leg hull alone, one frame at a time, gave lower-body IoU 0.641 → 0.757 against the original 469.) -**Luźne ubrania (`kind`: `robe`, `skirt`).** Tkanina wisi od miednicy, a nogi **wypychają ją tam, gdzie sięgają** (powłoka nóg, namiot od pasa, jedna klatka bez pamięci: bez przeskoków i bez przenikania), -wypchnięcie 5 cm i 0,8 drogi do nóg jak w oryginalnych szatach UO. Pomiar na oryginale 469: IoU dolnej części 0,641 -> 0,757 (`docs/qa/robe_physics.md`). Własność `uo_cloth` ustawia `uo_bind_item.py` (`PART` `robe` / `skirt`), a stosuje `render_uo_layer.py`. +Settings that worked for a fine mesh (24k vertices; set **t1**, not yet the defaults of `uo_cloth_sim.py`, give them in `"sim"`): `mscale 0.0333 goal 0.2 pushw 0.5 gravity 1.5 in_max -1 max_move 0 smooth 0.04 arm_goal 0.95 preroll 20 cycles 2 thick 0.02 dist 0.005`; riding: `mounted 1 horse 1` (the horse proxy is a collider). Blender's mass and stiffness depend on the vertex count, so `mscale` scales the per-vertex mass to the mesh. A robe made by a cloth simulator (a closed solid of separate panels, hundreds of thousands of vertices) also needs `"outer_shell": 0.006` (voxel remesh fuses the panels), `"prepare": {"HEM": 0.15}` and `"HEM_BAND"` (hem above the feet, a continuous trim), `"conform": {"CLOTH_KAPPA": 1.0, "CLOTH_MARGIN": 0.06, "CLOTH_DROP": 0.3, "SLEEVE_CUFF": 0.05}` (wrapped round the body, hangs from a knee, bell sleeves narrowed so the hands show), `"sim": {"arm_goal": 0.95, "smooth": 0.04}`, `"materials": {"GREY_CLOTH": {...}}` (grey like UO robes), `"scene": {"uo_cloth_lie": 1.0, "uo_hide_erode": 1, "uo_legs_under": 1, "uo_outline": 0.6}`; a torn robe keeps the holes of the model (`"prepare": {"HEM_FLARE": {"factor": 0.65}}` narrows a strongly flared skirt). -## 4. Render do klatek i pliku `.vd` +| `fabric` | Mass | Bending | Damping | Run | +|---|---|---|---|---| +| `silk` | 0.15 | 0.05 | 1 | full 35 actions × 5 directions | +| `cotton` | 0.3 | 0.5 | 5 | full 35 actions × 5 directions | +| `wool` | 0.5 | 2 | 10 | full 35 actions × 5 directions | +| `denim` | 1.0 | 10 | 25 | not run | +| `leather` | 0.4 | 150 | 25 | not run | -1. Silnik renderowania: **Cycles** (skrypt i tak sam go ustawi, razem z 1 próbką na piksel). -2. Otwórz tekst **`render_uo_layer.py`** i ustaw opcje na początku: +In-plane stiffness (tension, compression, shear) is 5 for all fabrics: Blender's presets (15-80) are pulled up by the pin and the hem climbed (cotton +0.26 m, wool +0.47 m in the walk; at 5: +0.04 / +0.05 m, silk +0.04). Free cloth without the pin (goal 0) falls apart or lifts the hem; the collision margin 0.02 m (`thick`) is needed, 0.006 m lets the legs through (leg pixels through the cloth in walk + run, silk: 723 with t1 against 1862 thin). Reports: `docs/qa/robe_variants_2026-10.md` / `.pdf` (what was tried, with measurements), `docs/qa/robe_variants_screenshots_2026-10.pdf` (renders of every variant with its settings). Remaining: legs show in the falls (die forward), the skirt is about 1.7× wider at the hem than the original robe 469, sleeves about 10% thicker. + +## 4. Rendering frames and the `.vd` file + +1. Engine: **Cycles** (the script sets it: items 512 adaptive samples, body layer 1). +2. Open **`render_uo_layer.py`** and set the options at the top: ```python - LAYER = "clothing" # "clothing" = warstwa przedmiotu, "body" = ciało, "all" = podgląd razem - CANVAS = (256, 256) # rozmiar renderu w px (szer., wys.); .vd i tak przycina każdą klatkę do zawartości - ANCHOR = (128, 192) # punkt zaczepienia w płótnie. 256×256 / (128, 192) mieści 444 z 449 animacji ludzi i ekwipunku - # klienta Nelderim (poza nim: latarnia, epolety z papugą). Stary rozmiar: (136, 120) i (68, 86) - ONLY = ["04_stand"] # test jednej akcji; [] = wszystkie 35 akcji - OUTLINE = 0.38 # ciemny kontur 1 px jak w UO (1.0 = bez konturu) - OUT_DIR = "//uo_render/" # folder obok pliku .blend - WRITE_VD = True - VD_FILE = "//uo_render/%s.vd" - HORSE_HOLDOUT = True # akcje konne: koń zasłania przedmiot - EXACT_BODY = True # docinanie po obrysie oryginalnego ciała - EXACT_COLORS = True # kolory ciała z oryginału (LAYER = "body" / "all") - HOLDOUT_MARGIN = 0.01 # ciało zasłania przedmiot, gdy jest przed nim o > 1 cm (płytkie przebicia skóry - # nie robią dziur); 0 = zwykły holdout Cycles - OCCLUDERS = [...] # części ciała, które mogą zasłaniać przedmiot: ręce, dłonie, głowa, nogi; - # tułów nigdy (przedmioty leżą na nim) - OWN_PARTS_NEVER_HIDE = True # części ciała, do których przedmiot jest oskórowany (spodnie: uda, golenie), nigdy go nie - # zasłaniają: przedmiot je okrywa, a ich skóra przed powłoką obcinała paski po bokach - DESPECKLE = 28 # pojedyncze ciemne kropki w środku przedmiotu (głębokie detale, nity) dostają kolor - # otoczenia (0 = wył.) - FILL_HOLES = 4 # dziurki do 4 px otoczone przedmiotem są wypełniane (0 = wył.) - BODY_GAP = 0.006 # w każdej klatce części ubrania bliżej niż 6 mm rąk, dłoni, nóg lub głowy są wypychane - # na zewnątrz - ręka przebijająca rękaw w ruchu nie robi dziury (0 = wył.) - MIN_PIECE = 8 # oderwane kawałki przedmiotu mniejsze niż 8 px (brzeg kołnierza / mankietu uciętego - # przez głowę lub dłoń) są usuwane; największy kawałek zostaje zawsze (0 = wył.) + LAYER = "clothing" # "clothing" = item layer, "body" = body, "all" = preview of both + CANVAS = (256, 256) # render size; the .vd crops every frame to its content anyway + ANCHOR = (128, 192) # anchor pixel (256x256 / (128, 192) holds 444 of 449 people and equipment animations; old size (136, 120), (68, 86)) + ONLY = ["04_stand"] # test one action; [] = all 35 + OUTLINE = 0.38 # dark 1-px outline of the body (1.0 = none); items: OUTLINE_TARGET 0.38; scene property uo_outline overrides + SAMPLES = 512 # Cycles samples for items; scene property uo_samples overrides + EDGE_FLOOR = 0 # the outline of an item is never darker than this (0-255); scene property uo_edge_floor + OUT_DIR = "//uo_render/"; WRITE_VD = True; VD_FILE = "//uo_render/%s.vd" + HORSE_HOLDOUT = True # mounted actions: the horse hides the item + EXACT_BODY = True # cut along the original body outline + EXACT_COLORS = True # body colours from the original + HOLDOUT_MARGIN = 0.01 # the body hides the item where it is > 1 cm in front of it (0 = plain Cycles holdout) + OCCLUDERS = [...] # parts that may hide the item: arms, hands, head, legs; never the torso + OWN_PARTS_NEVER_HIDE = True # parts the item is skinned to never hide it + DESPECKLE = 0 # N > 0: single dark dots inside the item take the colour around them (erased texture detail: off) + FILL_HOLES = 4 # holes up to 4 px surrounded by the item are filled + BODY_GAP = 0.006 # every frame, item parts closer than 6 mm to arms, hands, legs, head are pushed out + MIN_PIECE = 8 # detached bits under 8 px are removed; the biggest piece stays ``` -3. Uruchom **Run Script** (Alt+P). Pełna warstwa to 1050 klatek, ok. 15–30 min na CPU. Warstwa ubrania renderuje się - bez ciała, a to, co ciało zasłania, skrypt liczy z głębokości (z `HOLDOUT_MARGIN = 0` każda klatka renderuje się - dwa razy: z ciałem i bez). -4. Wynik w `uo_render/`: - - `clothing/frames/NN_akcja/dirK/NN.png`: klatki na płótnie `CANVAS` (domyślnie 256×256), - - `clothing/meta.json`: kolejność i punkt zaczepienia, - - **`clothing.vd`**: gotowy plik. - -**Render w częściach.** Kolejne przebiegi do tego samego `OUT_DIR` się sumują. Możesz np. ustawić broń pod ataki -i wyrenderować `ONLY = ["09_attack_1h_slash", ...]`, potem zmienić ułożenie i wyrenderować pozostałe akcje. Każdy przebieg -zapisuje `.vd` ze wszystkimi akcjami wyrenderowanymi do tej pory. Żeby zacząć od zera, usuń folder `uo_render/`. - -**Przerwanie renderu:** w oknie Blendera naciśnij **ESC** (postęp widać w pasku stanu na dole; okno nie zawiesza się, ale na czas -renderu blokuje inne akcje). W trybie tła (`blender -b`, moduł `bpy`) utwórz pusty plik `STOP` w folderze wyjściowym -(np. `uo_render/clothing/STOP`) albo naciśnij Ctrl+C. Gotowe klatki PNG zostają. - -**Kilka przedmiotów:** każdy renderuj osobno. W `Clothing` zostaw jeden przedmiot i zmień `VD_FILE`, np. -`"//uo_render/helm.vd"`. - -## 5. Docinanie - -- **Zasłanianie przez ciało:** warstwa ubrania zawiera tylko to, czego ciało nie zasłania, tak jak w UO. - Z `EXACT_BODY = True` granica biegnie dokładnie po obrysie oryginalnego ciała. Model 3D decyduje tylko, co jest przed, - a co za ciałem, więc przedmiot pasuje do oryginalnego ciała co do piksela. -- **Koń:** w akcjach konnych przedmiot zasłania koń, dokładnie po obrysie z jego klatek. -- **Wygląd UO:** czarne tło pod krawędziami, przezroczystość 0/1 (UO nie obsługuje półprzezroczystości) i ciemny kontur - 1 px (`OUTLINE`). -- **Przycinanie klatek:** `.vd` zapisuje każdą klatkę przyciętą do zawartości, razem z punktem zaczepienia. PNG-i mają - celowo pełne płótno, żeby klatki się pokrywały. Przycięte PNG da `vdtool extract --raw`. -- **`LAYER = "body"`:** z `EXACT_BODY = True` ciało jest identyczne z oryginałem, a z `False` to czysty model 3D - (~98% zgodności). - -## 6. Narzędzie `vdtool` - -`vdtool/vdtool.py` (Python 3.8+, `pip install pillow numpy`) rozpakowuje `.vd` do PNG i pakuje z powrotem w tej samej -kolejności (akcja → kierunek → klatka). Rozpakowanie i spakowanie bez zmian daje plik identyczny bajt w bajt. +3. **Run Script** (Alt+P). A full layer is 1050 frames, 15-30 minutes on a CPU. A clothing layer is rendered without the body; the script works out from depth what the body hides. +4. Output in `uo_render/`: `clothing/frames/NN_action/dirK/NN.png` (full canvas), `clothing/meta.json`, **`clothing.vd`**. -```bash -python vdtool.py info plik.vd # typ, akcje, liczba klatek -python vdtool.py extract plik.vd praca # -> praca/meta.json + praca/frames/NN_akcja/dirK/NN.png -python vdtool.py extract plik.vd praca --raw # klatki przycięte do zawartości -python vdtool.py pack praca nowy.vd # PNG + meta.json -> .vd -python vdtool.py verify plik.vd nowy.vd # co się zmieniło -python mul2vd.py anim.idx anim.mul wynik 701 468 # animacje z plików klienta (np. włosy 701, peleryna 468) -> .vd -``` +**Rendering in parts:** runs into the same `OUT_DIR` add up (e.g. `ONLY = ["09_attack_1h_slash", ...]`, change the placement, render the rest); every run writes a `.vd` of all actions so far. Delete `uo_render/` to start over. **Cancel:** ESC in Blender's window; in background mode create an empty file `STOP` in the output folder (e.g. `uo_render/clothing/STOP`) or press Ctrl+C; finished frames are kept. **Several items:** render each separately (one item in `Clothing`, change `VD_FILE`). -- **Tryb płótna (domyślny):** wszystkie klatki mają ten sam rozmiar i wspólny punkt zaczepienia (`meta.json → anchor`). - Przy pakowaniu każda klatka jest sama przycinana, a środek wyliczany. Najlepszy do edycji i dorysowywania. -- **`--raw`:** oryginalne, przycięte rozmiary. Nie zmieniaj wymiarów ani liczby klatek, nadaje się tylko do retuszu pikseli. +**Per-item render options** (scene properties; in a `uo_make_item.py` recipe under `"scene"`). They change one item, never the body or the light of other items: -**Zasady edycji:** -1. Nie przesuwaj rysunku względem płótna: 1 px na płótnie to 1 px w grze. -2. Przezroczystość jest 0/1: alfa ≥ 128 to piksel widoczny, poniżej to przezroczysty. -3. Każdy blok (akcja + kierunek) ma jedną paletę 256 kolorów 15-bit. Przy > 256 kolorach narzędzie redukuje paletę - (median cut) i ostrzega. Czysta czerń jest zamieniana na prawie czarną, bo `0x0000` to przezroczystość. -4. Elementy barwione hue rysuj w szarości (R = G = B). -5. Klatki możesz dodawać (kolejny numer) i usuwać (od końca). Każdy z 5 kierunków akcji powinien mieć tyle samo klatek. -6. Nie zmieniaj nazw folderów ani `meta.json`. Pusta klatka jest dozwolona. +| Option | Default | What it does | Measured effect | +|---|---|---|---| +| `uo_contrast` | 1 (off) | Contrast curve on the item's own pixels, after the outline: `rgb = pivot + k × (rgb - pivot)`. k > 1: darker creases, brighter lights; the item reads thinner and more painted, like the UO originals | Silk robe, sleeves in walk: brightness spread (std) 31 → 45 (k 1.5) → 52 (k 1.8); original robe 469: 54. Dark pixels around the hands 30% → 55-59% (469: 56%). Gold trim more saturated, mean brightness 109 → 98 (469: 113) | +| `uo_contrast_pivot` | 0.49 | The grey (0..1) unchanged by `uo_contrast` (about the median of a grey robe) | Raise it to keep the mean brightness higher | +| `uo_shade_sleeve` | 0 (off) | Smooth contact shadow where sleeve and torso meet (needs `conform`): the torso next to the arm and the inside of the sleeve darken by up to this share over `uo_shade_width` px, per frame, a gradient, no line | Reads as depth in side view and raised-arm spells; 0.6 slightly strong | +| `uo_shade_body` | 0 (off) | The same next to body parts that hide the item (hands, arms, head) | Very subtle at 0.6 | +| `uo_shade_width` | 3 | Width in px (1 px = 2.8 cm) of both gradients | 3-4 | +| `uo_outline` | auto (0.38) | Strength of the dark 1-px outline (1.0 = none) | Light grey robe: 0.6-0.8 | +| `uo_edge_floor` | 0 | The outline is never darker than this per channel | 24 = no pure black rim on a black item | +| `uo_samples` | 512 | Cycles samples | 1024 for metal gloss, dark leather | +| `uo_inner_line` | 0 | 1-px dark line between sleeve and torso (needs `conform`) | Spread 30 → 40 but reads as a drawn line: not recommended | +| `uo_hide_erode` | 0 | Hands and head hide the item 1 px less | 1 for robes: fewer skin holes at cuffs and collar | +| `uo_legs_under` | 0 | The legs never hide the item | 1 for a long robe | +| `uo_cloth_lie` | 0 | Falls (21, 22): share in which the robe lies along the legs; 0 = the simulation runs there | 1.0 = lies along the legs | -**Format `.vd`** (little-endian): -``` -int16 magic = 6, int16 animType = 0 (high, 22 akcje) | 1 (low, 13) | 2 (people, 35) -indeks: liczba_akcji*5 wpisów {int32 lookup, int32 length, int32 extra} (-1 = brak); blok = akcja*5 + kierunek -blok: uint16 palette[256] (RGB555), int32 frameCount, int32 frameOffset[frameCount] (od pozycji frameCount) -klatka: int16 centerX, centerY; uint16 width, height; serie {uint32 header, byte pixel[header & 0xFFF]}; 0x7FFF7FFF -header: bity 22..31 = (x - centerX) & 0x3FF, bity 12..21 = (y - centerY - height) & 0x3FF, bity 0..11 = długość serii -punkt zaczepienia w klatce = (centerX, centerY + height) -``` +**Base look of a robe or skirt.** `uo_make_item.py` gives `"kind": "robe"` / `"skirt"` the scene `uo_shade_sleeve` 0.4, `uo_shade_width` 4, `uo_contrast` 1.5 unless the recipe's own `"scene"` says otherwise (`"scene": {"uo_contrast": 1}` turns the curve off). Silk robe in walk, sleeve zone: brightness spread 31 → 46 (469: 54), dark share around the hands 38% → 55% (469: 56%). The shadow needs `"conform"`; the contrast does not. Other kinds get nothing. Tried and not useful for sleeves and hands (silk robe, 2026-10): own shadow strength, normal map strength, texture contrast, occlusion shading (spread stays ~31), moving the UO light left, right or above (34-36 and a darker robe): the UO light sits almost on the camera axis, so thin arms get flat shading. -## 7. Import do gry +## 5. Cutting -UOFiddler → **Animations → Animation Edit** → wybierz plik animacji i ID ciała lub przedmiotu → **Import from VD** → -wskaż plik `.vd` → zapisz (Save). Plik `.vd` musi mieć ten sam typ co cel (ciała ludzkie i ich przedmioty mają typ 2, -people). Każdy przedmiot ma osobne ID animacji. +- **Hidden by the body:** a clothing layer contains only what the body does not hide. With `EXACT_BODY = True` the cut follows the original outline; the 3D model only decides what is in front of or behind the body, so the item fits the original body to the pixel. +- **Horse:** in mounted actions the horse hides the item exactly along the outline from its frames. +- **UO look:** black under edges, 0/1 transparency (no semi-transparency), dark 1-px outline (`OUTLINE`). +- **Cropping:** the `.vd` stores every frame cropped with its anchor; the PNGs keep the full canvas so frames line up (`vdtool extract --raw` gives cropped PNGs). +- **`LAYER = "body"`:** `EXACT_BODY = True` = identical to the original; `False` = the pure 3D model (~98% agreement). -## 8. Dokładność +## 6. The `vdtool` tool -- **Tryb dokładny (`EXACT_BODY = True`):** wyrenderowana warstwa ciała jest identyczna z oryginałem (wszystkie 1050 klatek, - sprawdzone `vdtool verify`). -- **Sam model 3D (`EXACT_BODY = False`):** średnia zgodność obrysu (IoU) **0,892** na 1050 klatkach (`body_part_qa.py`), **bez żadnych - korekt kształtu** (same kości). IoU na akcję: `docs/qa/body_parts_after_fingers.json`. Kolory na wspólnych pikselach są dokładne z `EXACT_COLORS`. +`vdtool/vdtool.py` (Python 3.8+, `pip install pillow numpy`; messages in Polish) unpacks a `.vd` into PNGs and packs them back in the same order; unpack + pack without changes gives a byte-identical file. -Różnice to prawie wyłącznie 1-pikselowe paski wzdłuż krawędzi (oryginał rysowano innym modelem 3D). Dużych błędów, np. -ręki w innym miejscu niż na oryginale, nie ma, więc wycięcia w przedmiotach trafiają w rękę. Dla porównania: poprzedni -model bez swoich 1254 korekt miał 0,880, a z nimi 0,979 (ale przedmioty musiały kopiować te korekty). +```bash +python vdtool.py info file.vd # type, actions, frame counts +python vdtool.py extract file.vd work # -> work/meta.json + work/frames/NN_action/dirK/NN.png +python vdtool.py extract file.vd work --raw # frames cropped to their content +python vdtool.py pack work new.vd # PNG + meta.json -> .vd +python vdtool.py verify file.vd new.vd # what changed +python mul2vd.py anim.idx anim.mul out 701 468 # client animations (e.g. hair 701, cloak 468) -> .vd +``` -**Przedmioty UO.** Test replik przedmiotów z `anim.mul` (`test_items.py`: koszula, napierśnik, spodnie, buty, rękawice, hełm) na obecnym szkielecie: -średnia IoU z oryginalnymi klatkami **0,718** (koszula 0,714, napierśnik 0,690, spodnie 0,800, buty 0,768, rękawice 0,557, hełm 0,781; `docs/qa/items_after_fingers.json`). -Błąd kształtu dłoni względem oryginału (piksele modelu poza sprite'em na klatkę): 3,9 -> 1,4 (lewa) i 4,3 -> 2,0 (prawa) po przywróceniu palców. -Nie ma już testów spódnicy, płaszcza i broni (usunięte z szkieletu). +- **Canvas mode (default):** all frames have one size and the anchor of `meta.json`; packing crops each frame. Best for editing. **`--raw`:** original cropped sizes; only for retouching pixels. +- **Rules:** don't move the drawing relative to the canvas (1 px = 1 px in game); transparency is 0/1 (alpha ≥ 128 visible); one palette of 256 15-bit colours per block (more colours: median-cut reduction with a warning; pure black becomes near-black, since `0x0000` is transparent); draw hue-able parts in grey (R = G = B); frames may be added at the end or removed from the end, all 5 directions of an action should have the same count; don't rename folders or edit `meta.json`. -## 9. Jak powstał model +**`.vd` format** (little-endian): +``` +int16 magic = 6, int16 animType = 0 (high, 22 actions) | 1 (low, 13) | 2 (people, 35) +index: actions*5 entries {int32 lookup, int32 length, int32 extra} (-1 = empty); block = action*5 + direction +block: uint16 palette[256] (RGB555), int32 frameCount, int32 frameOffset[frameCount] (from the frameCount position) +frame: int16 centerX, centerY; uint16 width, height; runs {uint32 header, byte pixel[header & 0xFFF]}; 0x7FFF7FFF +header: bits 22..31 = (x - centerX) & 0x3FF, bits 12..21 = (y - centerY - height) & 0x3FF, bits 0..11 = run length +anchor inside the frame = (centerX, centerY + height) +``` + +## 7. Importing into the game + +UOFiddler → **Animations → Animation Edit** → pick the animation file and the body or item ID → **Import from VD** → the `.vd` → Save. The `.vd` must have the same type as the target (human bodies and their items: type 2, people). Each item has its own animation ID. -Ciało to siatka MakeHuman (CC0, mężczyzna) przeniesiona na szkielet UO; kamera UO (rzut ortograficzny, elewacja 28,45°, 36 px/m, zaczep w środku -piksela, podłoga 7 cm pod nim) i 210 póz × 5 kierunków dopasowano do 1050 oryginalnych klatek (własny rasteryzer + skinning jak w Blenderze), -potem kształt siatki dopasowano do obrysów wszystkich klatek, a barki i ramiona lekko zwężono na prośbę użytkownika. Światło UO (jedno, przy kamerze, -Lambert) wyznaczono z klatek i usunięto z koloru, dając albedo. Oryginalne klatki są spakowane w `.blend` jako atlas do trybów dokładnych. -W sesji 14 szkielet zredukowano do 19 kości (patrz rozdział 2). -Pomiary i decyzje: `docs/RAPORT_model3D_UO.txt`, `docs/AUDYT_2026-10-03.md`, `docs/qa/`. +## 8. Accuracy -## 10. Odtworzenie i narzędzia +- **Exact mode (`EXACT_BODY = True`):** the body layer is identical to the original (1050 frames, `vdtool verify`). +- **Pure 3D model:** mean silhouette IoU **0.899** over 1050 frames (`body_part_qa.py`, per action in `docs/qa/body_parts_after_natural_fit.json`) with no shape corrections; 2049 pixels off by ≥ 2 px (real pose errors, `pose_lib.py qa`). The differences are almost only 1-px bands along the edges (the original was drawn from another 3D model), with no large errors such as an arm in another place, so cut-outs of items hit the arm. The previous model scored 0.880 without its 1254 corrections and 0.979 with them, but items had to copy them. +- **UO items:** replicas of items from `anim.mul` on the current rig (`test_items.py`): mean IoU **0.723** (shirt 0.715, plate 0.699, trousers 0.801, boots 0.771, gloves 0.572, helmet 0.782; `docs/qa/items_after_natural_fit.json`). Weapons, robes and cloaks have their own tests (`test_weapons.py`, `test_robe.py`, `test_cloak.py`; all thresholds and last values in `docs/qa/baselines.md`). -Skrypty, którymi zbudowano ciało (wersje 1-13: dopasowanie póz, kształtu, korekt, tkaniny, broni, tarczy) zostały usunięte z repo razem z danymi pośrednimi: -są w historii gita do commitu `b86c314` (`git show b86c314:pipeline/body13/build_v13.py`). Obecny `.blend` jest źródłem prawdy; zmiany robi się na nim -(np. `pipeline/rig_simplify.py`) i opisuje w commicie. Dane wejściowe, które zostały: oryginalne pliki klienta `pipeline/body400.vd`, `pipeline/horse200.vd`, -klatki `client/`, sprite'y przedmiotów `pipeline/body13/mul/` (z `anim.mul`), wyciąg z klienta `client/extract/`. +## 9. History and tools -Żeby odtworzyć oryginalne klatki w `.blend`: `python build_originals.py`, potem `python pack_originals.py --blend ../model/UO_Body_0x190.blend` (w `pipeline/`). -Pomiary: `body_part_raster.py` + `body_part_qa.py` (sylwetka ciała), `test_items.py` (przedmioty), `test_canvas.py` (płótno), `layer_analysis.py`, `light_*.py`, `slot_dynamics.py`. +The body is a MakeHuman mesh (CC0, male) moved onto the UO skeleton. The UO camera and 210 poses × 5 directions were fitted to the 1050 original frames (own rasteriser + skinning like Blender's), then the mesh was fitted to the outlines of all frames, and shoulders and arms were narrowed slightly at the user's request. The UO light was derived from the frames and removed from the colour, giving the albedo. The rig was reduced to 19 UO bones, then fingers and weapon bones came back (54); arms, legs and head were refitted per frame, and finally all poses for natural motion. -## 11. Ograniczenia i częste problemy +The scripts that built the body (versions 1-13) were removed together with their intermediate data; they are in git history up to commit `b86c314`. The current `.blend` is the source of truth: changes are made on it (e.g. `pipeline/rig_simplify.py`) and described in the commit. Remaining input data: `pipeline/body400.vd`, `horse200.vd`, `client/`, item sprites `pipeline/body13/mul/` (from `anim.mul`). Measurements: `body_part_raster.py` + `body_part_qa.py`, `test_items.py`, `test_canvas.py`, `run_qa.py`. -**Ograniczenia** -- Rysunek mięśni jest bardziej miękki niż na sprite'ach, bo tekstura uśrednia wiele klatek. -- Palce i twarz pochodzą z MakeHuman (na klatkach ~60 px ich nie widać). -- Sam model 3D różni się od oryginału głównie 1-pikselowymi paskami na krawędziach (ok. 12% pikseli sylwetki). - Tryby dokładne (`EXACT_BODY`) usuwają to z renderów: ciało jest zawsze oryginalne, model decyduje tylko, co jest - przed, a co za nim. -- Koń to przybliżona bryła do zasłaniania, a nie model do edycji. +## 10. Limitations and common problems -**Częste problemy** +**Limitations:** muscle drawing is softer than on the sprites (the texture averages many frames); fingers and face come from MakeHuman (invisible at ~60 px); the pure model differs from the original by 1-px edge bands (~12% of silhouette pixels), which the exact modes remove; the horse is an approximate occluder, not a model for editing. -| Problem | Rozwiązanie | +| Problem | Fix | |---|---| -| Text Editor jest pusty | Wybierz tekst z listy w nagłówku edytora. Jeśli lista jest pusta, otwórz `UO_Body_0x190.blend` przez File → Open (nie importuj `.glb`/`.fbx` i nie dołączaj ciała do innej sceny). | -| Czarne albo dziwne ciało na renderze | Włącz *Auto Run Python Scripts* i otwórz plik ponownie. Ustaw Cycles. Sprawdź `LAYER`. | -| Tryby dokładne nic nie zmieniają | W pliku nie ma oryginalnych klatek: użyj `.blend` z tego repozytorium albo `pack_originals.py`. | -| Przedmiot przebija się przez ciało | Uruchom `uo_bind_item.py` z właściwym `PART` i zrób przedmiot odrobinę większy. | -| Przedmiot rozciąga się za ręką lub nogą | Ma wagi kości, których nie zakrywa. Uruchom `uo_bind_item.py` z właściwym `PART`. | -| Blender „wisi” przy skrypcie | Przedmiot ma za dużo wierzchołków. Zmniejsz go (*Decimate*). | -| Przedmiot stoi w miejscu | Brak modyfikatora *Armature* albo wag (rozdział 3). | -| Postać „skacze” w `vdtool` | Rysunek przesunięty względem punktu zaczepienia. | -| Poszarpane krawędzie po imporcie | Półprzezroczyste piksele: ustaw alfę 0 albo 255. | -| UOFiddler odrzuca plik | Inny typ animacji niż cel (ciała ludzkie: typ 2). | -| Render trwa długo | Testuj z `ONLY = ["04_stand"]`, pełny render zrób na końcu. | +| The Text Editor is empty | Pick a text from the list in the header. If it is empty, open `UO_Body_0x190.blend` with File → Open (don't import the `.glb`/`.fbx`, don't append the body into another scene). | +| The body renders black or wrong | Enable *Auto Run Python Scripts*, reopen the file, use Cycles, check `LAYER`. | +| Exact modes change nothing | The file has no original frames: use the `.blend` from this repo or `pack_originals.py`. | +| The item cuts into the body | `uo_bind_item.py` with the right `PART`; make the item slightly larger. | +| The item stretches after an arm or leg | It has weights of bones it does not cover: `uo_bind_item.py` with the right `PART`. | +| Blender freezes while the script runs | Too many vertices: *Decimate*. | +| The item stays in place | The *Armature* modifier or weights are missing (section 3). | +| The character "jumps" in `vdtool` | The drawing moved relative to the anchor. | +| Jagged edges after import | Semi-transparent pixels: alpha 0 or 255. | +| UOFiddler rejects the file | Different animation type than the target (human bodies: type 2). | +| Rendering takes long | Test with `ONLY = ["04_stand"]`, full render at the end. | + +## 11. Notes for AI assistants + +``` +PURPOSE : 3D model of UO body 0x190 (male, naked) rebuilt from anim1_0x0190.vd; authors clothing layers (.vd). +VD FORMAT : section 6. Colour 0x0000 = transparent (remap real black to 1). +SPACE : metres, Z up, character faces -Y, floor z = 0, UO anchor = world (0, 0, 0.07). +CAMERA : "UO_Camera", orthographic, elevation 28.45 deg, 36 px/m, 136x120 px, anchor on pixel (68, 86), +0.5 px in x. +DIRECTIONS : UO_Rig["uo_direction"] = d (0..4) rotates the rig by -45 deg * d; 0 = facing camera, 2 = profile facing + left, 4 = facing away; 5..7 are mirrors of 3..1 made by the client. +RIG : 54 bones (section 2). Quaternion rotations, MakeHuman weights. Per-frame girth = pose-bone scale (x, 1, z) on + upper_arm, forearm, hand, thigh, shin, foot, head; forearm/hand/shin/foot have Inherit Scale = None. +ACTIONS : 35 Blender actions "NN_name", props uo_action (0..34), uo_frames; UO frame i -> scene frame 1 + 3*i; + every action keys UO_Rig["uo_action_id"]. +BODY : UO_Body = MakeHuman mesh (13380 verts, UVs), no shape keys. +ITEMS : text "uo_bind_item.py" (PART preset -> allowed bones): nearest skin point restricted to body triangles whose + dominant bone is allowed; MAP "under": skin hit along the item vertex normal (else nearest); weights = body + weights there (allowed bones, renormalised), SMOOTH passes of neighbour averaging. RIGID presets (hair, beard, hat, + weapon, shield, bow, crossbow, quiver): 100 % on one bone. BIND FOLD: PARTS[...][1] = share of a limb bone's weight + kept, the rest goes to the parent bone (hand>forearm>upper_arm>chest, foot>shin>thigh>pelvis, head>neck); a tuple + (share, t0, t1) ramps along the bone. +HOLDOUT : clothing layer = Cycles render without the body; own z-buffer raster of body and items; the body hides a pixel + where depth_body < depth_item - HOLDOUT_MARGIN (0.01 m), using only body triangles whose dominant bone is in + OCCLUDERS (no torso); transparent patches <= FILL_HOLES px surrounded by the item are filled; 8-connected pieces + < MIN_PIECE px other than the biggest are cleared after the UO post-process. +BODY GAP : per UO frame the posed bound items are pushed BODY_GAP out of the posed OCCLUDERS triangles (BVH nearest, 12 rounds, + smoothed over item edges) via shape key 'uo_fix' with the Armature modifier off; removed after the run (also on STOP). +LOOSE CLOTH: items with property uo_cloth: leg push (cloth_lib.hull_push) + cloth_sim.npz from uo_cloth_sim.py (Blender cloth, pin to + the kinematic target, collision with UO_Body); the render adds the delta, then BODY_GAP. +MOUNTED : horse = body 0xC8; rider->horse action pairing 23->0, 24->1, 25..29->2. Objects Horse_a{action}_f{frame} (holdout + proxies, parented to UO_Rig) + text "uo_horse_masks.json" (key "action,frame,dir" -> base64(zlib(packbits(120x136 bool)))). +EXACT : image "UO_Original_Atlas" (1050 original frames, 35 cols x 30 rows of 136x120) + text "uo_original_frames.json" + (base64(zlib(RGBA)) per "action,frame,dir"); scene props uo_tile_col, uo_tile_row, uo_exact drive the projection. +MATERIAL : scene["uo_look"]: 1 = albedo * (0.08 + 0.92 * max(0, N.L)), light fixed to the camera; 0 = plain PBR. +RENDERING : text "render_uo_layer.py" (options: section 4). Cycles, items 512 adaptive samples (body 1), box filter 0.01. Post-process: + premultiply over black, alpha threshold 0.5 -> 0/1, 1-px boundary outline (items OUTLINE_TARGET 0.38). Runs into the + same OUT_DIR accumulate; a file named STOP cancels. Output: frames/NN_action/dirK/NN.png + meta.json + .vd + (anim_type 2, anchor 68,86). +QUALITY : exact mode: identical to the original body; pure model (bones only): mean silhouette IoU 0.899. +RULES : never swap left/right limbs relative to the original frames; new layers keep the same 35 actions, 5 directions and + frame counts as the body; do not redistribute UO client files or this repository's .blend. +REBUILD : the .blend is the source of truth; build scripts are in git history (commit b86c314), see section 9. +``` diff --git a/third_party/UO_Model3D_v13/README_EN.md b/third_party/UO_Model3D_v13/README_EN.md deleted file mode 100644 index 8527480..0000000 --- a/third_party/UO_Model3D_v13/README_EN.md +++ /dev/null @@ -1,470 +0,0 @@ -# UO Body 0x190: a 3D model from Ultima Online animations - -A naked male body (body 0x190 / 400) rebuilt in 3D from the UO client animation file `anim1_0x0190.vd`: -35 actions × 5 directions, 210 frames per direction, 1050 images in total. A realistic body (MakeHuman, CC0) in the -UO character's proportions, a 54-bone rig (19 UO bones plus the fingers and weapon bones), and all 35 animations fitted to the -original frames. It is meant for designing new clothing, armour, hair and weapon layers: the `.blend` renders -new UO frames and writes them straight into a `.vd` file. - -Polish version: [`README.md`](README.md). - -**Contents** -1. [What's included](#1-whats-included) -2. [How the model works](#2-how-the-model-works) -3. [Modelling an item](#3-modelling-an-item) -4. [Rendering frames and the `.vd` file](#4-rendering-frames-and-the-vd-file) -5. [Cutting](#5-cutting) -6. [The `vdtool` tool](#6-the-vdtool-tool) -7. [Importing into the game](#7-importing-into-the-game) -8. [Accuracy](#8-accuracy) -9. [How the model was built](#9-how-the-model-was-built) -10. [Rebuilding the model (pipeline)](#10-rebuilding-the-model-pipeline) -11. [Limitations and common problems](#11-limitations-and-common-problems) -12. [Notes for AI assistants](#12-notes-for-ai-assistants) - ---- - -## 1. What's included - -| File / folder | What it is | -|---|---| -| `UO_Body_0x190.blend` | Main file (Blender 4.2 – 5.2): body, rig, 35 actions, UO camera, clothing-layer scene, horse proxies, scripts. | -| `UO_Body_Texture.png`, `UO_Body_Albedo_dir0..4.png` | Albedo texture (grey like UO skin, lighting removed) and its variants for the 5 UO directions. | -| `example_clothing/` | Example layer (a shirt) in `Example_Shirt_layer.vd`, with a preview over the original body. | -| `vdtool/vdtool.py` | Tool to unpack and repack `.vd` files (section 6). | -| `pipeline/` | Pipeline scripts: render, item fitting, tests, measurements (section 10). | -| `pipeline/body400.vd`, `pipeline/horse200.vd` | Original client files: body 0x190 (`anim1_0x0190.vd`) and horse 0xC8. | -| `client/body_0x190_frames/`, `client/horse_0xC8_frames/` | Original body (1050) and horse (300) frames as PNG + `meta.json` (`vdtool extract`). | - -**Original frames.** The `.blend` contains the original UO client body frames (for the exact modes, -section 2). It is for your own use only; do not share it publicly. -To add them from your own client: copy `anim1_0x0190.vd` to `pipeline/body400.vd`, then run -`python build_originals.py` and `python pack_originals.py --blend ../model/UO_Body_0x190.blend` in `pipeline/`. - -## 2. How the model works - -**Mesh `UO_Body`** -- A realistic body based on MakeHuman (CC0): 13,380 vertices, UVs, fingers, toes, a face. Its proportions (arm, leg and - torso girth, the head) are fitted to the original frames with anatomical constraints. -- Rest pose: A-pose. Units are metres, the character faces −Y, Z is up. **The floor is z = 0.** -- There are no shape corrections (shape keys): the bones alone set the silhouette in every frame, so an item simply - follows the bones. - -**Rig `UO_Rig`** (54 bones: 19 UO bones, 30 fingers and 5 weapon and shield bones, `.L`/`.R` suffixes; only bones that move the character): -- `pelvis → spine → chest → neck → head`, `chest → clavicle → upper_arm → forearm → hand`, `pelvis → thigh → shin → foot`. - `pelvis` is the root and carries the character's translation. Clavicles raise the shoulder; they do not deform the mesh (Deform off). -- `finger1-1` … `finger5-3` (15 bones per hand, `finger1` is the thumb, bone X = curl axis). In every frame the hand is clenched like on the original - (finger and thumb curl fitted to the UO frames). Gloves (`"gloves"`) follow the fingers. -- Item-motion bones: `polearm.L`, `axe2h.L`, `bow.L` (children of `hand.L`), `weapon1h.R` (child of `hand.R`), `shield.L` (child of `forearm.L`), keyed in the 35 actions - (restored at the user's request; they depend only on the hand and forearm, so the fingers do not change them). -- There are no twist, toe or cloth-chain bones (removed in session 14; the weights of the toes went to the foot). - Before the simplification (commit `b86c314`) the rig had 112 bones. -- Weights come from MakeHuman (smooth joints, no candy-wrapper elbows or shoulders). -- **Girth per frame:** the X/Z scale of `upper_arm`, `forearm`, `hand`, `thigh`, `shin`, `foot`, `head` (Y = 1) makes a - limb slightly thicker or thinner where the original needs it. The next bone of the chain does not inherit it - (Inherit Scale = None), so limb lengths do not change. Items bound to these bones get thicker with the skin. - -**Fitted to the frames.** The pose of each of the 210 frames is fitted to all 5 directions of the original at once -(1050 images), with no shape corrections. Weapons, shields and hair are rigid on the hand, forearm and head bones. - -**Animations.** Each UO action is a Blender action `NN_name` (with a fake user). 1 UO frame = 3 scene frames (24 fps), -with smooth interpolation. Walk and run loop. Action properties: `uo_action` (number) and `uo_frames` (frame count). - -| # | action | # | action | # | action | -|---|---|---|---|---|---| -| 0 | walk_unarmed | 12 | attack_2h_bash | 24 | mounted_run | -| 1 | walk_armed | 13 | attack_2h_slash | 25 | mounted_stand | -| 2 | run_unarmed | 14 | attack_2h_pierce | 26 | mounted_attack_1h | -| 3 | run_armed | 15 | combat_advance | 27 | mounted_attack_bow | -| 4 | stand | 16 | spell_directed | 28 | mounted_attack_crossbow | -| 5 | fidget_1 | 17 | spell_area | 29 | mounted_attack_2h | -| 6 | fidget_2 | 18 | attack_bow | 30 | block | -| 7 | combat_idle_1h | 19 | attack_crossbow | 31 | punch | -| 8 | combat_idle_2h | 20 | get_hit | 32 | bow | -| 9 | attack_1h_slash | 21 | die_forward | 33 | salute | -| 10 | attack_1h_pierce | 22 | die_backward | 34 | eat | -| 11 | attack_1h_bash | 23 | mounted_walk | | | - -Left and right match the original (limbs were never swapped). In the mounted actions the rider sits in the air, -because in UO the horse is a separate animation. - -**The `UO_Camera`** sees exactly like the game camera. -- Orthographic, elevation 28.45°, 36 px/m, 136×120 image, anchor on the centre of pixel (68, 86). This is how the original body frames - are stored. `render_uo_layer.py` renders on a bigger canvas (`CANVAS`, default 256×256 with anchor (128, 192), still 36 px/m), - because 136×120 cuts off two-handed weapons, spears and tall headgear (chapter 4). -- The anchor is 7 cm above the floor (`uo_anchor_height`); all frames agree on this. -- The UO direction is the property `uo_direction` (0–4) on `UO_Rig`: 0 faces the camera, 2 is a profile facing left, - 4 faces away. Directions 5–7 are mirrors of 3–1, made by the client. - -**Material and UO lighting.** The UO light (one light at the camera, almost frontal) was estimated from the frames and -separated from the body colour. -- `uo_look = 1` (default): the "UO look", albedo × (0.08 ambient + 0.92 UO light). Renders look like the game frames. -- `uo_look = 0`: plain PBR (Principled BSDF) for editing and game engines. `export.py` uses this (it creates `.glb`/`.fbx` on demand; they are not in the repo). -- The node group **`UO_Look`** gives items the same lighting. The "Skin hue" node optionally tints the skin. -- **Exact modes** (need the original frames in the file and the Cycles engine): - - `EXACT_COLORS`: the body is painted with the original UO frame projected from the camera, so it has exactly the - original colours. - - `EXACT_BODY`: the body layer is identical to the original, and in a clothing layer the body hides the item exactly - along the original outline. - -**Horse (actions 23–29).** The `Horse_Proxy` collection holds an approximate horse volume (body 0xC8) for every mounted -frame. The volume decides **what** is behind the horse; **where** the horse is comes from the exact outline of its -frames (text `uo_horse_masks.json`). The horse therefore hides the rider and items to the pixel. - -**Scripts inside the `.blend`** (Text Editor; pick them from the text list in the editor header): - -| Text | Purpose | -|---|---| -| `render_uo_layer.py` | Renders a layer to frames and `.vd` (run with Alt+P). | -| `uo_materials.py` | Puts the materials of a foreign model (PBR, textures) into the UO look: wires the colour (texture, colour, colour attribute) into the `UO_Look` node, keeps transparency (alpha >= 0.5 = pixel in the sprite), drops PBR parameters (roughness, normal maps, environment reflections), but **metal gets a UO-style highlight** (measured on the plate and helm sprites: `SPEC_STRENGTH` 1.0 × albedo, `SPEC_POWER` 16; plate 2.0/19, mail 0.7/11, see `docs/qa/light_shadow.md`); `SATURATION = 0` gives a grey item for in-game dyeing. Run it after `uo_import_item.py`. | -| `uo_import_item.py` | Brings a foreign (e.g. free) model (`.glb` `.fbx` `.obj` `.dae`…) onto the body: bakes the mesh without the file's skeleton and weights, scales it and puts it at the height of the original UO item of that kind (`KIND`), in `Clothing` (before `uo_fit_item.py`, section 3). | -| `uo_fit_item.py` | Turns the selected item's sleeves onto the arms and pushes it out of the skin (before `uo_bind_item.py`, section 3). | -| `uo_densify_item.py` | Densifies the mesh of the selected item (subdivision without changing the shape, optional `SMOOTH`), so a low-poly item bends smoothly at elbows, knees and hips instead of folding along a few long edges; the target edge length depends on the slot (`EDGE_BY_KIND`, gloves 2 cm, the rest 3-5 cm). Before `uo_fit_item.py`. | -| `uo_prepare_item.py` | One step for a slot (`KIND`): `uo_densify_item.py` -> `uo_fit_item.py` -> `uo_bind_item.py` with that slot's settings (table `SLOTS`). Results and reasons: `docs/qa/slot_geometry.md`. | -| `uo_autofit_item.py` | Units, size and place of the selected item from the shape of the skin of its slot (not from one height per slot): `docs/qa/autofit.md`. Called by `uo_prepare_item.py` (`AUTOFIT`). | -| `uo_orient_weapon.py` | Stands a foreign weapon upright (tip up, flat side on +X, class length: sword 1 m, musket 1.3 m...), before `uo_place_weapon.py`. | -| `cloth_lib.py` | Loose garments (robe, skirt): a hull of the legs and a tent from the waist, per frame, numpy (`docs/qa/robe_physics.md`); used by `render_uo_layer.py` for items with the `uo_cloth` property. | -| `uo_bind_item.py` | Binds the selected item to the body in one run: parent, Armature and weights (section 3). | -| `uo_weapon_bones.py` | Adds the weapon bones (left hand: `polearm.L`, `axe2h.L`, `bow.L`; right hand: `weapon1h.R`) and keys their motion from `weapon_motion.json` (already run in the file; to load the motion again). | -| `uo_place_weapon.py` | Puts the selected weapon (shaft along +Z, tip up) on the grip line of its class, before `uo_bind_item.py`. Model the head (blade, axe) as a flat plate in the XZ plane, wide side on +X; the script turns the weapon about its shaft like the original weapon `REF_ANIM` (e.g. 624 halberd, 613 executioner's axe, 623 cutlass) and the bone then turns it by the measured roll (`weapon_motion.json`, field `roll`). Bows: roll undetermined. | -| `weapon_motion.json` | Data: grip (point, direction, butt position) and motion of the 4 weapon classes (3 in the left hand, 1H in the right), 210 poses each. | -| `uo_place_shield.py` | Puts the selected shield on the left forearm as UO holds it and slides it onto the arm (before `uo_bind_item.py`, section 3). | -| `uo_shield_keys.py` | Replaces the motion of the shield bone `shield.L` in an older file with the latest one (the shield and its binding stay). | -| `weapon_roll_fit.py`, `weapon_roll_lib.py`, `weapon_roll_apply.py`, `weapon_classify.py`, `test_weapon_roll.py` | Measure the roll (turn about the weapon's own axis) of all original weapons of `anim`..`anim5`, classify weapons to bones, write into `weapon_motion.json`, render test of a plate against the sprite. Results: `docs/qa/weapon_roll.md`. | -| `uo_vd_writer.py` | `.vd` writer used by the renderer (don't run it directly). | -| `uo_job.py` | Runs long scripts (render) step by step from a modal operator, so Blender's window does not freeze (progress in the status bar, ESC cancels). Used by `render_uo_layer.py` and `uo_cloth_bake.py` (don't run it directly). | -| `uo_horse_masks.json`, `uo_original_frames.json` | Data: horse outlines and original frames. | - -Tool scripts outside the file (`pipeline/`): `uo_make_item.py` (a free model -> an item in one command, section 3a), `item_sheet.py` / `arm_mirror_action.py` / `arm_sym_fit.py` / `arm_refit.py` / `arm_grid.py` (left arm of an action as the mirror of the right one, fit of an arm to the original frames; `docs/qa/elbow_spell.md`), `item_gif.py` (a contact sheet of frames and an animated GIF of an item on the original body), `item_qa.py` (clipping and thickness of an item in motion), `uo_conform_item.py` / `item_clearance.py` (a shirt as a shell of the body and the clipping in every frame, `docs/qa/jedi_tunic.md`), `item_thickness.py` (stand-off on frames vs the original sprites), `pose_capture.py` / `robe_calib.py` / `cloak_calib.py` / `cloak_fit_frames.py` (calibration of the physics of loose garments on the originals, `docs/qa/robe_physics.md`), `test_autofit.py` / `test_robe.py` / `test_cloak.py` / `run_qa.py` (tests of the autofit and of robes, and one regression with thresholds: `python pipeline/run_qa.py [--quick]`), `sync_blend_scripts.py` (copy scripts into the `.blend`), `run_render_headless.py` (render without GUI), `test_*.py` (tests), `body_part_raster.py` / `body_part_qa.py` (body silhouette vs the original), `light_*.py`, `layer_analysis*.py`, `slot_dynamics.py` (analyses of client frames), `build_originals.py` / `pack_originals.py` (original frames into the `.blend`), `export.py` (glb/fbx), `rig_simplify.py`, `rig_restore_fingers.py`, `rig_restore_weapons.py` (one-off rig changes), `weapon_*.py` and `test_weapons.py` / `test_weapon_roll.py` (weapon calibration and tests; they need sprites from the client, only the katana 627 is in the repo). - -The scripts live in the `.blend` file, not in objects. Always work in `UO_Body_0x190.blend` (File → Open) and bring your -items into it, not the other way round. - -## 3. Modelling an item - -1. **Open `UO_Body_0x190.blend`** and allow scripts: Preferences → Save & Load → *Auto Run Python Scripts*, or - "Allow Execution" in the yellow bar. -2. **Rest pose:** select `UO_Rig` → Object Data Properties (stick-figure icon) → Pose → *Rest Position*. Model on the - body in the A-pose, then switch back to *Pose Position*. -3. **Add the item:** model it or import it (File → Import / Append) and put it into the **`Clothing`** collection. - **A model from outside (e.g. a free one):** in the text **`uo_import_item.py`** set `FILE` (path to a `.glb`, `.fbx`, `.obj`, - `.dae`…) and `KIND` (`shirt`, `plate`, `arms`, `pants`, `legs`, `boots`, `gloves`, `helm`, `neck`, `hair`, `beard`, `hat`) and run it - (Alt+P). It bakes the mesh (without the file's skeleton and weights), throws away everything but the mesh, scales and - places the item so that its height matches the original UO item of that kind on the body (ranges from the original - sprites), and prints the `PART` for `uo_bind_item.py`. The meshes in the file are listed: leave out eyes, the model's body - and helper shapes with `SKIP`. If the item faces backwards set `TURN = 180`. Then **`uo_materials.py`** (the foreign materials into the UO look; without it the model's shiny metal renders about 55/255 off the UO light; metal gets a UO-style highlight), `uo_fit_item.py` and `uo_bind_item.py` - (below). Check the model's licence. Tests: `pipeline/test_import_item.py` (foreign file), `pipeline/test_materials.py` (materials). - Everything in this collection goes into the rendered layer. Delete `Example_Shirt` or disable it in renders. -4. **Clothing, armour, helmet, boots, gloves** (things that bend): the script **`uo_bind_item.py`**. - 1. Make every piece that is a separate item in the game (breastplate, pauldrons, gloves, boots, helmet) a separate - object. A 136×120 px frame shows little detail, so up to about 20k vertices is plenty (reduce a dense mesh - with a *Decimate* modifier). - 2. **Fit to the body** (optional, in Rest Position): select the item and run **`uo_fit_item.py`**. First the - sleeves (`MATCH_ARMS`): if the item was made for arms held differently (lower, more forward, bent at the - elbow), the script finds that pose and turns the sleeves onto the body's arms. Items without sleeves are left - alone. Then parts closer to the skin than `MIN_GAP` (15 mm) or inside the body are pushed out. A push moves the - whole area around it the same way and fades out smoothly over max(`RADIUS` = 4 cm, `SPREAD` × the push), so a - sleeve widens or moves as a whole instead of getting bumps, and folds, rivets and reliefs go with it. Subdivide - big faces first (Edit Mode, A, right click → Subdivide, Number of Cuts 2), as they can cut through the body - between their corners. Running it again changes nothing. `MAX_GAP > 0` also pulls standing-off parts in - (changes the look, off by default). Place and size the item yourself. - 3. Select the item, open the text **`uo_bind_item.py`**, set `PART` (the kind of item) and run it (Alt+P). - Every item vertex follows the skin **right under it** (along its normal, `MAP = "under"`), and the weights - are smoothed over the item (`SMOOTH = 4`). - - | `PART` | Item | What it follows | - |---|---|---| - | `"chest"` | breastplate, vest, tunic | skin under it; thigh near the hip 70% on the pelvis, sleeves on the arms | - | `"torso"` | something on the torso only | pelvis, spine, chest, neck | - | `"shoulders"` | pauldrons | chest, upper_arm | - | `"arms"` | sleeves, arm armour | upper_arm, forearm | - | `"gloves"` | gloves, gauntlets, bracers | forearm, hand | - | `"legs"` | trousers, leg armour up to the waist | pelvis, thigh, shin | - | `"boots"` | boots, greaves | shin, foot | - | `"helm"` | helmet, hood, mask | head | - | `"neck"` | gorget, collar | neck, chest, head | - | `"all"` | full suit in one object | skin under it, every bone | - | `"cloak"` | cloak | hangs from the chest and **swings back about the shoulders** per action (run, riding: streams out behind; table from the original 468, `docs/qa/cloak_physics.md`) | - | `"robe"`, `"skirt"` | robe, dress, skirt, kilt | like the skin down to the hips; below, the **pelvis**, and the legs push the cloth out per frame (`cloth_lib.py`, property `uo_cloth`; `docs/qa/robe_physics.md`) | - | `"hair"`, `"beard"`, `"hat"` | hair, beard, cap | rigid on `head` (UO hair and beards are rigid) | - | `"weapon1h"` | sword, mace, hammer, 1H axe, kryss, pickaxe | bone `weapon1h.R` on the right hand, motion fitted to 13 original weapons (0.7-1.3 px instead of 1.4-2.1 px); run `uo_place_weapon.py` with `PART = "weapon1h"` first | - | `"weapon"` / `"weapon.L"` | uncalibrated weapon | rigid on `hand.R` / `hand.L` | - | `"polearm"` (`"staff"`, `"weapon2h"`) | staff, spear, javelin, pitchfork, halberd, bardiche, crook | bone `polearm.L` on the left hand, motion fitted to the original weapons (0.6-1.1 px instead of 5-6 px); run `uo_place_weapon.py` first | - | `"axe2h"` | two-handed axe, hatchet / hammer in the left hand | bone `axe2h.L` (same, 1.2-1.7 px error) | - | `"shield"` | shield | rigid on `shield.L` (shield bone on the forearm, moves like the UO shield) | - | `"bow"` / `"crossbow"` | bow / crossbow | bone `bow.L` on the left hand (1.6-2.1 px error instead of 3 px) | - | `"quiver"` | quiver | rigid on `chest` | - - For `"chest"` the line of that type sets how much of a bone's weight stays on it: `"thigh": (0.3, 0.1, 0.4)` = 30% on the thigh - at the hip (the rest on the pelvis), 100% from 40% of the thigh length, smooth in between. - - 4. The script parents the item to `UO_Rig`, adds the *Armature* modifier and sets the weights, so the item moves - with the skin under it. Run it again after every change to the item's shape (old weights are replaced). - 5. Manual way (your own weights): Ctrl+P → *Armature Deform → With Empty Groups*, paint the weights or copy them with - a *Data Transfer* modifier (Vertex Groups, *Nearest Face Interpolated*) and delete the groups of bones the item - does not cover. - 6. Skin poking a few mm through the item (in the 3D view) does not cut holes in the frames: when rendering, the body - hides the item only where it is more than `HOLDOUT_MARGIN` (1 cm, section 4) in front of it. -5. **Weapon, shield, hair** (rigid things): `uo_bind_item.py` with `PART = "weapon"`, `"shield"`, `"hair"` etc. (table - above). Place a sword in Rest Position with the grip inside the clenched right hand and the blade on the thumb side: - that is how weapons sit on the original UO frames. Stand a shield upright with its face towards the front view - (Numpad 1), select it and run **`uo_place_shield.py`**: it goes onto the outside of the left forearm (like the UO - heater shield) and slides onto the arm, `GAP` = 1 cm; then `PART = "shield"`. Render a shield with - `BODY_GAP = 0` so it does not bend. -6. **Material:** Add → Group → **`UO_Look`**, and plug your colour or texture into its *Albedo* input. Make anything that - should take a hue in-game in greyscale. -7. **Check the motion:** Dope Sheet → Action Editor → pick the `NN_name` actions and play (Space). Game-camera view: - Numpad 0; change the direction with `uo_direction`. -8. **Tips:** - - Make clothing about 1–2 cm above the skin. - - Check attacks, spells and deaths in particular. - - Shirts, tunics, jackets made on another mannequin (sleeves beside the UO arms, the body through the front): `"conform": {...}` in a `uo_make_item.py` recipe (`pipeline/uo_conform_item.py`): sleeves moved onto the arm axes, the item wrapped like a membrane 1.5 cm off the skin (the model's folds and UVs kept), weights as the skin under it but the front and back of the chest (more than 2 cm from the arm's skin) keep 35% of the arm's weight, and the render keeps it 8 mm off all the skin in every frame (`cloth_lib.conform_push`). Measure: `pipeline/item_clearance.py` (skin through the item after the render's push, stretch), `docs/qa/jedi_tunic.md`. - - Robes, dresses and skirts: `PART = "robe"` / `"skirt"` (the cloth hangs from the pelvis and the legs push it out, and Blender's cloth simulation with collision against the body runs on top: `pipeline/uo_cloth_sim.py`, on by default in `uo_make_item.py` for `robe` / `skirt`, `--no-sim` turns it off; no bone chains). Cloaks: `PART = "cloak"` (`kind: cloak`), swung back per action and frame from the table `cloak_pitch.json` (IoU with the original 468: 0.376 -> 0.507, `docs/qa/cloak_physics.md`); no waving of the sides. - -## 3a. The fast path: a free model -> an item in one command - -No Blender window (`pip install numpy pillow scipy "bpy==4.2.*"`), from the repo folder: - -``` -python pipeline/uo_make_item.py --list model.glb # what is in the file: meshes, vertices, size (to choose "skip" / "keep") -python pipeline/uo_make_item.py recipe.json --preview # item.blend + preview.png (6 actions x 3 directions x 3 moments of the animation) -python pipeline/uo_make_item.py recipe.json --vd # the same + all 35 actions into clothing.vd (15-30 min) -``` - -The recipe (`file` and `kind` are required; every field is described in the header of `pipeline/uo_make_item.py`): - -```json -{"name": "gambeson", "file": "models/medieval_shirt.glb", "kind": "shirt", "skip": ["guy"]} -{"name": "sword", "file": "models/miecz.glb", "weapon": {"class": "sword", "part": "weapon1h"}} -``` - -What happens (the same scripts as in the Blender window): import (drops the meshes of `skip`, keeps `keep`, reduces a mesh over 30k vertices, smooth shading) -> `uo_materials.py` (UO look) -> -`uo_prepare_item.py` = **`uo_autofit_item.py`** (units, size and place from the shape of the skin, not from one height per slot; short jackets and wide pauldrons are not stretched; `docs/qa/autofit.md`) -> -densify (dense meshes, so that it bends smoothly) -> `uo_fit_item.py` (pushed out of the skin, **soft thickness limit** `LIMIT`: pauldrons and collars are no thicker than the original UO items) -> `uo_bind_item.py`. -Weapon (`weapon`): `uo_orient_weapon.py` stands it upright (tip up, flat side on +X, class length), then `uo_place_weapon.py` and `uo_bind_item.py`. -Next to `item.blend` there is `qa.json` (`pipeline/item_qa.py`, a few seconds): how many vertices are inside the body in motion (clipping before the render pushes them out of the body) and how far the item stands off the skin (thickness). The report says `AMBIGUOUS` when two solutions (e.g. front / back) fit almost equally well: check `preview.png` and set `turn` or `scale` in the recipe. - -In the Blender window `uo_autofit_item.py` needs `scipy` (Blender does not ship it): `import subprocess, sys; subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'scipy'])` in Blender's Python console, or simply run `uo_make_item.py` from outside. Without scipy `uo_import_item.py` falls back to the old placement from one height per slot (with a warning) and `uo_prepare_item.py` skips the fit. - -A model of several items with its mannequin in the file (e.g. a harness with a sword on the back): `"reference": {"keep": ["=mannequin mesh name"], "kind": "shirt"}` fits the mannequin to the body and every one of `"parts"` gets its transform -(`{"name": "straps", "keep": [...], "kind": "harness"}`, `{"name": "sword", "keep": [...], "rigid": "quiver"}`; `=name` = the exact mesh name). Rigid things on the back get the properties `uo_behind_torso` (the torso hides them from the front) and `uo_no_body_gap` (they are not bent away from limbs). - -**Loose garments (`kind`: `robe`, `skirt`).** The cloth hangs from the pelvis and the legs **push it out to where they reach** (a hull of the legs, a tent from the waist, one frame at a time with no memory: no jumps, no clipping), -5 cm past the legs and 0.8 of the way, as in the original UO robes. Measured on the original 469: lower-body IoU 0.641 -> 0.757 (`docs/qa/robe_physics.md`). The `uo_cloth` property is set by `uo_bind_item.py` (`PART` `robe` / `skirt`) and applied by `render_uo_layer.py`. - -## 4. Rendering frames and the `.vd` file - -1. Render engine: **Cycles** (the script sets it itself, with 1 sample per pixel). -2. Open the text **`render_uo_layer.py`** and set the options at the top: - ```python - LAYER = "clothing" # "clothing" = item layer, "body" = body, "all" = preview of both - CANVAS = (256, 256) # render size in px (width, height); the .vd crops every frame to its content anyway - ANCHOR = (128, 192) # anchor pixel inside the canvas. 256x256 / (128, 192) holds 444 of the 449 people and equipment - # animations of the Nelderim client (outside: a lantern, a parrot epaulet). Old size: (136, 120), (68, 86) - ONLY = ["04_stand"] # test one action; [] = all 35 actions - OUTLINE = 0.38 # dark 1-px outline like UO art (1.0 = none) - OUT_DIR = "//uo_render/" # folder next to the .blend - WRITE_VD = True - VD_FILE = "//uo_render/%s.vd" - HORSE_HOLDOUT = True # mounted actions: the horse hides the item - EXACT_BODY = True # cut along the original body outline - EXACT_COLORS = True # body colours from the original (LAYER = "body" / "all") - HOLDOUT_MARGIN = 0.01 # the body hides the item where it is > 1 cm in front of it (shallow skin pokes cut - # no holes); 0 = plain Cycles holdout - OCCLUDERS = [...] # body parts that may hide the item: arms, hands, head, legs; never the torso - # (items are worn over it) - OWN_PARTS_NEVER_HIDE = True # body parts the item is skinned to (trousers: thighs, shins) never hide it: the item wraps - # them, and their skin in front of the shell cut 1-px strips off the sides - DESPECKLE = 28 # single dark dots inside the item (deep details, rivets) take the colour around - # them (0 = off) - FILL_HOLES = 4 # holes up to 4 px fully surrounded by the item are filled (0 = off) - BODY_GAP = 0.006 # every frame, parts of the items closer than 6 mm to the arms, hands, legs or head are - # pushed out - an arm poking through a sleeve in motion cuts no hole (0 = off) - MIN_PIECE = 8 # detached bits of the item under 8 px (a collar / cuff rim cut off by the head or a - # hand) are removed; the biggest piece always stays (0 = off) - ``` -3. Run **Run Script** (Alt+P). A full layer is 1050 frames, about 15–30 minutes on a CPU. A clothing layer is rendered - without the body, and the script works out from depth what the body hides (with `HOLDOUT_MARGIN = 0` every frame - is rendered twice: with and without the body). -4. Output in `uo_render/`: - - `clothing/frames/NN_action/dirK/NN.png`: frames on a `CANVAS` canvas (256×256 by default), - - `clothing/meta.json`: frame order and anchor, - - **`clothing.vd`**: the finished file. - -**Rendering in parts.** Runs into the same `OUT_DIR` add up. For example, set the weapon up for attacks and render -`ONLY = ["09_attack_1h_slash", ...]`, then change the placement and render the other actions. Every run writes a `.vd` -with all actions rendered so far. Delete the `uo_render/` folder to start from scratch. - -**Cancelling a render:** press **ESC** in Blender's window (progress is in the status bar at the bottom; the window does not freeze, -but other input is blocked while it renders). In background mode (`blender -b`, the `bpy` module) create an empty file named `STOP` -in the output folder (e.g. `uo_render/clothing/STOP`) or press Ctrl+C. Finished PNG frames are kept. - -**Several items:** render each one separately. Keep one item in `Clothing` and change `VD_FILE`, e.g. -`"//uo_render/helm.vd"`. - -## 5. Cutting - -- **Hidden by the body:** a clothing layer contains only what the body does not hide, as in UO. With `EXACT_BODY = True` - the cut follows the outline of the original body exactly. The 3D model only decides what is in front of or behind the - body, so the item fits the original body to the pixel. -- **Horse:** in the mounted actions the horse hides the item, exactly along the outline from its frames. -- **UO look:** black under edges, 0/1 transparency (UO has no semi-transparency) and a dark 1-px outline (`OUTLINE`). -- **Cropping:** the `.vd` stores every frame cropped to its content, together with the anchor. The PNGs keep the full - canvas on purpose so frames line up. `vdtool extract --raw` gives cropped PNGs. -- **`LAYER = "body"`:** with `EXACT_BODY = True` the body is identical to the original; with `False` it is the pure - 3D model (~98% agreement). - -## 6. The `vdtool` tool - -`vdtool/vdtool.py` (Python 3.8+, `pip install pillow numpy`) unpacks a `.vd` into PNGs and packs them back in the same -order (action → direction → frame). Unpacking and repacking without changes gives a byte-identical file. Its messages -are in Polish. - -```bash -python vdtool.py info file.vd # type, actions, frame counts -python vdtool.py extract file.vd work # -> work/meta.json + work/frames/NN_action/dirK/NN.png -python vdtool.py extract file.vd work --raw # frames cropped to their content -python vdtool.py pack work new.vd # PNG + meta.json -> .vd -python vdtool.py verify file.vd new.vd # what changed -python mul2vd.py anim.idx anim.mul out 701 468 # animations from the client files (e.g. hair 701, cloak 468) -> .vd -``` - -- **Canvas mode (default):** all frames have the same size and share the anchor (`meta.json → anchor`). When packing, - each frame is cropped and its centre computed. Best for editing and drawing. -- **`--raw`:** original cropped sizes. Don't change sizes or frame counts; only for retouching pixels. - -**Editing rules:** -1. Don't move the drawing relative to the canvas: 1 px on the canvas is 1 px in the game. -2. Transparency is 0/1: alpha ≥ 128 is visible, below that it is transparent. -3. Every block (action + direction) has one palette of 256 15-bit colours. With more than 256 colours the tool reduces the - palette (median cut) and warns. Pure black becomes near-black, because `0x0000` means transparent. -4. Draw hue-able parts in grey (R = G = B). -5. You can add frames (next number) and remove them (from the end). All 5 directions of an action should have the same - number of frames. -6. Don't rename folders or edit `meta.json`. An empty frame is allowed. - -**`.vd` format** (little-endian): -``` -int16 magic = 6, int16 animType = 0 (high, 22 actions) | 1 (low, 13) | 2 (people, 35) -index: actions*5 entries {int32 lookup, int32 length, int32 extra} (-1 = empty); block = action*5 + direction -block: uint16 palette[256] (RGB555), int32 frameCount, int32 frameOffset[frameCount] (from the frameCount position) -frame: int16 centerX, centerY; uint16 width, height; runs {uint32 header, byte pixel[header & 0xFFF]}; 0x7FFF7FFF -header: bits 22..31 = (x - centerX) & 0x3FF, bits 12..21 = (y - centerY - height) & 0x3FF, bits 0..11 = run length -anchor inside the frame = (centerX, centerY + height) -``` - -## 7. Importing into the game - -UOFiddler → **Animations → Animation Edit** → pick the animation file and the body or item ID → **Import from VD** → -choose the `.vd` → Save. The `.vd` must have the same type as the target (human bodies and their items are type 2, -people). Each item has its own animation ID. - -## 8. Accuracy - -- **Exact mode (`EXACT_BODY = True`):** the rendered body layer is identical to the original (all 1050 frames, checked - with `vdtool verify`). -- **Pure 3D model (`EXACT_BODY = False`):** mean silhouette IoU **0.892** over 1050 frames (`body_part_qa.py`), **with no shape - corrections** (bones only). IoU per action: `docs/qa/body_parts_after_fingers.json`. Colours on overlapping pixels are exact with `EXACT_COLORS`. - -The differences are almost only 1-pixel bands along the edges (the original was drawn from a different 3D model). -There are no large errors such as an arm in a different place than on the original, so the cut-outs in items hit the -arm. For comparison: the previous model scored 0.880 without its 1254 corrections and 0.979 with them (but items had -to copy those corrections). - -**UO items.** A test of replicas of items from `anim.mul` (`test_items.py`: shirt, plate, trousers, boots, gloves, helmet) on the current rig: -mean IoU with the original frames **0.718** (shirt 0.714, plate 0.690, trousers 0.800, boots 0.768, gloves 0.557, helmet 0.781; `docs/qa/items_after_fingers.json`). -Hand shape error against the original (model pixels outside the sprite per frame): 3.9 -> 1.4 (left) and 4.3 -> 2.0 (right) after the fingers came back. -There are no skirt, cloak or weapon tests any more (their bones were removed). - -## 9. How the model was built - -The body is a MakeHuman mesh (CC0, male) moved onto the UO skeleton; the UO camera (orthographic, elevation 28.45°, 36 px/m, anchor in the pixel centre, -floor 7 cm below it) and 210 poses × 5 directions were fitted to the 1050 original frames (own rasteriser + skinning like Blender's), then the mesh shape -was fitted to the outlines of all frames, and the shoulders and arms were narrowed slightly at the user's request. The UO light (one light near the -camera, Lambert) was derived from the frames and removed from the colour, giving the albedo. The original frames are packed in the `.blend` as an atlas for the exact modes. -In session 14 the rig was reduced to 19 bones (see section 2). Measurements and decisions: `docs/RAPORT_model3D_UO.txt`, `docs/AUDYT_2026-10-03.md`, `docs/qa/`. - -## 10. Rebuilding and tools - -The scripts that built the body (versions 1-13: pose, shape and correction fitting, cloth, weapons, shield) were removed from the repository together with their intermediate data: -they are in git history up to commit `b86c314` (`git show b86c314:pipeline/body13/build_v13.py`). The current `.blend` is the source of truth; changes are made on it -(e.g. `pipeline/rig_simplify.py`) and described in the commit. Input data that remain: the original client files `pipeline/body400.vd`, `pipeline/horse200.vd`, -the frames in `client/`, item sprites `pipeline/body13/mul/` (from `anim.mul`), the client extract `client/extract/`. - -To restore the original frames in the `.blend`: `python build_originals.py`, then `python pack_originals.py --blend ../model/UO_Body_0x190.blend` (in `pipeline/`). -Measurements: `body_part_raster.py` + `body_part_qa.py` (body silhouette), `test_items.py` (items), `test_canvas.py` (canvas), `layer_analysis.py`, `light_*.py`, `slot_dynamics.py`. - -## 11. Limitations and common problems - -**Limitations** -- Muscle drawing is softer than on the sprites, because the texture averages many frames. -- Fingers and the face come from MakeHuman (they cannot be seen on ~60 px frames). -- The pure 3D model differs from the original mostly by 1-pixel bands along the edges (about 12% of the silhouette - pixels). The exact modes (`EXACT_BODY`) remove this from renders: the body is always the original, the model only - decides what is in front of it and what is behind. -- The horse is an approximate occluder, not a model for editing. - -**Common problems** - -| Problem | Fix | -|---|---| -| The Text Editor is empty | Pick a text from the list in the editor header. If the list is empty, open `UO_Body_0x190.blend` with File → Open (don't import the `.glb`/`.fbx` and don't append the body into another scene). | -| The body renders black or wrong | Enable *Auto Run Python Scripts* and reopen the file. Use Cycles. Check `LAYER`. | -| Exact modes change nothing | The file has no original frames: use the `.blend` from this repository or `pack_originals.py`. | -| The item cuts into the body | Run `uo_bind_item.py` with the right `PART`, and make the item slightly larger. | -| The item stretches after an arm or leg | It has weights of bones it does not cover. Run `uo_bind_item.py` with the right `PART`. | -| Blender freezes while the script runs | The item has too many vertices. Reduce it (*Decimate*). | -| The item stays in place | The *Armature* modifier or the weights are missing (section 3). | -| The character "jumps" in `vdtool` | The drawing moved relative to the anchor. | -| Jagged edges after import | Semi-transparent pixels: set alpha to 0 or 255. | -| UOFiddler rejects the file | Different animation type than the target (human bodies: type 2). | -| Rendering takes long | Test with `ONLY = ["04_stand"]`, and do the full render at the end. | - -## 12. Notes for AI assistants - -``` -PURPOSE : 3D model of UO body 0x190 (male, naked) rebuilt from anim1_0x0190.vd; used to author clothing layers (.vd). -VD FORMAT : see section 6. Colour 0x0000 = transparent (remap real black to 1). -SPACE : metres, Z up, character faces -Y, floor z = 0, UO anchor point = world (0, 0, 0.07). -CAMERA : "UO_Camera", orthographic, elevation 28.45 deg, 36 px/m, 136x120 px, anchor on pixel (68, 86), +0.5 px in x. -DIRECTIONS : UO_Rig["uo_direction"] = d (0..4) rotates the rig by -45 deg * d; 0 = facing camera, 2 = profile facing - left, 4 = facing away; 5..7 are mirrors of 3..1 made by the client. -RIG : 54 bones: pelvis (root) spine chest neck head; clavicle/upper_arm/forearm/hand .L/.R; thigh/shin/foot .L/.R - (clavicles do not deform); finger1-1..finger5-3 .L/.R (finger1 = thumb, local X = curl axis). Item bones polearm.L axe2h.L bow.L (hand.L), weapon1h.R (hand.R), shield.L (forearm.L). No twist, toe or cloth-chain bones. - Quaternion rotations, MakeHuman weights. Per-frame girth = pose-bone scale (x, 1, z) on upper_arm, - forearm, hand, thigh, shin, foot, head; forearm/hand/shin/foot have Inherit Scale = None. -ACTIONS : 35 Blender actions "NN_name", props uo_action (0..34) and uo_frames; UO frame i -> scene frame 1 + 3*i. - Every action keys UO_Rig["uo_action_id"]. -BODY : UO_Body = MakeHuman mesh (13380 verts, UVs), no shape keys. Items: text "uo_bind_item.py" (PART preset -> - allowed bones): nearest skin point restricted to body triangles whose dominant bone is allowed; MAP "under": - skin hit along the item vertex normal (else nearest); weights = body weights there (allowed bones only, - renormalised); SMOOTH passes of neighbour averaging over the item. RIGID presets (hair, beard, hat, weapon, shield, bow, - crossbow, quiver): 100 % on one bone (hand.R / hand.L / forearm.L / head / chest). -HOLDOUT : clothing layer = Cycles render without the body; own z-buffer raster of body and items; the body hides a - pixel where depth_body < depth_item - HOLDOUT_MARGIN (0.01 m), using only body triangles whose dominant bone is - in OCCLUDERS (no torso); transparent patches <= FILL_HOLES px fully - surrounded by the item are filled (from the render without the body, else the neighbours' colour); - 8-connected pieces < MIN_PIECE px other than the biggest are cleared after the UO post-process. -BODY GAP : per UO frame (once for all 5 directions) the posed bound items (welded nodes) are pushed BODY_GAP out of - the posed OCCLUDERS triangles (BVH nearest, 12 rounds, displacement smoothed over the item edges) and shown - through shape key 'uo_fix' with the Armature modifier off; removed again after the run (also on STOP). -BIND FOLD : uo_bind_item PARTS[...][1] = share of a limb bone's weight kept; the rest moves to the parent bone - (hand>forearm>upper_arm>chest, foot>shin>thigh>pelvis, head>neck); a tuple (share, t0, t1) ramps the share along the bone; "chest" = - thigh (0.3, 0.1, 0.4). -MOUNTED : horse = body 0xC8; rider->horse action pairing 23->0, 24->1, 25..29->2. Objects Horse_a{action}_f{frame} - (holdout proxies, parented to UO_Rig) + text "uo_horse_masks.json" (key "action,frame,dir" -> - base64(zlib(packbits(120x136 bool)))). Hiding happens only inside the horse silhouette. -EXACT : image "UO_Original_Atlas" (1050 original frames, 35 cols x 30 rows of 136x120) + text - "uo_original_frames.json" (tiles; base64(zlib(RGBA)) per "action,frame,dir"); scene props uo_tile_col, - uo_tile_row, uo_exact drive the projection. Present in this repository's .blend. -MATERIAL : scene["uo_look"]: 1 = albedo * (0.08 + 0.92 * max(0, N.L)), light fixed to the camera; 0 = plain PBR. -RENDERING : text "render_uo_layer.py": LAYER ("clothing" | "body" | "all"), ONLY, OUTLINE = 0.38, WRITE_VD, OUT_DIR, - VD_FILE, HORSE_HOLDOUT, EXACT_BODY, EXACT_COLORS. Cycles, 1 sample, box filter 0.01. Post-process: - premultiply over black, alpha threshold 0.5 -> 0/1, 1-px boundary outline * 0.38. Runs into the same - OUT_DIR accumulate; a file named STOP in the output folder cancels. - Output: frames/NN_action/dirK/NN.png + meta.json (vdtool layout) + .vd (anim_type 2, anchor 68,86). -QUALITY : exact mode: identical to the original body; pure model (bones only): mean silhouette IoU 0.888. -RULES : never swap left/right limbs relative to the original frames; new layers must keep the same 35 actions, - 5 directions and frame counts as the body; do not redistribute UO client files or this repository's .blend. -REBUILD : the .blend is the source of truth; build scripts are in git history (commit b86c314), see section 10. -``` diff --git a/third_party/UO_Model3D_v13/README_PL.md b/third_party/UO_Model3D_v13/README_PL.md new file mode 100644 index 0000000..6ac154a --- /dev/null +++ b/third_party/UO_Model3D_v13/README_PL.md @@ -0,0 +1,306 @@ +**[English version → README.md](README.md)** + +# UO Body 0x190: model 3D z animacji Ultima Online + +Nagi mężczyzna (body 0x190 / 400) odtworzony w 3D z pliku animacji klienta UO `anim1_0x0190.vd`: 35 akcji × 5 kierunków, 1050 obrazków. +Realistyczne ciało (MakeHuman, CC0) w proporcjach postaci UO, szkielet 54 kości i wszystkie 35 animacji dopasowane do oryginalnych klatek. +Służy do projektowania warstw ubrań, zbroi, włosów i broni: plik `.blend` renderuje nowe klatki UO i od razu zapisuje je do `.vd`. + +**Spis treści:** [1 Pliki](#1-pliki) · [2 Model](#2-jak-działa-model) · [3 Modelowanie przedmiotu](#3-modelowanie-przedmiotu) · [3a Szybka ścieżka](#3a-szybka-ścieżka-darmowy-model---przedmiot-jednym-poleceniem) · [4 Render](#4-render-do-klatek-i-pliku-vd) · [5 Docinanie](#5-docinanie) · [6 vdtool](#6-narzędzie-vdtool) · [7 Import do gry](#7-import-do-gry) · [8 Dokładność](#8-dokładność) · [9 Historia i narzędzia](#9-historia-i-narzędzia) · [10 Problemy](#10-ograniczenia-i-częste-problemy) · [11 Dla asystentów AI](#11-uwagi-dla-asystentów-ai) + +--- + +## 1. Pliki + +| Plik / folder | Co to jest | +|---|---| +| `model/UO_Body_0x190.blend` | Główny plik (Blender 4.2 – 5.2): ciało, szkielet, 35 akcji, kamera UO, scena warstwy ubrań, zastępniki konia, skrypty. | +| `model/UO_Body_Texture.png`, `UO_Body_Albedo_dir0..4.png` | Albedo (szare jak skóra UO, bez oświetlenia) i jego warianty dla 5 kierunków. | +| `model/example_clothing/` | Przykładowa warstwa (koszula) jako `.vd` z podglądem na oryginalnym ciele. | +| `model/items/horn/` | Receptury rogu dla prawej i lewej ręki. | +| `vdtool/` | `vdtool.py` rozpakowuje i pakuje `.vd` (rozdział 6), `mul2vd.py` zamienia `anim.mul` klienta. | +| `pipeline/` | Render, dopasowanie przedmiotów, testy, pomiary (rozdział 9). `body400.vd`, `horse200.vd` = oryginalne pliki klienta (body 0x190, koń 0xC8). | +| `client/` | Oryginalne klatki ciała (1050) i konia (300) jako PNG + `meta.json`; `extract/` = dane wyciągnięte z klienta. | +| `docs/qa/` | `baselines.md` (progi QA i ostatnie wartości), JSON-y z pomiarami, raporty o wariantach szaty (rozdział 3a). | + +**Oryginalne klatki.** `.blend` zawiera oryginalne klatki klienta UO (dla trybów dokładnych, rozdział 2). Tylko do własnego użytku, nie udostępniaj. Żeby dodać je ze swojego klienta: skopiuj `anim1_0x0190.vd` do `pipeline/body400.vd`, potem w `pipeline/` uruchom `python build_originals.py` i `python pack_originals.py --blend ../model/UO_Body_0x190.blend`. + +## 2. Jak działa model + +**Siatka `UO_Body`:** MakeHuman (CC0), 13 380 wierzchołków, UV, palce, twarz; proporcje dopasowane do oryginalnych klatek. Poza spoczynkowa: A-pose. Metry, postać patrzy w −Y, Z do góry, **podłoga z = 0**. Brak shape keys: samo kości ustawiają sylwetkę w każdej klatce, więc przedmiot po prostu za nimi podąża. + +**Szkielet `UO_Rig`** (54 kości: 19 kości UO, 30 kości palców, 5 kości broni i tarczy; `.L`/`.R`): +- `pelvis` (korzeń, niesie przesunięcie postaci) → `spine → chest → neck → head`; `chest → clavicle → upper_arm → forearm → hand`; `pelvis → thigh → shin → foot`. Obojczyki unoszą ramię, ale nie odkształcają siatki; mają klucze położenia tylko w 49 z 210 klatek, gdzie wymaga tego sylwetka. +- `finger1-1` … `finger5-3` (15 na rękę, `finger1` = kciuk, X = oś zgięcia): dłoń zaciśnięta jak na oryginale. Rękawice podążają za palcami. +- Kości przedmiotów: `polearm.L`, `axe2h.L`, `bow.L` (na `hand.L`), `weapon1h.R` (na `hand.R`), `shield.L` (na `forearm.L`), z kluczami we wszystkich 35 akcjach. +- Brak kości skrętu, palców stóp i łańcuchów tkaniny. Wagi z MakeHuman (bez „candy-wrapper”). +- **Obwód per klatka:** skala X/Z kości `upper_arm`, `forearm`, `hand`, `thigh`, `shin`, `foot`, `head` (Y = 1, następna kość jej nie dziedziczy) pogrubia lub cieni kończynę tam, gdzie wymaga tego oryginał; przedmioty związane z tymi kośćmi podążają. Głowa ma jedną skalę (1, 1, 0,942) we wszystkich klatkach, więc hełm nie pulsuje. + +**Dopasowanie do klatek.** Poza każdej z 210 klatek jest dopasowana do wszystkich 5 kierunków oryginału naraz, potem przeliczona pod **naturalny ruch przy tych samych sylwetkach** (`pipeline/pose_natural_fit.py`): gładko w czasie, bez zygzaka głowy, bez skręcania i ślizgania ramienia, łokieć jak zawias; żadna klatka nie jest gorsza niż wcześniej. W czarze obszarowym (akcja 17) ręce są symetryczne, a tułów tylko się pochyla. Broń, tarcze i włosy są sztywne na dłoni, przedramieniu i głowie. + +**Animacje.** Każda akcja UO to akcja Blendera `NN_nazwa` (fake user), własności `uo_action` i `uo_frames`; 1 klatka UO = 3 klatki sceny (24 fps), gładka interpolacja. Lewa i prawa strona zgadzają się z oryginałem. W akcjach konnych jeździec siedzi w powietrzu; koń to osobna animacja. + +| # | akcja | # | akcja | # | akcja | +|---|---|---|---|---|---| +| 0 | walk_unarmed | 12 | attack_2h_bash | 24 | mounted_run | +| 1 | walk_armed | 13 | attack_2h_slash | 25 | mounted_stand | +| 2 | run_unarmed | 14 | attack_2h_pierce | 26 | mounted_attack_1h | +| 3 | run_armed | 15 | combat_advance | 27 | mounted_attack_bow | +| 4 | stand | 16 | spell_directed | 28 | mounted_attack_crossbow | +| 5 | fidget_1 | 17 | spell_area | 29 | mounted_attack_2h | +| 6 | fidget_2 | 18 | attack_bow | 30 | block | +| 7 | combat_idle_1h | 19 | attack_crossbow | 31 | punch | +| 8 | combat_idle_2h | 20 | get_hit | 32 | bow | +| 9 | attack_1h_slash | 21 | die_forward | 33 | salute | +| 10 | attack_1h_pierce | 22 | die_backward | 34 | eat | +| 11 | attack_1h_bash | 23 | mounted_walk | | | + +**`UO_Camera`** widzi jak kamera gry: ortogonalna, elewacja 28,45°, 36 px/m, oryginalne klatki 136×120 z punktem zaczepienia w pikselu (68, 86), 7 cm nad podłogą. `render_uo_layer.py` renderuje na większym płótnie (`CANVAS` 256×256, punkt (128, 192)), bo 136×120 ucina broń dwuręczną, włócznie i wysokie nakrycia głowy. Kierunek = własność `uo_direction` (0–4) w `UO_Rig`: 0 patrzy w kamerę, 2 to profil w lewo, 4 patrzy od kamery; 5–7 to odbicia 3–1 robione przez klienta. + +**Materiał i światło UO.** Jedno światło przy kamerze, oszacowane z klatek i oddzielone od koloru ciała. `uo_look = 1` (domyślnie): albedo × (0,08 światła otoczenia + 0,92 światła UO), wygląda jak klatki gry; `uo_look = 0`: zwykły PBR do edycji i silników gier (`export.py` robi `.glb`/`.fbx` na żądanie, nie ma ich w repo). Grupa węzłów **`UO_Look`** daje przedmiotom to samo światło; „Skin hue” barwi skórę. **Tryby dokładne** (potrzebują oryginalnych klatek i Cycles): `EXACT_COLORS` maluje ciało oryginalną klatką rzutowaną z kamery; `EXACT_BODY` robi warstwę ciała identyczną z oryginałem i pozwala ciału zasłaniać przedmiot dokładnie po oryginalnym obrysie. + +**Koń (akcje 23–29).** Kolekcja `Horse_Proxy` ma przybliżoną bryłę konia (0xC8) dla każdej klatki konnej; decyduje **co** jest za koniem, a **gdzie** jest koń, wynika z dokładnego obrysu jego klatek (`uo_horse_masks.json`), więc koń zasłania jeźdźca i przedmioty co do piksela. + +**Skrypty wewnątrz `.blend`** (Text Editor, wybierz z listy w nagłówku). Pracuj zawsze w `UO_Body_0x190.blend` i wnoś do niego swoje przedmioty: + +| Tekst | Do czego | +|---|---| +| `render_uo_layer.py` | Renderuje warstwę do klatek i `.vd` (Alt+P). | +| `uo_import_item.py` | Wprowadza obcy model (`.glb` `.fbx` `.obj` `.dae`) na ciało: piecze siatkę, skaluje do wysokości oryginalnego przedmiotu danego `KIND`, wkłada do `Clothing`. | +| `uo_materials.py` | Obce materiały w wygląd UO: kolor do `UO_Look`, alfa >= 0,5 = piksel, parametry PBR odrzucone, **metal dostaje połysk w stylu UO** (zmierzony na spritach płyty i hełmu); `SATURATION = 0` = szary przedmiot do farbowania w grze. Po `uo_import_item.py`. | +| `uo_densify_item.py` | Dzieli siatkę bez zmiany kształtu, żeby low-poly gładko się giął (krawędź 2 cm rękawice, 3-5 cm reszta). | +| `uo_autofit_item.py` | Jednostki, rozmiar i miejsce z kształtu skóry danego slotu (wymaga `scipy`). | +| `uo_fit_item.py` | Obraca rękawy na ramiona i wypycha przedmiot ze skóry. | +| `uo_prepare_item.py` | Jeden krok na slot (`KIND`): autofit → densify → fit → bind. | +| `uo_bind_item.py` | Wiąże przedmiot z ciałem: rodzic, Armature, wagi (rozdział 3). | +| `uo_hood_cover.py` | Sprowadza kaptur na czaszkę, a kołnierz kaptura na ramiona. | +| `cloth_lib.py` | Luźne ubrania: powłoka nóg i namiot od pasa, per klatka (numpy); wypchnięcie przez nogi w rendererze i cel symulacji tkaniny. | +| `uo_orient_weapon.py`, `uo_place_weapon.py`, `uo_place_shield.py` | Stawiają broń pionowo, kładą ją na linii chwytu jej klasy (obrót wokół drzewca jak oryginalny `REF_ANIM`, potem kość obraca ją o zmierzony roll), kładą tarczę na lewym przedramieniu. Przed `uo_bind_item.py`. | +| `uo_weapon_bones.py`, `uo_shield_keys.py`, `weapon_motion.json` | Kości broni i ich ruch (chwyt i 210 póz 4 klas broni); wczytanie ruchu do starszego pliku. | +| `uo_vd_writer.py`, `uo_job.py` | Zapis `.vd` i runner, który nie zamraża okna Blendera przy długim renderze (nie uruchamiać bezpośrednio). | +| `uo_horse_masks.json`, `uo_original_frames.json` | Dane: obrysy konia i oryginalne klatki. | + +**Narzędzia poza plikiem (`pipeline/`):** + +| Grupa | Skrypty | +|---|---| +| Przedmiot jednym poleceniem | `uo_make_item.py` (3a), `uo_conform_item.py`, `uo_cloth_sim.py`, `item_gif.py` (arkusz klatek i GIF przedmiotu na oryginalnym ciele), `item_sheet.py` | +| Pomiary przedmiotu | `item_qa.py` (przebicia, grubość), `item_clearance.py`, `item_thickness.py`, `item_body_holes.py` (co widzi klient na oryginalnym ciele 400), `item_skin_check.py` (skóra przez przedmiot a w dziurach, stopy osobno) | +| Ciało i pozy | `pose_lib.py` (szkielet w numpy, co do piksela jak Blender; `qa`), `pose_natural_fit.py` + `apply_pose_keys.py`, `pose_naturalness.py`, `body_part_raster.py` + `body_part_qa.py`, `arm_*.py` (dopasowanie ręki, odbicie lewej ręki w akcji 17) | +| Testy | `run_qa.py [--quick]` (regresja z progami, `docs/qa/baselines.md`), `test_items.py`, `test_canvas.py`, `test_autofit.py`, `test_robe.py`, `test_cloak.py`, `test_materials.py`, `test_import_item.py`, `test_weapons.py`, `test_weapon_roll.py` (testy broni potrzebują sprite'ów z klienta; w repo jest tylko katana 627) | +| Kalibracja i analizy | `weapon_*.py`, `robe_calib.py`, `cloak_calib.py`, `cloak_fit_frames.py`, `layer_analysis*.py`, `light_*.py`, `slot_dynamics.py` | +| Środowisko | `sync_blend_scripts.py` (skrypty do `.blend`), `run_render_headless.py`, `build_originals.py` / `pack_originals.py`, `export.py`, `rig_*.py` (jednorazowe zmiany szkieletu) | + +## 3. Modelowanie przedmiotu + +1. **Otwórz `UO_Body_0x190.blend`**, zezwól na skrypty (Preferences → Save & Load → *Auto Run Python Scripts*). +2. **Poza spoczynkowa:** zaznacz `UO_Rig` → Object Data Properties → Pose → *Rest Position*; modeluj na A-pose, potem wróć do *Pose Position*. +3. **Dodaj przedmiot** (zamodeluj albo zaimportuj / dołącz) do kolekcji **`Clothing`**; wszystko w niej jest renderowane. Usuń `Example_Shirt` albo wyłącz. + **Obcy model:** w `uo_import_item.py` ustaw `FILE` i `KIND` (`shirt`, `plate`, `arms`, `pants`, `legs`, `boots`, `gloves`, `helm`, `neck`, `hair`, `beard`, `hat`) i uruchom (Alt+P). Odrzuca szkielet pliku i wszystko poza siatką (`SKIP` dla oczu, ciała modelu, pomocników; `TURN = 180`, gdy patrzy tyłem), stawia przedmiot na wysokości oryginalnego przedmiotu UO tego rodzaju i wypisuje `PART`. Potem `uo_materials.py` (bez niego błyszczący metal renderuje się ok. 55/255 obok światła UO), `uo_fit_item.py`, `uo_bind_item.py`. Sprawdź licencję. +4. **Rzeczy, które się uginają (ubrania, zbroje, hełmy, buty, rękawice):** + 1. Każdy element będący osobnym przedmiotem w grze zrób osobnym obiektem. 136×120 px pokazuje mało szczegółów: do ok. 20 tys. wierzchołków w zupełności wystarczy (*Decimate*). + 2. **`uo_fit_item.py`** (opcjonalnie, w Rest Position): obraca rękawy na ramiona (`MATCH_ARMS`) i wypycha części bliższe skórze niż `MIN_GAP` (15 mm). Wypchnięcie przesuwa swoje otoczenie tak samo, gasnąc na max(`RADIUS` 4 cm, `SPREAD` × wypchnięcie), więc fałdy i nity idą razem. Duże ściany najpierw podziel. Ponowne uruchomienie nic nie zmienia; `MAX_GAP > 0` dociąga też odstające części. + 3. W **`uo_bind_item.py`** ustaw `PART` i uruchom (Alt+P): każdy wierzchołek podąża za skórą tuż pod nim (`MAP = "under"`), wagi wygładzone (`SMOOTH = 4`). Uruchom ponownie po każdej zmianie kształtu. + + | `PART` | Przedmiot | Za czym podąża | + |---|---|---| + | `"chest"` | napierśnik, kamizelka, tunika | skóra pod spodem; udo przy biodrze 70% na miednicy, rękawy na ramionach | + | `"torso"` | coś tylko na tułowiu | pelvis, spine, chest, neck | + | `"shoulders"` | naramienniki | chest, upper_arm | + | `"arms"` | rękawy, zbroja ręki | upper_arm, forearm | + | `"gloves"` | rękawice, karwasze | forearm, hand | + | `"legs"` | spodnie, zbroja nóg | pelvis, thigh, shin | + | `"boots"` | buty, nagolenniki | shin, foot | + | `"helm"` | hełm, maska, sztywny kaptur | head | + | `"hood"` | kaptur z kołnierzem | kaptur: head, kołnierz: neck i chest; potem `uo_hood_cover.py` (receptura `"hood_cover"`) | + | `"neck"` | kryza, kołnierz | neck, chest, head | + | `"all"` | pełny strój w jednym obiekcie | skóra pod spodem, każda kość | + | `"cloak"` | płaszcz | wisi na piersi i **odchyla się do tyłu wokół ramion** per akcja (tabela `cloak_pitch.json` z oryginału 468; bez falowania boków) | + | `"robe"`, `"skirt"` | szata, sukienka, spódnica, kilt | skóra do bioder; niżej miednica i wypchnięcie przez nogi (`cloth_lib.py`, własność `uo_cloth`), potem symulacja tkaniny (3a) | + | `"hair"`, `"beard"`, `"hat"` | włosy, broda, czapka | sztywno na `head` | + | `"weapon1h"` | miecz, buzdygan, młot, topór 1H, kilof | kość `weapon1h.R`, dopasowana do 13 oryginalnych broni (błąd 0,7-1,3 px); najpierw `uo_place_weapon.py` | + | `"weapon"` / `"weapon.L"` | nieskalibrowana broń | sztywno na `hand.R` / `hand.L`; `weapon.L` wkłada przedmiot 1H (róg, latarnia) do lewej ręki na odbitym chwycie | + | `"polearm"` (`"staff"`, `"weapon2h"`) | kij, włócznia, halabarda | kość `polearm.L` (0,6-1,1 px); najpierw `uo_place_weapon.py` | + | `"axe2h"` | topór dwuręczny, toporek w lewej ręce | kość `axe2h.L` (1,2-1,7 px) | + | `"shield"` | tarcza | sztywno na `shield.L` | + | `"bow"` / `"crossbow"` | łuk / kusza | kość `bow.L` (1,6-2,1 px) | + | `"quiver"` | kołczan | sztywno na `chest` | + + Dla `"chest"` linia typu `"thigh": (0.3, 0.1, 0.4)` mówi, ile wagi kości zostaje na niej: 30% na udzie przy biodrze, 100% od 40% jego długości. + 4. Ręcznie: Ctrl+P → *Armature Deform → With Empty Groups*, maluj lub kopiuj wagi (*Data Transfer*), usuń grupy kości, których przedmiot nie zakrywa. + 5. Skóra wystająca o kilka mm przez przedmiot w widoku 3D nie robi dziur: ciało zasłania przedmiot tylko tam, gdzie jest o więcej niż `HOLDOUT_MARGIN` (1 cm) przed nim. +5. **Broń, tarcza, włosy** (sztywne): `uo_bind_item.py` z odpowiednim `PART`. Miecz ustaw w Rest Position z rękojeścią w prawej dłoni i klingą od strony kciuka (tak leży na oryginalnych klatkach). Tarczę ustaw pionowo, licem do widoku z przodu (Numpad 1), uruchom `uo_place_shield.py` (zewnętrzna strona lewego przedramienia, `GAP` 1 cm), potem `PART = "shield"`; renderuj z `BODY_GAP = 0`. +6. **Materiał:** Add → Group → **`UO_Look`**, podłącz kolor lub teksturę do *Albedo*. Wszystko, co ma przyjmować barwę w grze, rób w skali szarości. +7. **Sprawdź ruch:** Dope Sheet → Action Editor → wybierz `NN_nazwa` i odtwórz (Space); widok gry = Numpad 0, kierunek = `uo_direction`. Szczególnie sprawdź ataki, czary i śmierć. Ubranie trzymaj 1-2 cm nad skórą. + +## 3a. Szybka ścieżka: darmowy model -> przedmiot jednym poleceniem + +Bez okna Blendera (`pip install numpy pillow scipy "bpy==4.2.*"`), z folderu repo: + +``` +python pipeline/uo_make_item.py --list model.glb # siatki, wierzchołki, rozmiar (do wyboru "skip" / "keep") +python pipeline/uo_make_item.py recipe.json --preview # item.blend + preview.png (6 akcji x 3 kierunki x 3 momenty) +python pipeline/uo_make_item.py recipe.json --vd # to samo + 35 akcji do clothing.vd (15-30 min) +``` + +Receptura (`file` i `kind` wymagane; każde pole opisane w nagłówku `uo_make_item.py`): + +```json +{"name": "gambeson", "file": "models/medieval_shirt.glb", "kind": "shirt", "skip": ["guy"]} +{"name": "sword", "file": "models/miecz.glb", "weapon": {"class": "sword", "part": "weapon1h"}} +``` + +Kroki (te same skrypty co w oknie Blendera): import (odrzuca `skip`, zostawia `keep`, zmniejsza powyżej 30 tys. wierzchołków) → `uo_materials.py` → `uo_prepare_item.py` (**autofit**: jednostki, rozmiar i miejsce z kształtu skóry, krótkie kurtki i szerokie naramienniki nie są rozciągane; densify; `uo_fit_item.py` z **miękkim limitem grubości** `LIMIT`; bind). Broń: `uo_orient_weapon.py` (czubek do góry, płaska strona na +X, długość klasy), `uo_place_weapon.py`, bind. Obok `item.blend` leży `qa.json` (`item_qa.py`): wierzchołki wewnątrz ciała w ruchu i odstęp od skóry. `AMBIGUOUS` znaczy, że dwa rozwiązania (przód / tył) pasują prawie tak samo: sprawdź `preview.png`, ustaw `turn` albo `scale`. W oknie Blendera `uo_autofit_item.py` wymaga `scipy` (zainstaluj w konsoli Pythona Blendera albo uruchom `uo_make_item.py` z zewnątrz); bez niego jest stare ustawienie z jednej wysokości na slot. + +**Specjalne pola receptury:** +- `"conform"` (`uo_conform_item.py`): koszule, tuniki i kurtki zrobione na innym manekinie: rękawy przesunięte na osie ramion, przedmiot owinięty jak błona 1,5 cm od skóry (fałdy i UV zostają), render trzyma go 8 mm od skóry w każdej klatce. Pomiar: `item_clearance.py`. +- Model kilku przedmiotów z manekinem: `"reference": {"keep": ["=nazwa siatki manekina"], "kind": "shirt"}` dopasowuje manekina do ciała, a każdy wpis `"parts"` dostaje jego transformację (`{"name": "straps", "keep": [...], "kind": "harness"}`, `{"name": "sword", "keep": [...], "rigid": "quiver"}`; `=nazwa` = dokładna nazwa siatki). Rzeczy na plecach dostają `uo_behind_torso` i `uo_no_body_gap`. Cienkie części znikają przy 36 px/m: `"widen": {"width": 0.07}` (paski), `"skin_wrap": {"GAP": 0.008}` (paski położone na skórze), `"thicken": {"width": 0.085}` (płaska pochwa widziana z krawędzi). +- `"scene": {...}` = opcje renderu per przedmiot (rozdział 4), `"sim": {...}` = ustawienia symulacji tkaniny, `"prepare"`, `"materials"`, `"holes"`, `"bake_normal"` / `"bake_ao"` jak w nagłówku `uo_make_item.py`. + +**Luźne ubrania (`kind`: `robe`, `skirt`).** Tkanina wisi od miednicy, a symulacja tkaniny Blendera (`pipeline/uo_cloth_sim.py`, domyślnie włączona w `uo_make_item.py`; `"sim": {...}` = jej ustawienia, `--no-sim` = wyłączona) pozwala nogom wypychać ją jak prawdziwy materiał. Każdy wierzchołek jest trzymany słabą sprężyną (pin `goal`) przy celu kinematycznym = przedmiot skinowany szkieletem + `pushw` × wypchnięcie przez nogi (`cloth_lib.hull_push`); tkanina koliduje z ustawionym ciałem; wynik zapisuje się jako różnica względem pełnego celu (`cloth_sim.npz`) i dodaje go `render_uo_layer.py`. Cel nie musi pasować do sprite'a oryginalnej szaty 469: liczy się fizyka (kolizje, rąbek) i wygląd. (Sama powłoka nóg, jedna klatka bez pamięci, dała IoU dolnej części ciała 0,641 → 0,757 względem oryginału 469.) + +Ustawienia, które zadziałały na gęstej siatce (24 tys. wierzchołków; zestaw **t1**, jeszcze nie domyślne w `uo_cloth_sim.py`, podaj je w `"sim"`): `mscale 0.0333 goal 0.2 pushw 0.5 gravity 1.5 in_max -1 max_move 0 smooth 0.04 arm_goal 0.95 preroll 20 cycles 2 thick 0.02 dist 0.005`; jazda konno: `mounted 1 horse 1` (zastępczy korpus konia jest colliderem). Masa i sztywność w Blenderze zależą od liczby wierzchołków, więc `mscale` skaluje masę wierzchołka do siatki. Szata z symulatora tkaniny (zamknięta bryła z osobnych paneli, setki tysięcy wierzchołków) potrzebuje też `"outer_shell": 0.006` (remesh voxelowy skleja panele), `"prepare": {"HEM": 0.15}` i `"HEM_BAND"` (rąbek nad stopami, ciągła lamówka), `"conform": {"CLOTH_KAPPA": 1.0, "CLOTH_MARGIN": 0.06, "CLOTH_DROP": 0.3, "SLEEVE_CUFF": 0.05}` (owinięta wokół ciała, wisi od kolana, rękawy dzwonowe zwężone, żeby było widać dłonie), `"sim": {"arm_goal": 0.95, "smooth": 0.04}`, `"materials": {"GREY_CLOTH": {...}}` (szara jak szaty UO), `"scene": {"uo_cloth_lie": 1.0, "uo_hide_erode": 1, "uo_legs_under": 1, "uo_outline": 0.6}`; szata podarta zostawia dziury modelu (`"prepare": {"HEM_FLARE": {"factor": 0.65}}` zwęża mocno rozkloszowaną spódnicę). + +| `fabric` | Masa | Zginanie | Tłumienie | Policzone | +|---|---|---|---|---| +| `silk` | 0,15 | 0,05 | 1 | pełne 35 akcji × 5 kierunków | +| `cotton` | 0,3 | 0,5 | 5 | pełne 35 akcji × 5 kierunków | +| `wool` | 0,5 | 2 | 10 | pełne 35 akcji × 5 kierunków | +| `denim` | 1,0 | 10 | 25 | nie liczone | +| `leather` | 0,4 | 150 | 25 | nie liczone | + +Sztywność w płaszczyźnie (tension, compression, shear) wynosi 5 dla wszystkich tkanin: presety Blendera (15-80) są podciągane przez pin i rąbek się unosił (cotton +0,26 m, wool +0,47 m w chodzie; przy 5: +0,04 / +0,05 m, silk +0,04). Wolna tkanina bez pinu (goal 0) rozpada się albo unosi rąbek; potrzebny jest margines kolizji 0,02 m (`thick`), 0,006 m przepuszcza nogi (piksele nóg przez tkaninę w chodzie + biegu, silk: 723 z t1 wobec 1862 przy cienkim). Raporty: `docs/qa/robe_variants_2026-10.md` / `.pdf` (co próbowaliśmy, z pomiarami), `docs/qa/robe_variants_screenshots_2026-10.pdf` (rendery każdego wariantu z ustawieniami). Zostaje: nogi widać w upadkach (die forward), spódnica ok. 1,7× szersza przy rąbku niż w oryginalnej szacie 469, rękawy ok. 10% grubsze. + +## 4. Render do klatek i pliku `.vd` + +1. Silnik: **Cycles** (skrypt ustawia go sam: przedmioty 512 próbek adaptacyjnych, warstwa ciała 1). +2. Otwórz **`render_uo_layer.py`** i ustaw opcje na górze: + ```python + LAYER = "clothing" # "clothing" = warstwa przedmiotu, "body" = ciało, "all" = podgląd obu + CANVAS = (256, 256) # rozmiar renderu; .vd i tak przycina każdą klatkę do zawartości + ANCHOR = (128, 192) # piksel zaczepienia (256x256 / (128, 192) mieści 444 z 449 animacji ludzi i ekwipunku; stary rozmiar (136, 120), (68, 86)) + ONLY = ["04_stand"] # test jednej akcji; [] = wszystkie 35 + OUTLINE = 0.38 # ciemny kontur 1 px ciała (1.0 = brak); przedmioty: OUTLINE_TARGET 0.38; nadpisuje go własność sceny uo_outline + SAMPLES = 512 # próbki Cycles dla przedmiotów; nadpisuje je własność sceny uo_samples + EDGE_FLOOR = 0 # kontur przedmiotu nigdy ciemniejszy niż to (0-255); własność sceny uo_edge_floor + OUT_DIR = "//uo_render/"; WRITE_VD = True; VD_FILE = "//uo_render/%s.vd" + HORSE_HOLDOUT = True # akcje konne: koń zasłania przedmiot + EXACT_BODY = True # docięcie po oryginalnym obrysie ciała + EXACT_COLORS = True # kolory ciała z oryginału + HOLDOUT_MARGIN = 0.01 # ciało zasłania przedmiot tam, gdzie jest > 1 cm przed nim (0 = zwykły holdout Cycles) + OCCLUDERS = [...] # części, które mogą zasłaniać przedmiot: ręce, dłonie, głowa, nogi; nigdy tułów + OWN_PARTS_NEVER_HIDE = True # części, do których przedmiot jest związany, nigdy go nie zasłaniają + DESPECKLE = 0 # N > 0: pojedyncze ciemne kropki w przedmiocie dostają kolor otoczenia (kasowało detale tekstury: wyłączone) + FILL_HOLES = 4 # dziury do 4 px otoczone przedmiotem są zalewane + BODY_GAP = 0.006 # w każdej klatce części przedmiotu bliższe niż 6 mm do rąk, dłoni, nóg, głowy są wypychane + MIN_PIECE = 8 # oderwane kawałki poniżej 8 px są usuwane; największy zostaje + ``` +3. **Run Script** (Alt+P). Pełna warstwa to 1050 klatek, 15-30 minut na CPU. Warstwa ubrań jest renderowana bez ciała; skrypt z głębi liczy, co ciało zasłania. +4. Wynik w `uo_render/`: `clothing/frames/NN_akcja/dirK/NN.png` (pełne płótno), `clothing/meta.json`, **`clothing.vd`**. + +**Render po kawałku:** kolejne uruchomienia z tym samym `OUT_DIR` się sumują (np. `ONLY = ["09_attack_1h_slash", ...]`, zmień ustawienie, wyrenderuj resztę); każdy bieg zapisuje `.vd` ze wszystkimi dotąd zrobionymi akcjami. Usuń `uo_render/`, żeby zacząć od zera. **Przerwanie:** ESC w oknie Blendera; w trybie tła utwórz pusty plik `STOP` w folderze wyjściowym (np. `uo_render/clothing/STOP`) albo Ctrl+C; gotowe klatki zostają. **Kilka przedmiotów:** renderuj każdy osobno (jeden w `Clothing`, zmień `VD_FILE`). + +**Opcje renderu per przedmiot** (własności sceny; w recepturze `uo_make_item.py` pod `"scene"`). Zmieniają jeden przedmiot, nigdy ciało ani światło innych przedmiotów: + +| Opcja | Domyślnie | Co robi | Zmierzony efekt | +|---|---|---|---| +| `uo_contrast` | 1 (wył.) | Krzywa kontrastu na pikselach samego przedmiotu, po konturze: `rgb = pivot + k × (rgb - pivot)`. k > 1: ciemniejsze fałdy, jaśniejsze światła; przedmiot wygląda cieniej i bardziej „malowany”, jak oryginały UO | Szata silk, rękawy w chodzie: rozrzut jasności (std) 31 → 45 (k 1,5) → 52 (k 1,8); oryginał 469: 54. Ciemne piksele wokół dłoni 30% → 55-59% (469: 56%). Złota lamówka bardziej nasycona, średnia jasność 109 → 98 (469: 113) | +| `uo_contrast_pivot` | 0,49 | Szarość (0..1) niezmieniana przez `uo_contrast` (ok. mediana szarej szaty) | Podnieś, żeby zachować wyższą średnią jasność | +| `uo_shade_sleeve` | 0 (wył.) | Gładki cień kontaktowy tam, gdzie rękaw spotyka tułów (wymaga `conform`): tułów obok ręki i wnętrze rękawa ciemnieją do tego ułamka na `uo_shade_width` px, per klatka, gradient, bez linii | Daje głębię z boku i w czarach z uniesionymi rękami; 0,6 lekko za mocno | +| `uo_shade_body` | 0 (wył.) | To samo przy częściach ciała zasłaniających przedmiot (dłonie, ręce, głowa) | Bardzo subtelne przy 0,6 | +| `uo_shade_width` | 3 | Szerokość w px (1 px = 2,8 cm) obu gradientów | 3-4 | +| `uo_outline` | auto (0,38) | Siła ciemnego konturu 1 px (1.0 = brak) | Jasnoszara szata: 0,6-0,8 | +| `uo_edge_floor` | 0 | Kontur nigdy ciemniejszy niż to na kanał | 24 = brak czystej czerni na czarnym przedmiocie | +| `uo_samples` | 512 | Próbki Cycles | 1024 dla połysku metalu, ciemnej skóry | +| `uo_inner_line` | 0 | Ciemna linia 1 px między rękawem a tułowiem (wymaga `conform`) | Rozrzut 30 → 40, ale wygląda jak narysowana linia: niezalecane | +| `uo_hide_erode` | 0 | Dłonie i głowa zasłaniają przedmiot o 1 px mniej | 1 dla szat: mniej dziur ze skórą przy mankietach i kołnierzu | +| `uo_legs_under` | 0 | Nogi nigdy nie zasłaniają przedmiotu | 1 dla długiej szaty | +| `uo_cloth_lie` | 0 | Upadki (21, 22): w jakim stopniu szata leży wzdłuż nóg; 0 = działa tam symulacja | 1.0 = leży wzdłuż nóg | + +**Bazowy wygląd szaty lub spódnicy.** `uo_make_item.py` daje `"kind": "robe"` / `"skirt"` scenę `uo_shade_sleeve` 0,4, `uo_shade_width` 4, `uo_contrast` 1,5, chyba że `"scene"` w recepturze mówi inaczej (`"scene": {"uo_contrast": 1}` wyłącza krzywą). Szata silk w chodzie, strefa rękawa: rozrzut jasności 31 → 46 (469: 54), udział ciemnych pikseli wokół dłoni 38% → 55% (469: 56%). Cień wymaga `"conform"`, kontrast nie. Inne rodzaje nic nie dostają. Próbowane i bezużyteczne dla rękawów i dłoni (szata silk, 2026-10): siła własnego cienia, siła normal mapy, kontrast tekstury, cieniowanie okluzją (rozrzut zostaje ok. 31), przesunięcie światła UO w lewo, w prawo lub do góry (34-36 i ciemniejsza szata): światło UO leży prawie na osi kamery, więc cienkie ręce dostają płaskie cieniowanie. + +## 5. Docinanie + +- **Zasłonięte przez ciało:** warstwa ubrań zawiera tylko to, czego ciało nie zasłania. Z `EXACT_BODY = True` cięcie idzie po oryginalnym obrysie; model 3D decyduje tylko, co jest przed ciałem, a co za nim, więc przedmiot pasuje do oryginalnego ciała co do piksela. +- **Koń:** w akcjach konnych koń zasłania przedmiot dokładnie po obrysie z jego klatek. +- **Wygląd UO:** czerń pod krawędziami, przezroczystość 0/1 (bez półprzezroczystości), ciemny kontur 1 px (`OUTLINE`). +- **Przycinanie:** `.vd` zapisuje każdą klatkę przyciętą z punktem zaczepienia; PNG-i trzymają całe płótno, żeby klatki się pokrywały (`vdtool extract --raw` daje przycięte PNG). +- **`LAYER = "body"`:** `EXACT_BODY = True` = identyczne z oryginałem; `False` = czysty model 3D (~98% zgodności). + +## 6. Narzędzie `vdtool` + +`vdtool/vdtool.py` (Python 3.8+, `pip install pillow numpy`; komunikaty po polsku) rozpakowuje `.vd` do PNG i pakuje z powrotem w tej samej kolejności; rozpakowanie i spakowanie bez zmian daje plik identyczny bajt w bajt. + +```bash +python vdtool.py info plik.vd # typ, akcje, liczby klatek +python vdtool.py extract plik.vd work # -> work/meta.json + work/frames/NN_akcja/dirK/NN.png +python vdtool.py extract plik.vd work --raw # klatki przycięte do zawartości +python vdtool.py pack work nowy.vd # PNG + meta.json -> .vd +python vdtool.py verify plik.vd nowy.vd # co się zmieniło +python mul2vd.py anim.idx anim.mul out 701 468 # animacje klienta (np. włosy 701, płaszcz 468) -> .vd +``` + +- **Tryb płótna (domyślny):** wszystkie klatki mają jeden rozmiar i punkt zaczepienia z `meta.json`; przy pakowaniu każda jest przycinana. Najlepszy do edycji. **`--raw`:** oryginalne przycięte rozmiary; tylko do retuszu pikseli. +- **Zasady:** nie przesuwaj rysunku względem płótna (1 px = 1 px w grze); przezroczystość 0/1 (alfa ≥ 128 widoczne); jedna paleta 256 kolorów 15-bitowych na blok (więcej kolorów: redukcja median cut z ostrzeżeniem; czysta czerń staje się prawie czarną, bo `0x0000` to przezroczystość); części do barwienia rysuj w szarości (R = G = B); klatki można dodawać na końcu lub usuwać od końca, wszystkie 5 kierunków akcji powinno mieć tyle samo; nie zmieniaj nazw folderów ani `meta.json`. + +**Format `.vd`** (little-endian): +``` +int16 magic = 6, int16 animType = 0 (high, 22 akcje) | 1 (low, 13) | 2 (people, 35) +indeks: akcje*5 wpisów {int32 lookup, int32 length, int32 extra} (-1 = pusty); blok = akcja*5 + kierunek +blok: uint16 palette[256] (RGB555), int32 frameCount, int32 frameOffset[frameCount] (od pozycji frameCount) +klatka: int16 centerX, centerY; uint16 width, height; serie {uint32 header, byte pixel[header & 0xFFF]}; 0x7FFF7FFF +header: bity 22..31 = (x - centerX) & 0x3FF, bity 12..21 = (y - centerY - height) & 0x3FF, bity 0..11 = długość serii +punkt zaczepienia w klatce = (centerX, centerY + height) +``` + +## 7. Import do gry + +UOFiddler → **Animations → Animation Edit** → wybierz plik animacji i ID ciała lub przedmiotu → **Import from VD** → `.vd` → Save. `.vd` musi mieć ten sam typ co cel (ciała ludzkie i ich przedmioty: typ 2, people). Każdy przedmiot ma własne ID animacji. + +## 8. Dokładność + +- **Tryb dokładny (`EXACT_BODY = True`):** warstwa ciała identyczna z oryginałem (1050 klatek, `vdtool verify`). +- **Czysty model 3D:** średnie IoU sylwetki **0,899** w 1050 klatkach (`body_part_qa.py`, per akcja w `docs/qa/body_parts_after_natural_fit.json`) bez korekt kształtu; 2049 pikseli o ≥ 2 px obok (prawdziwe błędy pozy, `pose_lib.py qa`). Różnice to prawie tylko pasy 1 px wzdłuż krawędzi (oryginał rysowano z innego modelu 3D), bez dużych błędów, np. ręki w innym miejscu, więc wycięcia przedmiotów trafiają w rękę. Poprzedni model miał 0,880 bez swoich 1254 korekt i 0,979 z nimi, ale przedmioty musiały je kopiować. +- **Przedmioty UO:** repliki przedmiotów z `anim.mul` na obecnym szkielecie (`test_items.py`): średnie IoU **0,723** (koszula 0,715, płyta 0,699, spodnie 0,801, buty 0,771, rękawice 0,572, hełm 0,782; `docs/qa/items_after_natural_fit.json`). Broń, szaty i płaszcze mają własne testy (`test_weapons.py`, `test_robe.py`, `test_cloak.py`; wszystkie progi i ostatnie wartości w `docs/qa/baselines.md`). + +## 9. Historia i narzędzia + +Ciało to siatka MakeHuman (CC0, męska) przeniesiona na szkielet UO. Kamera UO i 210 póz × 5 kierunków zostały dopasowane do 1050 oryginalnych klatek (własny rasteryzer + skinning jak w Blenderze), potem kształt siatki dopasowano do obrysów wszystkich klatek, a ramiona i barki lekko zwężono na prośbę użytkownika. Światło UO wyprowadzono z klatek i usunięto z koloru, co dało albedo. Szkielet zredukowano do 19 kości UO, potem wróciły palce i kości broni (54); ręce, nogi i głowę przeliczono per klatka, a na końcu wszystkie pozy pod naturalny ruch. + +Skrypty, które budowały ciało (wersje 1-13), usunięto razem z danymi pośrednimi; są w historii gita do commitu `b86c314`. Źródłem prawdy jest obecny `.blend`: zmiany robi się na nim (np. `pipeline/rig_simplify.py`) i opisuje w commicie. Pozostałe dane wejściowe: `pipeline/body400.vd`, `horse200.vd`, `client/`, sprite'y przedmiotów `pipeline/body13/mul/` (z `anim.mul`). Pomiary: `body_part_raster.py` + `body_part_qa.py`, `test_items.py`, `test_canvas.py`, `run_qa.py`. + +## 10. Ograniczenia i częste problemy + +**Ograniczenia:** rysunek mięśni jest miększy niż na spritach (tekstura uśrednia wiele klatek); palce i twarz pochodzą z MakeHuman (niewidoczne przy ~60 px); czysty model różni się od oryginału pasami 1 px przy krawędziach (~12% pikseli sylwetki), które tryby dokładne usuwają; koń to przybliżony okluder, nie model do edycji. + +| Problem | Rozwiązanie | +|---|---| +| Text Editor jest pusty | Wybierz tekst z listy w nagłówku. Jeśli lista jest pusta, otwórz `UO_Body_0x190.blend` przez File → Open (nie importuj `.glb`/`.fbx` i nie dołączaj ciała do innej sceny). | +| Czarne albo dziwne ciało na renderze | Włącz *Auto Run Python Scripts*, otwórz plik ponownie, ustaw Cycles, sprawdź `LAYER`. | +| Tryby dokładne nic nie zmieniają | W pliku nie ma oryginalnych klatek: użyj `.blend` z tego repozytorium albo `pack_originals.py`. | +| Przedmiot przebija się przez ciało | `uo_bind_item.py` z właściwym `PART`; zrób przedmiot odrobinę większy. | +| Przedmiot rozciąga się za ręką lub nogą | Ma wagi kości, których nie zakrywa: `uo_bind_item.py` z właściwym `PART`. | +| Blender „wisi” przy skrypcie | Za dużo wierzchołków: *Decimate*. | +| Przedmiot stoi w miejscu | Brak modyfikatora *Armature* albo wag (rozdział 3). | +| Postać „skacze” w `vdtool` | Rysunek przesunięty względem punktu zaczepienia. | +| Poszarpane krawędzie po imporcie | Półprzezroczyste piksele: alfa 0 albo 255. | +| UOFiddler odrzuca plik | Inny typ animacji niż cel (ciała ludzkie: typ 2). | +| Render trwa długo | Testuj z `ONLY = ["04_stand"]`, pełny render na końcu. | + +## 11. Uwagi dla asystentów AI + +Pełny opis techniczny (format `.vd`, przestrzeń, kamera, kierunki, kości, wagi, holdout, BODY_GAP, koń, tryby dokładne, materiał, render) jest w sekcji 11 angielskiego `README.md`; to jedyna wersja utrzymywana w całości. Najważniejsze: + +``` +PRZESTRZEŃ : metry, Z do góry, postać patrzy w -Y, podłoga z = 0, punkt UO = świat (0, 0, 0,07). +KAMERA : "UO_Camera", ortogonalna, elewacja 28,45 st., 36 px/m, 136x120 px, punkt w pikselu (68, 86). +KIERUNKI : UO_Rig["uo_direction"] = d (0..4) obraca szkielet o -45 st. * d; 5..7 to odbicia 3..1 robione przez klienta. +SZKIELET : 54 kości (rozdział 2); akcje "NN_nazwa", UO klatka i -> klatka sceny 1 + 3*i. +PRZEDMIOTY : uo_bind_item.py (PART -> dozwolone kości, waga = waga skóry pod spodem); sztywne: 100% na jednej kości. +HOLDOUT : ciało zasłania piksel, gdzie depth_body < depth_item - HOLDOUT_MARGIN (0,01 m), tylko trójkąty z OCCLUDERS (bez tułowia). +LUŹNA TKANINA: własność uo_cloth: wypchnięcie przez nogi + cloth_sim.npz z uo_cloth_sim.py; render dodaje deltę, potem BODY_GAP. +KONIE : koń = body 0xC8; parowanie akcji 23->0, 24->1, 25..29->2; zasłanianie tylko w obrysie konia (uo_horse_masks.json). +ZASADY : nigdy nie zamieniaj lewej i prawej kończyny względem oryginalnych klatek; nowe warstwy mają te same 35 akcji, 5 kierunków + i liczby klatek co ciało; nie rozpowszechniaj plików klienta UO ani `.blend` z tego repozytorium. +ODTWORZENIE: źródłem prawdy jest `.blend`; skrypty budujące są w historii gita (commit b86c314), rozdział 9. +``` diff --git a/third_party/UO_Model3D_v13/model/UO_Body_0x190.blend b/third_party/UO_Model3D_v13/model/UO_Body_0x190.blend index 1eeecc3..3ff6f34 100644 --- a/third_party/UO_Model3D_v13/model/UO_Body_0x190.blend +++ b/third_party/UO_Model3D_v13/model/UO_Body_0x190.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:80a323730621343d4c0666a5711294f916e921180f3c2e8fadf0e2226c745a32 -size 11175226 +oid sha256:0a8f105ec91091fa1250981f85ad55129e97732ad5cd1c5a8d0aaa09f235efcd +size 11153793 diff --git a/third_party/UO_Model3D_v13/pipeline/action_overlay.py b/third_party/UO_Model3D_v13/pipeline/action_overlay.py new file mode 100644 index 0000000..bb82f46 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/action_overlay.py @@ -0,0 +1,39 @@ +"""Overlay sheet of the pure 3D body vs the ORIGINAL UO body frames for one action (rows = 5 directions, columns = frames). + + python body_part_raster.py ../model/UO_Body_0x190.blend lab.npz --actions 21 + python action_overlay.py lab.npz OUT.png --action 21 [--scale 3] + +Colours: both = light grey, only the sprite = green, only the model = red (so a limb in a wrong place shows as a red / green pair). Needs: numpy pillow. +""" +import sys, os, argparse +import numpy as np +from PIL import Image, ImageDraw +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from body_part_qa import originals + + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument("lab"); ap.add_argument("out") + ap.add_argument("--action", type=int, required=True); ap.add_argument("--scale", type=int, default=3) + ap.add_argument("--frames", default=None) + a = ap.parse_args() + z = np.load(a.lab); lab, key = z["lab"], z["key"]; O = originals() + sel = [i for i, k in enumerate(key) if k[0] == a.action] + nf = 1 + max(key[i][2] for i in sel) + frames = [int(x) for x in a.frames.split(",")] if a.frames else list(range(nf)) + H, W = lab.shape[1:]; s = a.scale + sheet = Image.new("RGB", (W * s * len(frames), H * s * 5), (30, 30, 30)) + for i in sel: + _, d, f = key[i] + if f not in frames: continue + m = lab[i] >= 0; o = O[(a.action, d, f)] + im = np.zeros((H, W, 3), np.uint8) + im[m & o] = (200, 200, 200); im[o & ~m] = (60, 220, 60); im[m & ~o] = (230, 50, 50) + t = Image.fromarray(im).resize((W * s, H * s), Image.NEAREST) + ImageDraw.Draw(t).text((3, 3), "d%d f%d" % (d, f), fill=(255, 255, 0)) + sheet.paste(t, (frames.index(f) * W * s, d * H * s)) + sheet.save(a.out); print("saved", a.out) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/apply_pose_keys.py b/third_party/UO_Model3D_v13/pipeline/apply_pose_keys.py new file mode 100644 index 0000000..0a17598 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/apply_pose_keys.py @@ -0,0 +1,113 @@ +"""Writes the pose keys of pose_natural_fit.py (OUT_DIR/a.json) into a .blend and saves a copy. + + python apply_pose_keys.py IN.blend OUT.blend OUT_DIR [--actions 0,1,2] [--bones all|fit] (bpy 4.2; the .blend is saved with it) + +Per action: every key of rotation_quaternion / location / scale of the bones in the JSON is set to the new value at its UO frame (scene frame +1 + 3 i; a loop that repeats its first key at the end gets the first frame's value again). Quaternion keys are written sign-continuous +(dot with the previous key >= 0, the repeated loop key too): Blender interpolates the four components separately, and a sign flip between +two keys spins the bone through the in-between frames. Channels keep their fcurves even when all keys become 0 (a channel without keys in one action keeps the value of the action played before). +The item bones (weapon1h.R, polearm.L, axe2h.L, bow.L, shield.L) are then checked: their armature-space matrices must be the ORIGINAL +ones (they were fitted to the original weapons); a frame that differs by more than 1e-4 gets them set through pose_bone.matrix. +Needs: numpy "bpy==4.2.*". +""" +import sys, os, json, glob +import numpy as np +import bpy +from mathutils import Matrix, Quaternion + +STEP = 3 +ITEM_BONES = ["weapon1h.R", "polearm.L", "axe2h.L", "bow.L", "shield.L"] +FIT_BONES = ["spine", "chest", "neck", "head", "clavicle.R", "upper_arm.R", "forearm.R", "hand.R", "clavicle.L", "upper_arm.L", "forearm.L", "hand.L"] + + +def fix_quat_signs(act): + """every quaternion key gets the sign that keeps dot(previous key, key) >= 0 (same rotation, no spin between the keys)""" + groups = {} + for fc in act.fcurves: + if fc.data_path.endswith("rotation_quaternion"): + groups.setdefault(fc.data_path, {})[fc.array_index] = fc + flips = 0 + for dp, g in groups.items(): + if len(g) != 4: continue + fcs = [g[c] for c in range(4)] + m = min(len(fc.keyframe_points) for fc in fcs) + Q = np.array([[fc.keyframe_points[i].co[1] for fc in fcs] for i in range(m)]) + for i in range(1, m): + if np.dot(Q[i], Q[i - 1]) < 0: + Q[i] = -Q[i]; flips += 1 + for c in range(4): fcs[c].keyframe_points[i].co[1] = float(Q[i, c]) + for fc in fcs: fc.update() + return flips + + +def main(): + a = sys.argv[1:] + src, dst, d = os.path.abspath(a.pop(0)), os.path.abspath(a.pop(0)), os.path.abspath(a.pop(0)) + o = {"--actions": "", "--bones": "all"} + while a: k = a.pop(0); o[k] = a.pop(0) + want = {int(x) for x in o["--actions"].split(",")} if o["--actions"] else None + bpy.ops.wm.open_mainfile(filepath=src) + sc = bpy.context.scene + rig = bpy.data.objects["UO_Rig"]; P = rig.pose.bones + acts = {int(x["uo_action"]): x for x in bpy.data.actions if "uo_action" in x} + for path in sorted(glob.glob(os.path.join(d, "a*.json"))): + J = json.load(open(path)); aid = J["action"] + if want is not None and aid not in want: continue + act = acts[aid]; n = int(act["uo_frames"]); bones = J["bones"] + q, loc, scl = np.array(J["q"]), np.array(J["loc"]), np.array(J["scale"]) + assert len(q) == n + rig.animation_data.action = act + # original armature-space matrices of the item bones + T0 = [] + for i in range(n): + sc.frame_set(1 + STEP * i); bpy.context.view_layer.update() + T0.append({b: P[b].matrix.copy() for b in ITEM_BONES if b in P}) + sel = set(bones) if o["--bones"] == "all" else set(FIT_BONES) | set(ITEM_BONES) + for k, b in enumerate(bones): + if b not in sel: continue + qs = q[:, k].copy() + for i in range(1, n): + if np.dot(qs[i], qs[i - 1]) < 0: qs[i] = -qs[i] + for prop, vals, nc in (("rotation_quaternion", qs, 4), ("location", loc[:, k], 3), ("scale", scl[:, k], 3)): + for c in range(nc): + fc = act.fcurves.find('pose.bones["%s"].%s' % (b, prop), index=c) + if fc is None: + if b in ITEM_BONES: continue # unkeyed channel of an item bone: the exported value is whatever the previous action left; the matrix check below decides + neutral = (1.0 if c == 0 else 0.0) if prop == "rotation_quaternion" else (1.0 if prop == "scale" else 0.0) + assert np.abs(vals[:, c] - neutral).max() < 1e-6, (aid, b, prop, c, "new keys on a channel without an fcurve") + continue + kps = fc.keyframe_points + assert len(kps) in (n, n + 1), (aid, b, prop, len(kps), n) + for i in range(n): + assert abs(kps[i].co[0] - (1 + STEP * i)) < 1e-6 + kps[i].co[1] = float(vals[i, c]) + if len(kps) == n + 1: # loop closure: the first frame again, sign continuous with the last + v0 = vals[0].copy() + if prop == "rotation_quaternion" and np.dot(vals[0], vals[n - 1]) < 0: v0 = -v0 + kps[n].co[1] = float(v0[c]) + fc.update() + # items: armature-space matrices as before + worst, fixed = 0.0, 0 + for i in range(n): + sc.frame_set(1 + STEP * i); bpy.context.view_layer.update() + for b, T in T0[i].items(): + e = max(abs(x - y) for r1, r2 in zip(P[b].matrix, T) for x, y in zip(r1, r2)) + worst = max(worst, e) + if e > 1e-4: + P[b].matrix = T; bpy.context.view_layer.update() + for prop, nc in (("rotation_quaternion", 4), ("location", 3)): + val = getattr(P[b], prop) + for c in range(nc): + fc = act.fcurves.find('pose.bones["%s"].%s' % (b, prop), index=c) + if fc is not None: + fc.keyframe_points[i].co[1] = val[c]; fc.update() + fixed += 1 + flips = fix_quat_signs(act) + print("action %2d %-26s written; item bones: max matrix difference %.1e, %d frame-bones re-solved; %d quaternion sign flips removed" % + (aid, act.name, worst, fixed, flips), flush=True) + bpy.ops.wm.save_as_mainfile(filepath=dst) + print("saved", dst) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/arm_joint_fit.py b/third_party/UO_Model3D_v13/pipeline/arm_joint_fit.py new file mode 100644 index 0000000..17a0365 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/arm_joint_fit.py @@ -0,0 +1,152 @@ +"""Joint fit of BOTH arms (clavicle, upper arm, forearm hinge + twist, hand) and optionally the upper torso (spine / chest free rotation; --torso 2: also pelvis rotation + location, thighs, shins) of one frame of a UO action against the +ORIGINAL body frames, 5 directions at once. Unlike arm_sym_fit.py the arms are independent (death / fall actions are not symmetric) and unlike arm_grid.py / arm_refit.py the +other arm is fitted too (a fit of one arm with the other one wrong only moves the error). + + python arm_joint_fit.py IN.blend OUT.json --action 21 --frame 4 [--torso 0|1|2] [--flex 145] (env NRAND, MAXFEV, TORSO_PRIOR, TOP_W, EDGE_W); mounted frames: the horse hides the body as in body_part_raster.py + python arm_sym_fit.py IN.blend OUT.blend --action 21 --apply a.json,b.json (writes the keys, same layout; save with bpy 4.2) + +Objective: as arm_sym_fit.py (1 - IoU whole + above TOP rows, mean of 5 directions) + small priors (elbow flex 0..FLEX, rotations near the key); starts: the key, the key with +the elbows bent differently, random arm directions. Needs: numpy scipy pillow "bpy==4.2.*". +""" +import sys, os, json +import numpy as np +import bpy +from mathutils import Quaternion, Vector, Matrix +from scipy.optimize import minimize +from scipy.ndimage import distance_transform_edt +from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from body_part_raster import raster, CW, CH, CANCH, STEP +from arm_refit import setup, ROOT, TOP, rotvec + +NRAND, MAXFEV = int(os.environ.get("NRAND", 6)), int(os.environ.get("MAXFEV", 1500)) +TOP_W = float(os.environ.get("TOP_W", 1.0)) # weight of the rows above the shoulder line (arm_refit.py: 1.0) +EDGE_W = float(os.environ.get("EDGE_W", 0.0)) # extra weight of the >= 2 px errors +CHAIN = ["clavicle", "upper_arm", "forearm", "hand"] + + +def horse_masks(aid, frame): + """horse object of a mounted frame and its sprite masks per direction (same data as body_part_raster.py); (None, {}) when not mounted""" + import base64, zlib + h = bpy.data.objects.get("Horse_a%d_f%d" % (aid, frame)) + if h is None or "uo_horse_masks.json" not in bpy.data.texts: + return None, {} + h.hide_viewport = h.hide_render = False + HM = {} + for k, v in json.loads(bpy.data.texts["uo_horse_masks.json"].as_string())["masks"].items(): + a, f, d = (int(x) for x in k.split(",")) + if (a, f) != (aid, frame): continue + bits = np.unpackbits(np.frombuffer(zlib.decompress(base64.b64decode(v)), np.uint8))[:120 * 136].reshape(120, 136).astype(bool) + m = np.zeros((CH, CW), bool); m[CANCH[1] - 86:CANCH[1] - 86 + 120, CANCH[0] - 68:CANCH[0] - 68 + 136] = bits + HM[d] = m + return h, HM + + +def main(): + a = sys.argv[1:] + blend, out = os.path.abspath(a.pop(0)), os.path.abspath(a.pop(0)) + o = {"--action": "21", "--frame": "4", "--torso": "0", "--flex": "145"} + while a: k = a.pop(0); o[k] = a.pop(0) + aid, frame, torso, FLEX = int(o["--action"]), int(o["--frame"]), int(o["--torso"]), np.radians(float(o["--flex"])) + tprior = float(os.environ.get("TORSO_PRIOR", 0.05)) + bpy.ops.wm.open_mainfile(filepath=blend) + sc = bpy.context.scene + rig, body = bpy.data.objects["UO_Rig"], bpy.data.objects["UO_Body"]; P = rig.pose.bones + sc.camera = bpy.data.objects["UO_Camera"]; setup(sc) + act = next(x for x in bpy.data.actions if x.get("uo_action") == aid); rig.animation_data.action = act + ORIG = {d: np.array(Image.open(os.path.join(ROOT, "client/body_0x190_frames/frames/%s/dir%d/%02d.png" % (act.name, d, frame))).convert("RGBA"))[..., 3] > 0 for d in range(5)} + sc.frame_set(1 + frame * STEP); rig.update_tag(); bpy.context.view_layer.update() + + horse, HM = horse_masks(aid, frame) + + def cost(): + c = 0.0 + for d in range(5): + rig["uo_direction"] = d; rig.update_tag(); body.update_tag(); bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get(); cam = sc.camera.evaluated_get(dg) + Pm, Vm = np.array(cam.calc_matrix_camera(dg, x=CW, y=CH)), np.array(cam.matrix_world.inverted()) + lab, depth = raster(body, dg, Pm, Vm, None) + if horse is not None and d in HM: # mounted: the horse hides what is behind it, clipped to its sprite (as body_part_raster.py) + hl, hd = raster(horse, dg, Pm, Vm, None) + lab[(hl >= 0) & (hd < depth) & HM[d]] = -1 + s, b = lab >= 0, ORIG[d] + c += 1 - (s & b).sum() / max((s | b).sum(), 1) + if EDGE_W: # pixels off by >= 2 px from the other silhouette (pose errors, not the 1-px outline) + dd = np.where(s & ~b, distance_transform_edt(~b), distance_transform_edt(~s)) + c += EDGE_W * ((s ^ b) & (dd >= 2)).sum() / max((s | b).sum(), 1) + s, b = s[:TOP], b[:TOP]; c += TOP_W * (1 - (s & b).sum() / max((s | b).sum(), 1)) + return c / 5 + + key = {(b, s): Quaternion(P["%s.%s" % (b, s)].rotation_quaternion) for b in CHAIN for s in "LR"} + TB = ("spine", "chest") + (("pelvis", "thigh.L", "thigh.R", "shin.L", "shin.R") if torso == 2 else ()) + tkey = {b: Quaternion(P[b].rotation_quaternion) for b in TB} + ploc0 = Vector(P["pelvis"].location) + NT = 0 if not torso else 3 * len(TB) + (3 if torso == 2 else 0) + old = {n: [list(P[n].rotation_quaternion), list(P[n].scale), list(P[n].location)] for n in P.keys()} + NA = 11 + + def setarm(s, x): + # x: 3 upper arm rotvec, flex, twist, 3 hand rotvec, 3 clavicle rotvec + P["clavicle." + s].rotation_quaternion = key[("clavicle", s)] @ rotvec(x[8:11]) + P["upper_arm." + s].rotation_quaternion = key[("upper_arm", s)] @ rotvec(x[0:3]) + P["forearm." + s].rotation_quaternion = Quaternion((1, 0, 0), x[3]) @ Quaternion((0, 1, 0), x[4]) + P["hand." + s].rotation_quaternion = key[("hand", s)] @ rotvec(x[5:8]) + + def pen_arm(x): + return 5 * max(0, x[3] - FLEX) + 5 * max(0, -x[3]) + 0.004 * float(np.sum(np.square(x[[0, 1, 2, 5, 6, 7]]))) + 0.01 * x[4] ** 2 + 0.02 * float(np.sum(np.square(x[8:11]))) + + def settorso(x): + for k, b in enumerate(TB): P[b].rotation_quaternion = tkey[b] @ rotvec(x[3 * k:3 * k + 3]) + if torso == 2: P["pelvis"].location = ploc0 + Vector(x[3 * len(TB):3 * len(TB) + 3]) # bone-local units, small + + def f_side(x, s): + setarm(s, x); return cost() + pen_arm(x) + + def f_all(x): + setarm("L", x[:NA]); setarm("R", x[NA:2 * NA]) + pen = pen_arm(x[:NA]) + pen_arm(x[NA:2 * NA]) + if torso: settorso(x[2 * NA:]); pen += tprior * float(np.sum(np.square(x[2 * NA:]))) + return cost() + pen + + def x_key(fl): + x = np.zeros(NA); x[3] = fl; return x + rng = np.random.default_rng(frame + 100 * aid) + xs = {} + c_old = cost() + print(" key cost %.4f" % c_old, flush=True) + for s in "LR": + other = "R" if s == "L" else "L" + starts = [np.zeros(NA)] + [x_key(f) for f in (0.7, 1.2, 1.8)] + for k in range(NRAND): + x0 = np.zeros(NA); x0[0:3] = rng.normal(0, 0.9, 3); x0[3] = rng.uniform(0.3, 2.2); x0[8:11] = rng.normal(0, 0.15, 3); starts.append(x0) + res = [] + for x0 in starts: + r = minimize(f_side, x0, args=(s,), method="Powell", options={"xtol": 1e-2, "ftol": 1e-3, "maxfev": MAXFEV}) + res.append((r.fun, r.x)) + res.sort(key=lambda t: t[0]); xs[s] = res + print(" side", s, "best", [round(t[0], 4) for t in res[:4]], "flex", [round(float(np.degrees(t[1][3]))) for t in res[:4]], flush=True) + setarm(s, res[0][1]) # the next side is fitted with this one in place + # joint polish from the best 2 x 2 combinations + best = None + for i in range(2): + for j in range(2): + x0 = np.r_[xs["L"][i][1], xs["R"][j][1], np.zeros(NT)] + r = minimize(f_all, x0, method="Powell", options={"xtol": 1e-2, "ftol": 1e-4, "maxfev": 2 * MAXFEV}) + print(" joint", i, j, "cost %.4f" % r.fun, flush=True) + if best is None or r.fun < best.fun: best = r + f_all(best.x) + if best.fun > c_old: # never worse than the key: keep it (x = 0 is not the key: the forearm is set absolutely) + for n_, (q, s_, l) in old.items(): P[n_].rotation_quaternion = q; P[n_].location = l + c_new = cost() + chg = {} + for n_, (q, s_, l) in old.items(): + qn, sn, ln = list(P[n_].rotation_quaternion), list(P[n_].scale), list(P[n_].location) + if np.abs(np.array(qn) - q).max() > 1e-9: chg.setdefault(n_, {})["rotation_quaternion"] = qn + if np.abs(np.array(ln) - l).max() > 1e-9: chg.setdefault(n_, {})["location"] = ln + el = {s: float(np.degrees(best.x[k * NA + 3])) for k, s in enumerate("LR")} + print("FRAME", frame, "cost old %.4f -> fitted %.4f, elbows L %.0f R %.0f" % (c_old, c_new, el["L"], el["R"]), flush=True) + json.dump({"action": aid, "frame": frame, "torso": torso, "cost_old": c_old, "cost_fit": c_new, "elbow_deg": el, "channels": chg}, open(out, "w"), indent=1) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/arm_mirror_action.py b/third_party/UO_Model3D_v13/pipeline/arm_mirror_action.py index 3e9498d..9e27aeb 100644 --- a/third_party/UO_Model3D_v13/pipeline/arm_mirror_action.py +++ b/third_party/UO_Model3D_v13/pipeline/arm_mirror_action.py @@ -4,7 +4,7 @@ Copies, per key of the action, from the chosen side to the other for clavicle, upper_arm, forearm, hand and the finger bones: rotation q = (w, x, -y, -z) (the rest matrices of the L / R bones are exact mirrors M B M, M = diag(-1, 1, 1), so the local rotation mirrors this way), scale as it is, location (-x, y, z). The torso, head and legs are not touched. -Prints the error of the mirror in the chest frame (m) before saving. See docs/qa/elbow_spell.md. +Prints the error of the mirror in the chest frame (m) before saving. """ import sys, os import numpy as np diff --git a/third_party/UO_Model3D_v13/pipeline/arm_mismatch.py b/third_party/UO_Model3D_v13/pipeline/arm_mismatch.py new file mode 100644 index 0000000..1febbb9 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/arm_mismatch.py @@ -0,0 +1,21 @@ +"""Arm-region mismatch per (action, frame): pixels only the model has that belong to an arm part (upper_arm / forearm / hand / clavicle) + pixels only the sprite has whose nearest +model pixel is an arm part, summed over 5 directions (px). python arm_mismatch.py lab.npz [--top 30] (lab.npz from body_part_raster.py)""" +import sys, os +import numpy as np +from scipy.ndimage import distance_transform_edt +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from body_part_qa import originals +z = np.load(sys.argv[1]); lab, key, parts = z["lab"], z["key"], [str(p) for p in z["parts"]] +top = int(sys.argv[sys.argv.index("--top") + 1]) if "--top" in sys.argv else 30 +arm = np.array([p.split(".")[0] in ("upper_arm", "forearm", "hand", "clavicle") for p in parts]) +O = originals(); res = {} +for i, k in enumerate(key): + l = lab[i]; m = l >= 0; o = O[tuple(k)] + a_only = (m & ~o) & arm[np.where(m, l, 0)] + _, idx = distance_transform_edt(~m, return_indices=True) + near = l[idx[0], idx[1]] + s_only = (o & ~m) & arm[np.where(near >= 0, near, 0)] & (near >= 0) + r = res.setdefault((int(k[0]), int(k[2])), [0, 0]); r[0] += a_only.sum(); r[1] += s_only.sum() +rows = sorted(((v[0] + v[1], k, v) for k, v in res.items()), reverse=True) +for t, k, v in rows[:top]: print("action %2d frame %d: arm mismatch %3d px (model+ %d, sprite+ %d)" % (k[0], k[1], t, v[0], v[1])) +print("mean per frame %.1f" % np.mean([t for t, _, _ in rows])) diff --git a/third_party/UO_Model3D_v13/pipeline/arm_refit.py b/third_party/UO_Model3D_v13/pipeline/arm_refit.py index a60ac4b..9b17369 100644 --- a/third_party/UO_Model3D_v13/pipeline/arm_refit.py +++ b/third_party/UO_Model3D_v13/pipeline/arm_refit.py @@ -1,4 +1,4 @@ -"""Local refit (Powell, random starts) of one arm of the UO rig against the ORIGINAL body frames, 5 directions at once. Experimental, see docs/qa/elbow_spell.md. +"""Local refit (Powell, random starts) of one arm of the UO rig against the ORIGINAL body frames, 5 directions at once. Experimental. python arm_refit.py ../model/UO_Body_0x190.blend OUT.json --action 17 --side L --frames 1,2 [--apply] (env FLEX_MAX, NRAND, MAXFEV) diff --git a/third_party/UO_Model3D_v13/pipeline/arm_sym_fit.py b/third_party/UO_Model3D_v13/pipeline/arm_sym_fit.py index b46abba..9f28e0d 100644 --- a/third_party/UO_Model3D_v13/pipeline/arm_sym_fit.py +++ b/third_party/UO_Model3D_v13/pipeline/arm_sym_fit.py @@ -6,7 +6,7 @@ Objective: 1 - IoU of the pure-3D body silhouette vs the sprite over 5 directions + the same above TOP rows + a small prior; so the numbers can be compared with `body_part_qa.py`-style IoU (whole silhouette). OUT.json: per frame the values of every changed channel and the cost before / after. -Needs: pip install numpy scipy pillow "bpy==4.2.*". See docs/qa/elbow_spell.md. +Needs: pip install numpy scipy pillow "bpy==4.2.*". """ import sys, os, json import numpy as np diff --git a/third_party/UO_Model3D_v13/pipeline/cloak_pitch.json b/third_party/UO_Model3D_v13/pipeline/cloak_pitch.json index 07584bc..c67665a 100644 --- a/third_party/UO_Model3D_v13/pipeline/cloak_pitch.json +++ b/third_party/UO_Model3D_v13/pipeline/cloak_pitch.json @@ -1 +1 @@ -{"comment":"cloak (original 468): change of the pitch (deg, back = +) of the cape about the shoulders per action and frame, relative to the stand pose; [shoulders, hem]. cloak_fit_frames.py, docs/qa/cloak_physics.md","stand":[20.0,0.0],"actions":{"0":[[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-7.5],[0.0,-2.5],[0.0,-2.5],[0.0,-7.5],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0]],"1":[[0.0,-7.5],[0.0,-5.0],[0.0,-7.5],[0.0,-10.0],[2.5,-10.0],[5.0,-7.5],[2.5,-5.0],[0.0,-7.5],[0.0,-10.0],[0.0,-10.0]],"2":[[40.0,42.5],[45.0,45.0],[52.5,45.0],[57.5,40.0],[65.0,27.5],[67.5,25.0],[60.0,37.5],[52.5,45.0],[45.0,47.5],[35.0,55.0]],"3":[[40.0,42.5],[42.5,47.5],[50.0,47.5],[57.5,40.0],[60.0,32.5],[60.0,32.5],[57.5,40.0],[50.0,47.5],[42.5,50.0],[40.0,50.0]],"4":[[0.0,0.0]],"5":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"6":[[0.0,-2.5],[0.0,-7.5],[0.0,-10.0],[0.0,-7.5],[0.0,-2.5]],"7":[[10.0,0.0]],"8":[[10.0,0.0]],"9":[[2.5,0.0],[10.0,0.0],[15.0,2.5],[12.5,7.5],[7.5,7.5],[2.5,5.0],[0.0,7.5]],"10":[[17.5,-7.5],[12.5,-5.0],[10.0,-7.5],[10.0,-10.0],[12.5,-10.0],[17.5,-5.0],[20.0,5.0]],"11":[[10.0,-10.0],[10.0,-10.0],[10.0,-10.0],[10.0,-10.0],[12.5,-10.0],[12.5,-5.0],[5.0,5.0]],"12":[[12.5,7.5],[20.0,0.0],[27.5,0.0],[32.5,20.0],[37.5,45.0],[37.5,42.5],[32.5,15.0]],"13":[[10.0,0.0],[10.0,0.0],[12.5,-2.5],[17.5,-7.5],[15.0,-5.0],[10.0,2.5],[15.0,2.5]],"14":[[12.5,2.5],[22.5,2.5],[35.0,-5.0],[35.0,-7.5],[25.0,-5.0],[17.5,-2.5],[12.5,5.0]],"15":[[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,7.5],[10.0,2.5],[10.0,0.0],[10.0,0.0],[10.0,0.0]],"16":[[20.0,5.0],[17.5,-5.0],[12.5,-10.0],[5.0,-10.0],[-5.0,-10.0],[-7.5,-10.0],[-2.5,-10.0]],"17":[[15.0,-2.5],[25.0,-7.5],[27.5,-10.0],[22.5,-10.0],[17.5,-7.5],[15.0,-5.0],[17.5,-7.5]],"18":[[10.0,-2.5],[7.5,-5.0],[2.5,-2.5],[0.0,0.0],[2.5,0.0],[7.5,-2.5],[10.0,-7.5]],"19":[[10.0,7.5],[10.0,5.0],[10.0,7.5],[12.5,7.5],[15.0,5.0],[12.5,7.5],[10.0,10.0]],"20":[[37.5,12.5],[30.0,17.5],[22.5,17.5],[17.5,12.5],[12.5,10.0]],"21":[[22.5,-5.0],[27.5,10.0],[27.5,25.0],[20.0,20.0],[12.5,0.0],[10.0,-10.0]],"22":[[5.0,10.0],[15.0,15.0],[20.0,20.0],[17.5,10.0],[12.5,-5.0],[10.0,-10.0]],"23":[[52.5,7.5],[60.0,2.5],[65.0,2.5],[62.5,5.0],[60.0,2.5]],"24":[[52.5,30.0],[60.0,27.5],[65.0,25.0],[60.0,30.0],[52.5,37.5]],"25":[[20.0,30.0]],"26":[[22.5,40.0],[25.0,45.0],[20.0,57.5],[12.5,67.5],[10.0,70.0]],"27":[[12.5,47.5],[17.5,40.0],[20.0,32.5],[20.0,30.0],[20.0,30.0]],"28":[[20.0,20.0],[20.0,20.0],[20.0,20.0],[20.0,17.5],[20.0,12.5],[20.0,12.5],[20.0,17.5]],"29":[[30.0,25.0],[27.5,20.0],[20.0,27.5],[12.5,37.5],[10.0,40.0]],"30":[[7.5,0.0],[2.5,5.0],[2.5,10.0],[7.5,7.5],[10.0,7.5]],"31":[[10.0,0.0],[10.0,0.0],[12.5,0.0],[15.0,5.0],[12.5,17.5],[12.5,25.0],[17.5,22.5]],"32":[[-2.5,-10.0],[-7.5,-10.0],[-10.0,-10.0],[-7.5,-10.0],[-2.5,-10.0]],"33":[[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0]],"34":[[0.0,2.5],[2.5,2.5],[5.0,0.0],[2.5,5.0],[0.0,10.0]]}} \ No newline at end of file +{"comment":"cloak (original 468): change of the pitch (deg, back = +) of the cape about the shoulders per action and frame, relative to the stand pose; [shoulders, hem]. cloak_fit_frames.py","stand":[20.0,0.0],"actions":{"0":[[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-7.5],[0.0,-2.5],[0.0,-2.5],[0.0,-7.5],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0]],"1":[[0.0,-7.5],[0.0,-5.0],[0.0,-7.5],[0.0,-10.0],[2.5,-10.0],[5.0,-7.5],[2.5,-5.0],[0.0,-7.5],[0.0,-10.0],[0.0,-10.0]],"2":[[40.0,42.5],[45.0,45.0],[52.5,45.0],[57.5,40.0],[65.0,27.5],[67.5,25.0],[60.0,37.5],[52.5,45.0],[45.0,47.5],[35.0,55.0]],"3":[[40.0,42.5],[42.5,47.5],[50.0,47.5],[57.5,40.0],[60.0,32.5],[60.0,32.5],[57.5,40.0],[50.0,47.5],[42.5,50.0],[40.0,50.0]],"4":[[0.0,0.0]],"5":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"6":[[0.0,-2.5],[0.0,-7.5],[0.0,-10.0],[0.0,-7.5],[0.0,-2.5]],"7":[[10.0,0.0]],"8":[[10.0,0.0]],"9":[[2.5,0.0],[10.0,0.0],[15.0,2.5],[12.5,7.5],[7.5,7.5],[2.5,5.0],[0.0,7.5]],"10":[[17.5,-7.5],[12.5,-5.0],[10.0,-7.5],[10.0,-10.0],[12.5,-10.0],[17.5,-5.0],[20.0,5.0]],"11":[[10.0,-10.0],[10.0,-10.0],[10.0,-10.0],[10.0,-10.0],[12.5,-10.0],[12.5,-5.0],[5.0,5.0]],"12":[[12.5,7.5],[20.0,0.0],[27.5,0.0],[32.5,20.0],[37.5,45.0],[37.5,42.5],[32.5,15.0]],"13":[[10.0,0.0],[10.0,0.0],[12.5,-2.5],[17.5,-7.5],[15.0,-5.0],[10.0,2.5],[15.0,2.5]],"14":[[12.5,2.5],[22.5,2.5],[35.0,-5.0],[35.0,-7.5],[25.0,-5.0],[17.5,-2.5],[12.5,5.0]],"15":[[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,10.0],[10.0,7.5],[10.0,2.5],[10.0,0.0],[10.0,0.0],[10.0,0.0]],"16":[[20.0,5.0],[17.5,-5.0],[12.5,-10.0],[5.0,-10.0],[-5.0,-10.0],[-7.5,-10.0],[-2.5,-10.0]],"17":[[15.0,-2.5],[25.0,-7.5],[27.5,-10.0],[22.5,-10.0],[17.5,-7.5],[15.0,-5.0],[17.5,-7.5]],"18":[[10.0,-2.5],[7.5,-5.0],[2.5,-2.5],[0.0,0.0],[2.5,0.0],[7.5,-2.5],[10.0,-7.5]],"19":[[10.0,7.5],[10.0,5.0],[10.0,7.5],[12.5,7.5],[15.0,5.0],[12.5,7.5],[10.0,10.0]],"20":[[37.5,12.5],[30.0,17.5],[22.5,17.5],[17.5,12.5],[12.5,10.0]],"21":[[22.5,-5.0],[27.5,10.0],[27.5,25.0],[20.0,20.0],[12.5,0.0],[10.0,-10.0]],"22":[[5.0,10.0],[15.0,15.0],[20.0,20.0],[17.5,10.0],[12.5,-5.0],[10.0,-10.0]],"23":[[52.5,7.5],[60.0,2.5],[65.0,2.5],[62.5,5.0],[60.0,2.5]],"24":[[52.5,30.0],[60.0,27.5],[65.0,25.0],[60.0,30.0],[52.5,37.5]],"25":[[20.0,30.0]],"26":[[22.5,40.0],[25.0,45.0],[20.0,57.5],[12.5,67.5],[10.0,70.0]],"27":[[12.5,47.5],[17.5,40.0],[20.0,32.5],[20.0,30.0],[20.0,30.0]],"28":[[20.0,20.0],[20.0,20.0],[20.0,20.0],[20.0,17.5],[20.0,12.5],[20.0,12.5],[20.0,17.5]],"29":[[30.0,25.0],[27.5,20.0],[20.0,27.5],[12.5,37.5],[10.0,40.0]],"30":[[7.5,0.0],[2.5,5.0],[2.5,10.0],[7.5,7.5],[10.0,7.5]],"31":[[10.0,0.0],[10.0,0.0],[12.5,0.0],[15.0,5.0],[12.5,17.5],[12.5,25.0],[17.5,22.5]],"32":[[-2.5,-10.0],[-7.5,-10.0],[-10.0,-10.0],[-7.5,-10.0],[-2.5,-10.0]],"33":[[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0],[0.0,-10.0]],"34":[[0.0,2.5],[2.5,2.5],[5.0,0.0],[2.5,5.0],[0.0,10.0]]}} \ No newline at end of file diff --git a/third_party/UO_Model3D_v13/pipeline/cloth_lib.py b/third_party/UO_Model3D_v13/pipeline/cloth_lib.py index 997b381..4804e39 100644 --- a/third_party/UO_Model3D_v13/pipeline/cloth_lib.py +++ b/third_party/UO_Model3D_v13/pipeline/cloth_lib.py @@ -84,7 +84,7 @@ def solve(X0, edges, rest_len, free, heads, tails, radius, gap=0.012, iters=30, # ---------------------------------------------------------------------------------------------------------------------------------------------------------------- # Leg hull: the hanging cloth is pushed out, around the vertical axis of the waist, to where the legs reach - the "shadow" of the legs at every height, not their contact. -# Measured on the original robes (docs/qa/robe_physics.md): the hem of a robe follows the legs in stride, and a robe never pokes out of the hem line of the legs. +# Measured on the original robes: the hem of a robe follows the legs in stride, and a robe never pokes out of the hem line of the legs. # For every height z of the garment (in the rest frame of the pelvis) and every angle around the axis, h(angle, z) = the farthest reach of the legs in that direction at that # height (the support function of the circles in which the capsules of the legs cut the plane); a vertex at radius rho < h + MARGIN is pushed out by KAPPA * (h + MARGIN - rho). # One field per frame, looked up for any mesh (no topology needed), smooth in angle and height, no iteration, no memory of the frame before. @@ -204,7 +204,7 @@ def cloak_bend(V, z_top, z_hem, y_top, d0, d1): # Conformed garments (uo_conform_item.py: custom property uo_conform, point attribute uo_region): every frame each vertex is kept `gap` outside the skin, pushed along the skin # normal and spread over the item, so the skin never comes through it. All the skin counts for every part of the item (torso, legs, head, both arms and hands): sparing a -# sleeve the torso or the other arm (a body-to-body contact) left the arm and the chest showing through the sleeves in the spells (docs/qa/jedi_tunic.md). The regions stay in +# sleeve the torso or the other arm (a body-to-body contact) left the arm and the chest showing through the sleeves in the spells. The regions stay in # the data (0 torso part, 1 / 2 sleeves, 3 / 4 their roots), each with its own set of bones here. _ALL = {"pelvis", "spine", "chest", "neck", "clavicle.L", "clavicle.R", "head", "thigh.L", "shin.L", "foot.L", "thigh.R", "shin.R", "foot.R", "upper_arm.L", "forearm.L", "hand.L", "upper_arm.R", "forearm.R", "hand.R"} diff --git a/third_party/UO_Model3D_v13/pipeline/flail_fit.py b/third_party/UO_Model3D_v13/pipeline/flail_fit.py new file mode 100644 index 0000000..b40a324 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/flail_fit.py @@ -0,0 +1,74 @@ +"""Compare a rendered flail (clothing layer, render_uo_layer.py canvas) with an original flail animation (frames from vdtool.py extract): per frame the ball (the largest +thick blob: the item opened by one pixel) and the whole item (symmetric mean distance between the two sets of item pixels, px), on the common anchor. + + python flail_fit.py OURS_FRAMES OURS_ANCHOR ORIG_FRAMES ORIG_ANCHOR [--actions 01_walk_armed,...] [--json out.json] + + OURS_ANCHOR / ORIG_ANCHOR: "x,y" of the canvas anchor (render_uo_layer CANVAS anchor 128,192; vdtool extract prints it, e.g. 92,103). +Prints per action: frames, mean ball distance (px), mean item distance (px); and the total. +""" +import argparse, glob, json, os +import numpy as np +from PIL import Image +from scipy import ndimage + + +def load(path, anc): + a = np.array(Image.open(path).convert("RGBA")); m = a[..., 3] > 0 + ys, xs = np.nonzero(m) + P = np.c_[xs - anc[0], ys - anc[1]].astype(float) + op = ndimage.binary_opening(m, iterations=1); lab, k = ndimage.label(op) + ball = None + if k: + sz = ndimage.sum(op, lab, range(1, k + 1)); j = int(np.argmax(sz)) + 1 + yy, xx = np.nonzero(lab == j); ball = np.array([xx.mean() - anc[0], yy.mean() - anc[1]]) + return P, ball + + +def setdist(A, B): + from scipy.spatial import cKDTree + return 0.5 * (cKDTree(B).query(A)[0].mean() + cKDTree(A).query(B)[0].mean()) + + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument("ours"); ap.add_argument("oa"); ap.add_argument("orig"); ap.add_argument("ra") + ap.add_argument("--actions", default=""); ap.add_argument("--json", default="") + a = ap.parse_args(); oa = tuple(map(int, a.oa.split(","))); ra = tuple(map(int, a.ra.split(","))) + acts = a.actions.split(",") if a.actions else sorted(os.listdir(a.ours)) + res = {}; allb = []; alli = []; allso = []; allsr = [] + for act in acts: + bs, its, so, sr = [], [], [], [] + for dd in sorted(glob.glob(os.path.join(a.ours, act, "dir*"))): # ball speed: px between consecutive frames, ours and the original + fs = sorted(glob.glob(os.path.join(dd, "*.png"))) + pb = pc = None + for f in fs: + g = os.path.join(a.orig, act, os.path.basename(dd), os.path.basename(f)) + if not os.path.exists(g): + continue + b = load(f, oa)[1]; c = load(g, ra)[1] + if b is not None and pb is not None: + so.append(float(np.linalg.norm(b - pb))) + if c is not None and pc is not None: + sr.append(float(np.linalg.norm(c - pc))) + pb, pc = b, c + for f in sorted(glob.glob(os.path.join(a.ours, act, "dir*", "*.png"))): + g = os.path.join(a.orig, act, os.path.basename(os.path.dirname(f)), os.path.basename(f)) + if not os.path.exists(g): + continue + P, b = load(f, oa); Q, c = load(g, ra) + if len(P) < 3 or len(Q) < 3: + continue + its.append(setdist(P, Q)) + if b is not None and c is not None: + bs.append(float(np.linalg.norm(b - c))) + if its: + res[act] = dict(frames=len(its), ball=float(np.mean(bs)) if bs else None, item=float(np.mean(its)), + speed=float(np.mean(so)) if so else None, speed_orig=float(np.mean(sr)) if sr else None) + allb += bs; alli += its; allso += so; allsr += sr + print("%-22s frames %3d ball %5.1f px item %5.1f px ball speed %5.1f px/frame (original %5.1f)" % (act, len(its), res[act]["ball"] or -1, res[act]["item"], res[act]["speed"] or -1, res[act]["speed_orig"] or -1)) + print("FLAIL_FIT ball %.2f px item %.2f px ball speed %.2f px/frame (original %.2f) (%d frames)" % (np.mean(allb), np.mean(alli), np.mean(allso), np.mean(allsr), len(alli))) + if a.json: + json.dump(dict(actions=res, ball=float(np.mean(allb)), item=float(np.mean(alli))), open(a.json, "w"), indent=1) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/hand_fit.py b/third_party/UO_Model3D_v13/pipeline/hand_fit.py index 3934a58..18f3fc5 100644 --- a/third_party/UO_Model3D_v13/pipeline/hand_fit.py +++ b/third_party/UO_Model3D_v13/pipeline/hand_fit.py @@ -6,7 +6,7 @@ Parameters of the right hand (the left one is its mirror, q -> (w, x, -y, -z)): hand rotation (3, rotation vector on the key), hand scale (uniform + along the bone, 2), thumb: base rotation (3) + flexion of the two outer joints (2), fingers 2-5: flexion (about the local X of the finger bones = the curl axis, joints 1:2:3 = 1:1:0.8) and spread (about the local Z of the first joint) (2 each). Objective: (1 - IoU of the pure-3D body silhouette vs the sprite) + EDGE_W * (1 - F-score of the outline / depth-jump pixels of the model vs the dark pixels of the sprite), counted only in discs (HAND_R px) around both hands (projected head / tail of the hand bones), summed over -5 directions; small prior on the parameters. Arms, torso and legs are not touched. See docs/qa/elbow_spell.md. +5 directions; small prior on the parameters. Arms, torso and legs are not touched. Needs: pip install numpy scipy pillow "bpy==4.2.*". """ import sys, os, json diff --git a/third_party/UO_Model3D_v13/pipeline/head_scan.py b/third_party/UO_Model3D_v13/pipeline/head_scan.py new file mode 100644 index 0000000..33eea2c --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/head_scan.py @@ -0,0 +1,78 @@ +"""Head yaw per action and frame: how far the head is turned about the vertical, and how fast it jumps. + + python head_scan.py ../model/UO_Body_0x190.blend [--actions 23,24] [--out scan.json] [--per-frame] + +Per action (UO frames = scene frames 1 + 3 i; loops wrap): + world_yaw yaw (deg, about armature Z, + = the plan's sign) of the head bone against its rest orientation: min .. max + chest_yaw twist (deg, about the chest's own Y) of the head against the chest, rest relation removed: min .. max + jump max |yaw(i) - mid(yaw(i-1), yaw(i+1))| (deg) of the world yaw +Needs: numpy "bpy==4.2.*". +""" +import sys, os, json +import numpy as np +import bpy + +LOOP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 15, 23, 24, 25} +STEP = 3 + + +def unit(m): + return m[:3, :3] / np.linalg.norm(m[:3, :3], axis=0, keepdims=True) + + +def yaw_z(R): + return -np.degrees(np.arctan2(R[1, 0], R[0, 0])) + + +def twist_y(R): + from scipy.spatial.transform import Rotation + q = Rotation.from_matrix(R).as_quat() # x y z w + t = 2 * np.arctan2(q[1], q[3]) + return -np.degrees((t + np.pi) % (2 * np.pi) - np.pi) + + +def main(): + a = sys.argv[1:] + blend = os.path.abspath(a.pop(0)) + only, out, per_frame = None, None, False + while a: + k = a.pop(0) + if k == "--actions": only = {int(x) for x in a.pop(0).split(",")} + elif k == "--out": out = a.pop(0) + elif k == "--per-frame": per_frame = True + bpy.ops.wm.open_mainfile(filepath=blend) + sc = bpy.context.scene + rig = bpy.data.objects["UO_Rig"]; P = rig.pose.bones; Bn = rig.data.bones + rh, rc = np.array(Bn["head"].matrix_local)[:3, :3], np.array(Bn["chest"].matrix_local)[:3, :3] + rh, rc = rh / np.linalg.norm(rh, axis=0), rc / np.linalg.norm(rc, axis=0) + rel0 = rc.T @ rh + res = {} + acts = sorted([x for x in bpy.data.actions if "uo_action" in x], key=lambda x: int(x["uo_action"])) + for act in acts: + aid = int(act["uo_action"]) + if only is not None and aid not in only: continue + rig.animation_data.action = act + n = int(act["uo_frames"]); loop = aid in LOOP + w, c = [], [] + for i in range(n): + sc.frame_set(1 + STEP * i); bpy.context.view_layer.update() + H, C = unit(np.array(P["head"].matrix)), unit(np.array(P["chest"].matrix)) + w.append(yaw_z(H @ rh.T)) + c.append(twist_y(rel0.T @ (C.T @ H))) + w, c = np.array(w), np.array(c) + idx = list(range(n)) if loop else list(range(1, n - 1)) + jump = [abs(((w[i] - 0.5 * (w[(i - 1) % n] + w[(i + 1) % n]) + 180) % 360) - 180) for i in idx] if n >= 3 else [0] + # unwrap the world yaw around its median so that min .. max is meaningful + wu = np.degrees(np.unwrap(np.radians(w))) + st = {"world_yaw": [float(wu.min()), float(wu.max())], "chest_yaw": [float(c.min()), float(c.max())], "jump": float(max(jump)), + "chest_yaw_jump": float(max(abs(c[i] - 0.5 * (c[(i - 1) % n] + c[(i + 1) % n])) for i in idx)) if n >= 3 else 0.0} + if per_frame: st["world_f"] = [round(float(x), 1) for x in wu]; st["chest_f"] = [round(float(x), 1) for x in c] + res[act.name] = st + print("%-26s %16s %16s %7s %7s" % ("action", "world yaw", "chest yaw", "jump", "cJump")) + for nm, st in res.items(): + print("%-26s %7.1f..%-7.1f %7.1f..%-7.1f %7.1f %7.1f" % (nm, *st["world_yaw"], *st["chest_yaw"], st["jump"], st["chest_yaw_jump"])) + if out: json.dump(res, open(out, "w"), indent=1) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/iou_action.py b/third_party/UO_Model3D_v13/pipeline/iou_action.py new file mode 100644 index 0000000..067cd7a --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/iou_action.py @@ -0,0 +1,12 @@ +"""Silhouette IoU per frame (mean of 5 directions) of one action from a body_part_raster.py npz vs the original frames. python iou_action.py lab.npz ACTION [lab2.npz]""" +import sys, os +import numpy as np +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from body_part_qa import originals +O = originals(); a = int(sys.argv[2]); zs = [np.load(p) for p in (sys.argv[1], *sys.argv[3:])] +for z in zs: + lab, key = z["lab"], z["key"]; r = {} + for i, k in enumerate(key): + if k[0] != a: continue + m = lab[i] >= 0; o = O[tuple(k)]; r.setdefault(k[2], []).append((m & o).sum() / max((m | o).sum(), 1)) + print(" ".join("f%d %.3f" % (f, np.mean(v)) for f, v in sorted(r.items())), "| mean %.4f" % np.mean([np.mean(v) for v in r.values()])) diff --git a/third_party/UO_Model3D_v13/pipeline/item_body_holes.py b/third_party/UO_Model3D_v13/pipeline/item_body_holes.py new file mode 100644 index 0000000..0de6af7 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/item_body_holes.py @@ -0,0 +1,145 @@ +"""The ORIGINAL UO body showing through holes of an item's clothing layer: what the client shows when the item (clothing.vd) is drawn over body 400. + + python item_body_holes.py ITEM.blend OUT_DIR [--clothing RENDER_DIR] [--actions 04_stand,...] [--sheet 02_run_unarmed:2 ...] [--json out.json] + +item_skin_check.py looks at the 3D preview (body and item rendered together); a user of the client sees something else: the clothing layer (render_uo_layer.py +LAYER "clothing", with EXACT_BODY the body hides the item where the 3D body is in front of it) drawn over the original body frames (client/body_0x190_frames). +A pixel of the original body that is not covered by the item but lies INSIDE the item's outline (the outline closed by 2 px and its holes filled) is a hole the +user sees as the body through the robe. Each hole pixel is labelled by the body part of the 3D model there (hands, head, arms, torso, legs; a render of the body +layer with flat part colours, the nearest labelled pixel): a hand in front of the robe is drawn over it on purpose, a leg or a shoulder is a fault. + +RENDER_DIR = a finished render of the item (OUT of run_render_headless.py / uo_make_item.py --vd, holding clothing/frames); without it the clothing layer is rendered +into OUT_DIR. Prints per action the hole pixels by part and the frames with any (hands and head apart), --sheet writes OUT_DIR/holes__.png: every frame +of that direction as the client shows it (3x), the hole pixels circled (black = arm, orange = torso, blue = leg, white = hand / head). +""" +import os, sys, glob, json, subprocess +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +BODY = os.path.join(HERE, "..", "client", "body_0x190_frames") +PARTS = {"hand": (255, 0, 0), "head": (0, 255, 0), "arm": (255, 255, 0), "torso": (255, 0, 255), "leg": (0, 255, 255)} +CLOSE = 2 # px: the outline of the item is closed by this much before its holes are filled (a slit between two folds is not a hole) + + +def render(blend, out, layer, acts, pre=None, extra=()): + cmd = [sys.executable, os.path.join(HERE, "run_render_headless.py"), blend, out] + (["--pre", pre] if pre else []) + [ + 'LAYER="%s"' % layer, "ONLY=%r" % (list(acts),), "CANVAS=(256,256)", "ANCHOR=(128,192)"] + list(extra) + subprocess.run(cmd, check=True, capture_output=True) + + +def body_frame(meta, act, d, f, canvas, anchor): + """alpha + RGB of the original body frame on our canvas (anchors aligned)""" + from PIL import Image + p = os.path.join(BODY, "frames", act, "dir%d" % d, "%02d.png" % f) + if not os.path.exists(p): + return None + a = np.array(Image.open(p).convert("RGBA")) + ox, oy = anchor[0] - meta["anchor"][0], anchor[1] - meta["anchor"][1] + out = np.zeros((canvas[1], canvas[0], 4), np.uint8) + h, w = a.shape[:2] + out[oy:oy + h, ox:ox + w] = a + return out + + +def labels(img): + """part index per pixel of the flat-coloured body render (-1 = none), nearest of the PARTS colours""" + cols = np.array(list(PARTS.values()), float) + rgb = img[..., :3].astype(float); al = img[..., 3] > 0 + d = ((rgb[..., None, :] - cols) ** 2).sum(-1) + lab = d.argmin(-1); lab[~al] = -1 + return lab + + +def main(): + from PIL import Image, ImageDraw + from scipy.ndimage import binary_closing, binary_fill_holes, distance_transform_edt + args = sys.argv[1:] + blend, out = os.path.abspath(args.pop(0)), os.path.abspath(args.pop(0)) + acts, sheets, clothing, js = [], [], None, None + while args: + f = args.pop(0) + if f == "--actions": + acts = args.pop(0).split(",") + elif f == "--sheet": + sheets.append(args.pop(0)) + elif f == "--clothing": + clothing = os.path.abspath(args.pop(0)) + elif f == "--json": + js = os.path.abspath(args.pop(0)) + os.makedirs(out, exist_ok=True) + meta = json.load(open(os.path.join(BODY, "meta.json"))) + allacts = sorted({os.path.basename(p) for p in glob.glob(os.path.join(BODY, "frames", "*"))}) + acts = acts or allacts + cdir = os.path.join(clothing or out, "clothing", "frames") + if not os.path.isdir(cdir): + render(blend, out, "clothing", acts) + cdir = os.path.join(out, "clothing", "frames") + ldir = os.path.join(out, "body", "frames") + if not os.path.isdir(ldir): # the body in flat part colours (item_skin_check.py colours it), no outline / UO colours + render(blend, out, "body", acts, pre=os.path.join(HERE, "item_skin_check.py"), extra=["EXACT_COLORS=False", "EXACT_BODY=False", "OUTLINE=1.0"]) + canvas, anchor = (256, 256), (128, 192) + pn = list(PARTS) + rep = {}; tot = np.zeros(len(pn), int); frames_bad = 0; nfr = 0 + for act in acts: + cnt = np.zeros(len(pn), int); bad = 0; per = {} + for d in range(5): + for p in sorted(glob.glob(os.path.join(cdir, act, "dir%d" % d, "*.png"))): + f = int(os.path.basename(p)[:2]); nfr += 1 + c = np.array(Image.open(p).convert("RGBA")); ca = c[..., 3] > 0 + b = body_frame(meta, act, d, f, canvas, anchor) + if b is None: + continue + ba = b[..., 3] > 0 + inside = binary_fill_holes(binary_closing(ca, iterations=CLOSE)) if ca.any() else ca + hole = ba & ~ca & inside + lp = os.path.join(ldir, act, "dir%d" % d, os.path.basename(p)) + if hole.any() and os.path.exists(lp): + lab = labels(np.array(Image.open(lp).convert("RGBA"))) + have = lab >= 0 + if have.any(): # the part of the nearest labelled pixel (the 3D body is off the original by a pixel or two) + _, (iy, ix) = distance_transform_edt(~have, return_indices=True) + lab = lab[iy, ix] + k = np.bincount(lab[hole], minlength=len(pn)) + else: + k = np.zeros(len(pn), int); k[pn.index("torso")] = int(hole.sum()) + cnt += k + fault = k[pn.index("arm")] + k[pn.index("torso")] + k[pn.index("leg")] + if fault: + bad += 1; per["%d:%d" % (d, f + 1)] = {pn[i]: int(k[i]) for i in range(len(pn)) if k[i]} + rep[act] = dict(parts={pn[i]: int(cnt[i]) for i in range(len(pn))}, frames_with_fault=bad, frames=per) + tot += cnt; frames_bad += bad + print("%-26s arm %4d torso %4d leg %4d | hand %4d head %3d | frames with arm / torso / leg holes %d" % ( + act, cnt[2], cnt[3], cnt[4], cnt[0], cnt[1], bad), flush=True) + print("ITEM_BODY_HOLES frames %d, arm %d, torso %d, leg %d px (hand %d, head %d), frames with a fault %d" % (nfr, tot[2], tot[3], tot[4], tot[0], tot[1], frames_bad)) + if js: + json.dump(rep, open(js, "w"), indent=1) + for s in sheets: # the client's view: clothing over the original body, 3x, holes circled + act, d = s.split(":"); d = int(d); tiles = [] + for p in sorted(glob.glob(os.path.join(cdir, act, "dir%d" % d, "*.png"))): + f = int(os.path.basename(p)[:2]) + c = np.array(Image.open(p).convert("RGBA")); b = body_frame(meta, act, d, f, canvas, anchor) + if b is None: + continue + bg = Image.new("RGBA", canvas, (30, 34, 30, 255)); bg.alpha_composite(Image.fromarray(b)); bg.alpha_composite(Image.fromarray(c)) + ca = c[..., 3] > 0; inside = binary_fill_holes(binary_closing(ca, iterations=CLOSE)); hole = (b[..., 3] > 0) & ~ca & inside + box = (70, 90, 186, 200); im = bg.crop(box).resize(((box[2] - box[0]) * 3, (box[3] - box[1]) * 3), Image.NEAREST); dr = ImageDraw.Draw(im) + lp = os.path.join(ldir, act, "dir%d" % d, os.path.basename(p)) + lab = labels(np.array(Image.open(lp).convert("RGBA"))) if os.path.exists(lp) else None + if lab is not None and (lab >= 0).any(): + _, (iy, ix) = distance_transform_edt(~(lab >= 0), return_indices=True); lab = lab[iy, ix] + for y, x in zip(*np.nonzero(hole)): + part = pn[lab[y, x]] if lab is not None else "torso" + col = {"arm": (0, 0, 0), "torso": (255, 120, 0), "leg": (0, 160, 255)}.get(part, (255, 255, 255)) + X, Y = (x - box[0]) * 3 + 1, (y - box[1]) * 3 + 1 + dr.ellipse((X - 4, Y - 4, X + 4, Y + 4), outline=col, width=2) + dr.text((4, 4), "%s d%d f%d" % (act, d, f + 1), fill=(255, 255, 255)) + tiles.append(im) + if tiles: + W = Image.new("RGB", (sum(t.width for t in tiles[:5]), tiles[0].height * ((len(tiles) + 4) // 5)), (0, 0, 0)) + for i, t in enumerate(tiles): + W.paste(t.convert("RGB"), ((i % 5) * t.width, (i // 5) * t.height)) + W.save(os.path.join(out, "holes_%s_%d.png" % (act, d))) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/item_gif.py b/third_party/UO_Model3D_v13/pipeline/item_gif.py index 242d12f..2ba9238 100644 --- a/third_party/UO_Model3D_v13/pipeline/item_gif.py +++ b/third_party/UO_Model3D_v13/pipeline/item_gif.py @@ -1,10 +1,11 @@ """An animated GIF of a rendered item on the original UO body (to look at the motion, not at single frames). - python item_gif.py RENDER_DIR OUT.gif [--actions 00_walk_unarmed,02_run_unarmed,09_attack_1h_slash] [--dirs 0,2,3] [--scale 4] [--fps 10] [--layer clothing] + python item_gif.py RENDER_DIR OUT.gif [--actions 00_walk_unarmed,02_run_unarmed,09_attack_1h_slash] [--dirs 0,2,3] [--scale 4] [--fps 10] [--layer clothing] [--crop auto|X0,Y0,X1,Y1] RENDER_DIR holds RENDER_DIR//frames/NN_action/dirK/NN.png (render_uo_layer.py, 256x256, anchor (128, 192)); the body frames are the originals of client/body_0x190_frames (145x133, anchor (75, 92)): they are exactly what the game draws under the item. Every action is shown in the given directions side by side and -looped `--loops` times, one action after the other. +looped `--loops` times, one action after the other. `--crop auto` (default: the fixed window CROP) = the window that holds every pixel of every shown frame (body and item) + 6 px, +so that nothing is cut off in the falls, the rides and the long swings. """ import argparse, glob, json, os import numpy as np @@ -29,10 +30,30 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("root"); ap.add_argument("out"); ap.add_argument("--layer", default="clothing") ap.add_argument("--actions", default="00_walk_unarmed,02_run_unarmed,09_attack_1h_slash"); ap.add_argument("--dirs", default="0,2,3") - ap.add_argument("--scale", type=int, default=4); ap.add_argument("--fps", type=int, default=10); ap.add_argument("--loops", type=int, default=2) + ap.add_argument("--crop", default=""); ap.add_argument("--scale", type=int, default=4); ap.add_argument("--fps", type=int, default=10); ap.add_argument("--loops", type=int, default=2) a = ap.parse_args() meta = json.load(open(os.path.join(BODY, "meta.json"))) dirs = [int(x) for x in a.dirs.split(",")] + crop = CROP + if a.crop == "auto": + box = None + for act in a.actions.split(","): + dd = glob.glob(os.path.join(a.root, a.layer, "frames", act + "*")) + if not dd: + continue + for d in dirs: + for f in sorted(glob.glob(os.path.join(dd[0], "dir%d" % d, "*.png"))) + [None] * 0: + bb = Image.open(f).convert("RGBA").getchannel("A").point(lambda v: 255 if v >= 128 else 0).getbbox() + if bb: + box = bb if box is None else (min(box[0], bb[0]), min(box[1], bb[1]), max(box[2], bb[2]), max(box[3], bb[3])) + for im in body_frames(meta, int(act[:2]), d): + bb = im.getchannel("A").point(lambda v: 255 if v >= 128 else 0).getbbox() + if bb: + box = bb if box is None else (min(box[0], bb[0]), min(box[1], bb[1]), max(box[2], bb[2]), max(box[3], bb[3])) + if box: + crop = (max(box[0] - 6, 0), max(box[1] - 6, 0), min(box[2] + 6, 256), min(box[3] + 6, 256)); print("crop", crop) + elif a.crop: + crop = tuple(int(x) for x in a.crop.split(",")) frames = [] for act in a.actions.split(","): n_act = int(act[:2]) @@ -53,7 +74,7 @@ def main(): if i < len(body): bg.alpha_composite(body[i]) bg.alpha_composite(Image.open(item[i]).convert("RGBA")) - tiles.append(bg.crop(CROP)) + tiles.append(bg.crop(crop)) W, H = tiles[0].size row = Image.new("RGB", (W * len(tiles), H + 12), (20, 20, 20)) for k, t in enumerate(tiles): @@ -62,7 +83,8 @@ def main(): frames.append(row.resize((row.width * a.scale, row.height * a.scale), Image.NEAREST)) if not frames: raise SystemExit("no frames in %s" % a.root) - frames[0].save(a.out, save_all=True, append_images=frames[1:], duration=int(1000 / a.fps), loop=0, optimize=False) + ms = 1000.0 / a.fps; dur = [int(round((k + 1) * ms / 10.0)) * 10 - int(round(k * ms / 10.0)) * 10 for k in range(len(frames))] # GIF delays are whole 1/100 s: the average stays exactly 1 / fps + frames[0].save(a.out, save_all=True, append_images=frames[1:], duration=dur, loop=0, optimize=False) print(a.out, len(frames), "frames", frames[0].size) diff --git a/third_party/UO_Model3D_v13/pipeline/item_qa.py b/third_party/UO_Model3D_v13/pipeline/item_qa.py index acdc720..696373f 100644 --- a/third_party/UO_Model3D_v13/pipeline/item_qa.py +++ b/third_party/UO_Model3D_v13/pipeline/item_qa.py @@ -7,7 +7,7 @@ inside share of the vertices more than 2 mm inside the skin (the render pushes these out by BODY_GAP, but a lot of them means bad weights or a shell that is too tight), depth the deepest vertex (mm), stand-off the distance from the skin of the vertices that are outside, p50 / p90 / max (cm): the "thickness". The original items stand 5.6-8.1 cm (plate) / 5.6 (shirt) from the - silhouette at p90 (docs/qa/layer_analysis.md); LIMIT_BY_KIND of uo_fit_item.py keeps the item under its slot's limit in the rest pose. + silhouette at p90; LIMIT_BY_KIND of uo_fit_item.py keeps the item under its slot's limit in the rest pose. Prints the worst actions. Items of a loose garment (custom property uo_cloth) are measured before the legs push them out, so their `inside` counts the legs poking through. """ import sys, os, json diff --git a/third_party/UO_Model3D_v13/pipeline/item_skin_check.py b/third_party/UO_Model3D_v13/pipeline/item_skin_check.py index 419448a..c2abcf6 100644 --- a/third_party/UO_Model3D_v13/pipeline/item_skin_check.py +++ b/third_party/UO_Model3D_v13/pipeline/item_skin_check.py @@ -2,16 +2,19 @@ body flat by part (hands red, head green, arms yellow, torso magenta, legs cyan), then the pixels of pure arm / torso colour are counted (blends at the edge of a hand read as magenta and are left out). The clothing layer cannot show this: there the item is drawn over the body even where the skin is in front of it. - python item_skin_check.py ITEM.blend OUT_DIR [--actions 04_stand,17_spell_area,...] [--sheet 17_spell_area] + python item_skin_check.py ITEM.blend OUT_DIR [--actions 04_stand,17_spell_area,...] [--sheet 17_spell_area] [--split] Prints per action the arm / torso pixels and the frames with any; --sheet writes OUT_DIR/marked_.png (5 directions x frames, the counted pixels circled: -black = arm, orange = torso). Torso pixels next to the head (the neck above a collar) do not count; the pelvis under a short hem does (look at the sheet). +black = arm, orange = torso, blue = leg). Torso pixels next to the head (the neck above a collar) do not count; the pelvis under a short hem does (look at the sheet). Runs the render (about 7 min for all actions); OUT_DIR/all/frames is reused when it is there. +--split (an item with holes on purpose, a torn robe): the item is also rendered alone (layer clothing, the body does not hide it) and each counted skin pixel is +split: THROUGH the cloth (the item is there, the skin is in front of it: the body pierces the item, a fault) or in a HOLE (no item at that pixel: seen through a rip of the model, or +below the hem). On the sheet the hole pixels are marked by small green circles. """ import os, sys, glob, subprocess import numpy as np -COLS = {"hand": (1, 0, 0), "head": (0, 1, 0), "arm": (1, 1, 0), "torso": (1, 0, 1), "leg": (0, 1, 1)} +COLS = {"hand": (1, 0, 0), "head": (0, 1, 0), "arm": (1, 1, 0), "torso": (1, 0, 1), "leg": (0, 1, 1), "foot": (1, 1, 1)} def colour_scene(): @@ -37,7 +40,7 @@ def emit(name, c): def part(n): b = n.split(".")[0] - return "hand" if (b == "hand" or b.startswith("finger")) else "head" if b == "head" else "arm" if b in ("upper_arm", "forearm") else "leg" if b in ("thigh", "shin", "foot") else "torso" + return "hand" if (b == "hand" or b.startswith("finger")) else "head" if b == "head" else "arm" if b in ("upper_arm", "forearm") else "leg" if b in ("thigh", "shin") else "foot" if b == "foot" else "torso" order = list(COLS) me.materials.clear() for k in order: @@ -57,32 +60,63 @@ def masks(a): return arm, tor +def foot_mask(a): + """feet (white): a robe / skirt hem that ends above the feet shows them legitimately below it, so they are counted apart and not in the leg numbers""" + r, g, b, al = a[..., 0], a[..., 1], a[..., 2], a[..., 3] > 0 + return al & (r > 235) & (g > 235) & (b > 235) + + +def leg_mask(a): + """legs without the feet (thigh, shin; cyan): arm / torso are what must stay covered, the legs are a robe's to cover down to the hem""" + r, g, b, al = a[..., 0], a[..., 1], a[..., 2], a[..., 3] > 0 + return al & (r < 110) & (g > 200) & (b > 200) + + def main(): from PIL import Image, ImageDraw args = sys.argv[1:] blend, out = os.path.abspath(args.pop(0)), os.path.abspath(args.pop(0)) - acts, sheets = [], [] + acts, sheets, split = [], [], False while args: f = args.pop(0) if f == "--actions": acts = args.pop(0).split(",") elif f == "--sheet": sheets.append(args.pop(0)) + elif f == "--split": + split = True here = os.path.dirname(os.path.abspath(__file__)) if not os.path.isdir(os.path.join(out, "all", "frames")): subprocess.run([sys.executable, os.path.join(here, "run_render_headless.py"), blend, out, "--pre", os.path.abspath(__file__), 'LAYER="all"', "EXACT_COLORS=False", "EXACT_BODY=False", "ONLY=%r" % (acts,), "CANVAS=(256,256)", "ANCHOR=(128,192)"], check=True, capture_output=True) - tot = [0, 0, 0, 0] + if split and not os.path.isdir(os.path.join(out, "clothing", "frames")): + subprocess.run([sys.executable, os.path.join(here, "run_render_headless.py"), blend, out, "--pre", os.path.abspath(__file__), 'LAYER="clothing"', "EXACT_COLORS=False", + "EXACT_BODY=False", "ONLY=%r" % (acts,), "CANVAS=(256,256)", "ANCHOR=(128,192)"], check=True, capture_output=True) + + def item_mask(f): + g = f.replace(os.path.join(out, "all", "frames"), os.path.join(out, "clothing", "frames")) + return np.array(Image.open(g).convert("RGBA"))[..., 3] > 0 if split and os.path.exists(g) else None + tot = [0, 0, 0, 0, 0]; stot = np.zeros(6, int) for act in sorted(os.listdir(os.path.join(out, "all", "frames"))): if acts and act not in acts: continue - arm = tor = fr = bad = 0 + arm = tor = leg = fr = bad = ft = 0 + sp = np.zeros(6, int) for f in sorted(glob.glob(os.path.join(out, "all", "frames", act, "dir*", "*.png"))): - m1, m2 = masks(np.array(Image.open(f).convert("RGBA")).astype(int)) - arm += int(m1.sum()); tor += int(m2.sum()); fr += 1; bad += int((m1.sum() + m2.sum()) > 0) - tot = [tot[0] + fr, tot[1] + arm, tot[2] + tor, tot[3] + bad] - print("%-26s frames %4d arm px %4d torso px %4d frames with any %d" % (act, fr, arm, tor, bad)) - print("ITEM_SKIN frames %d, arm px %d, torso px %d, frames with any %d" % tuple(tot)) + im = np.array(Image.open(f).convert("RGBA")).astype(int); m1, m2 = masks(im) + ft += int(foot_mask(im).sum()); leg += int(leg_mask(im).sum()); arm += int(m1.sum()); tor += int(m2.sum()); fr += 1; bad += int((m1.sum() + m2.sum()) > 0) + it = item_mask(f) + if it is not None: + for k, m in enumerate((m1, m2, leg_mask(im))): + sp[k] += int((m & it).sum()); sp[3 + k] += int((m & ~it).sum()) + tot = [tot[0] + fr, tot[1] + arm, tot[2] + tor, tot[3] + bad, tot[4] + leg] + print("%-26s frames %4d arm px %4d torso px %4d frames with any %d (leg px %d, not counted: foot px %d)" % (act, fr, arm, tor, bad, leg, ft)) + if split: + stot += sp + print("%-26s through the cloth: arm %4d torso %4d leg %4d | in holes: arm %4d torso %4d leg %4d" % (("",) + tuple(sp))) + print("ITEM_SKIN frames %d, arm px %d, torso px %d, frames with any %d, leg px %d" % tuple(tot)) + if split: + print("ITEM_SKIN_SPLIT through the cloth: arm %d torso %d leg %d px | in holes: arm %d torso %d leg %d px" % tuple(stot)) for act in sheets: rows = [] for d in range(5): @@ -92,9 +126,14 @@ def main(): crop = (88, 90, 168, 200) im = Image.new("RGBA", a.shape[1::-1], (255, 255, 255, 255)); im.alpha_composite(Image.fromarray(a.astype(np.uint8))) im = im.crop(crop).resize(((crop[2] - crop[0]) * 4, (crop[3] - crop[1]) * 4), Image.NEAREST); dr = ImageDraw.Draw(im) - for m, c in ((m1, (0, 0, 0)), (m2, (255, 120, 0))): + it = item_mask(f) + for m, c in ((m1, (0, 0, 0)), (m2, (255, 120, 0)), (leg_mask(a), (0, 160, 255))): for y, x in zip(*np.nonzero(m)): - X = (x - crop[0]) * 4 + 2; Y = (y - crop[1]) * 4 + 2; dr.ellipse((X - 5, Y - 5, X + 5, Y + 5), outline=c, width=2) + X = (x - crop[0]) * 4 + 2; Y = (y - crop[1]) * 4 + 2 + if it is not None and not it[y, x]: + dr.ellipse((X - 3, Y - 3, X + 3, Y + 3), outline=(0, 200, 0), width=1) + else: + dr.ellipse((X - 5, Y - 5, X + 5, Y + 5), outline=c, width=2) row.append(im) rows.append(row) W = Image.new("RGB", (max(len(r) for r in rows) * 320, 440 * len(rows)), (255, 255, 255)) diff --git a/third_party/UO_Model3D_v13/pipeline/pose_lib.py b/third_party/UO_Model3D_v13/pipeline/pose_lib.py new file mode 100644 index 0000000..fff3160 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/pose_lib.py @@ -0,0 +1,496 @@ +"""The UO rig in numpy: forward kinematics, linear blend skinning and a pixel-centre coverage raster of the body through the UO camera, +fast enough for pose fitting (no Blender needed after the export). + + python pose_lib.py export ../model/UO_Body_0x190.blend OUT.npz (bpy 4.2: rest pose, weights, triangles, camera, keys of all 35 actions) + python pose_lib.py check ../model/UO_Body_0x190.blend OUT.npz (bpy: pose matrices and silhouettes against Blender's own) + +Blender's conventions: pose matrix of a bone = parent pose matrix (scale removed when Inherit Scale = None) @ rest offset @ T(location) R(quaternion) S(scale); +skinning = sum of w * pose @ rest^-1 over the deforming bones, divided by the summed weight (Armature modifier, no preserve volume). +The canvas is the one of the original body frames (145x133, anchor (75, 92), 36 px/m) like body_part_raster.py. +""" +import sys, os +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +CW, CH = 145, 133 +STEP = 3 +LOOP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 15, 23, 24, 25} + + +# ---------------------------------------------------------------- quaternions (w, x, y, z) +def qnorm(q): + return q / np.linalg.norm(q, axis=-1, keepdims=True) + + +def qmul(a, b): + aw, ax, ay, az = np.moveaxis(a, -1, 0); bw, bx, by, bz = np.moveaxis(b, -1, 0) + return np.stack([aw * bw - ax * bx - ay * by - az * bz, aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw], -1) + + +def qconj(q): + return q * np.array([1.0, -1, -1, -1]) + + +def qmat(q): + q = qnorm(np.asarray(q, float)); w, x, y, z = np.moveaxis(q, -1, 0) + return np.stack([np.stack([1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], -1), + np.stack([2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], -1), + np.stack([2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], -1)], -2) + + +def matq(R): + """rotation matrices (..., 3, 3) -> unit quaternions (..., 4), w >= 0""" + from scipy.spatial.transform import Rotation + sh = R.shape[:-2]; q = Rotation.from_matrix(R.reshape(-1, 3, 3)).as_quat()[:, [3, 0, 1, 2]] + q[q[:, 0] < 0] *= -1 + return q.reshape(sh + (4,)) + + +def qexp(v): + """rotation vectors (..., 3) -> quaternions""" + a = np.linalg.norm(v, axis=-1, keepdims=True); s = np.where(a > 1e-12, np.sin(a / 2) / np.maximum(a, 1e-12), 0.5) + return np.concatenate([np.cos(a / 2), v * s], -1) + + +def qlog(q): + """quaternions -> rotation vectors (shortest)""" + q = np.where(q[..., :1] < 0, -q, q); v = q[..., 1:]; n = np.linalg.norm(v, axis=-1, keepdims=True) + a = 2 * np.arctan2(n, q[..., :1]) + return np.where(n > 1e-12, v / np.maximum(n, 1e-12) * a, 2 * v) + + +def qangle(a, b): + return 2 * np.arccos(np.clip(np.abs(np.sum(a * b, -1)), 0, 1)) + + +def qslerp(a, b, t): + d = np.sum(a * b, -1, keepdims=True); b = np.where(d < 0, -b, b); d = np.abs(d) + th = np.arccos(np.clip(d, -1, 1)); s = np.sin(th) + w0 = np.where(s > 1e-6, np.sin((1 - t) * th) / np.maximum(s, 1e-6), 1 - t); w1 = np.where(s > 1e-6, np.sin(t * th) / np.maximum(s, 1e-6), t) + return qnorm(w0 * a + w1 * b) + + +def ortho_y(R): + """Blender's orthogonalize_m3_stable(R, 1, true): keep the direction of the Y column, make X and Z orthogonal to it and to each other + (the shear between them split evenly), unit length""" + x, y, z = R[:, 0].copy(), R[:, 1].copy(), R[:, 2].copy() + ly2 = y @ y + x -= y * (x @ y) / ly2; z -= y * (z @ y) / ly2; y /= np.sqrt(ly2) + nz, nx = z / np.linalg.norm(z), x / np.linalg.norm(x) + c = nz @ nx + if 1e-4 < abs(c) < 1 - 1e-7: # blender: v2 = Z, v3 = X (axis order after the primary Y) + ang = np.arccos(c); tgt = ang + (np.pi / 2 - ang) / 2 + t = nz - nx * c; nz = t * (np.sin(tgt) / np.linalg.norm(t)) + nx * np.cos(tgt) + t = nx - nz * (nz @ nx); nx = t / np.linalg.norm(t) + return np.stack([nx, y, nz], 1) + + +# ---------------------------------------------------------------- rig +class Rig: + def __init__(self, path): + z = np.load(path, allow_pickle=True) + self.z = z + self.bones = [str(b) for b in z["bones"]]; self.bi = {b: i for i, b in enumerate(self.bones)} + self.parent = z["parent"]; self.rest = z["rest"]; self.noscale = z["noscale"] + self.order = list(range(len(self.bones))) # export writes parents before children + self.offset = np.array([np.linalg.inv(self.rest[p]) @ self.rest[b] if p >= 0 else self.rest[b] for b, p in enumerate(self.parent)]) + self.restinv = np.linalg.inv(self.rest) + self.V = z["verts"] # rest vertices in armature space (n, 3) + self.W = z["W"]; self.WB = z["WB"] # (n, k) weights normalised, (n, k) bone index (-1 = none) + self.T = z["tris"] + self.CAM = z["cam"] # (5, 4, 4): armature space -> clip space per direction + self.vbone = self.WB[np.arange(len(self.WB)), np.argmax(self.W, 1)] # dominant bone of a vertex + self.actions = {int(a): str(n) for a, n in zip(z["act_id"], z["act_name"])} + + def keys(self, aid): + """basis values of all bones for the frames of action aid: q (n, B, 4), loc (n, B, 3), scale (n, B, 3)""" + z = self.z; s = z["key_act"] == aid + return z["key_q"][s].copy(), z["key_loc"][s].copy(), z["key_scale"][s].copy() + + def fk(self, q, loc, scale, M=None, start=None): + """pose matrices (B, 4, 4) in armature space from basis values of one frame. With M and start (bone indices) only the + subtrees of `start` are recomputed in place (the rest of M is taken as is).""" + B = len(self.bones) + Rq = qmat(q) + basis = np.zeros((B, 4, 4)); basis[:, :3, :3] = Rq * scale[:, None, :]; basis[:, :3, 3] = loc; basis[:, 3, 3] = 1 + if M is None: + M = np.zeros((B, 4, 4)); todo = self.order + else: + todo = self.subtree(start) + for b in todo: + p = self.parent[b] + if p < 0: + M[b] = self.offset[b] @ basis[b] + elif self.noscale[b]: # Blender: rotation / scale from the orthogonalised parent, location through the full parent + Po = M[p].copy(); Po[:3, :3] = ortho_y(Po[:3, :3]) + rs = Po @ self.offset[b]; lm = M[p] @ self.offset[b] + M[b] = rs @ basis[b]; M[b][:3, 3] = lm[:3, :3] @ loc[b] + lm[:3, 3] + else: + M[b] = M[p] @ self.offset[b] @ basis[b] + return M + + def subtree(self, start): + """bone indices of the subtrees rooted at `start`, parents before children""" + s = set(start); out = [] + for b in self.order: + if b in s or self.parent[b] in s: + s.add(b); out.append(b) + return out + + def skin(self, M, vidx=None): + """deformed vertices (armature space) for pose matrices M; vidx = subset of vertices""" + K = M @ self.restinv # (B, 4, 4) + V = self.V if vidx is None else self.V[vidx] + W = self.W if vidx is None else self.W[vidx]; WB = self.WB if vidx is None else self.WB[vidx] + out = np.zeros((len(V), 3)) + for k in range(W.shape[1]): + b = WB[:, k]; ok = b >= 0 + if not ok.any(): continue + Kb = K[np.where(ok, b, 0)] + p = np.einsum("nij,nj->ni", Kb[:, :3, :3], V) + Kb[:, :3, 3] + out += (W[:, k] * ok)[:, None] * p + return out + + def project(self, X, d, depth=False): + h = np.c_[X, np.ones(len(X))] @ self.CAM[d].T + p = h[:, :2] / h[:, 3:4] + P = np.stack([(p[:, 0] + 1) * 0.5 * CW, (1 - p[:, 1]) * 0.5 * CH], 1) + return (P, h[:, 2] / h[:, 3]) if depth else P + + def horse(self, aid, i): + """(5, CH, CW) nearest horse-proxy depth inside the horse sprite (inf elsewhere), or None when the frame is not mounted""" + z = self.z; k = "horse_%d_%d" % (aid, i) + if k + "_v" not in z.files: return None + V, T, HM = z[k + "_v"], z[k + "_t"], z[k + "_m"] + out = [] + for d in range(5): + P, Zd = self.project(V, d, True) + _, dep = coverage_fast(P, T, Zd); dep = dep.reshape(CH, CW) + dep[~HM[d]] = np.inf + out.append(dep) + return np.array(out) + + +try: # optional: pip install numba (the same raster ~20x faster) + import numba + + @numba.njit(cache=True) + def _cov_nb(P, tris, Z, use_z, cnt, dep): + for t in range(tris.shape[0]): + a, b, c = tris[t, 0], tris[t, 1], tris[t, 2] + Ax, Ay, Bx, By, Cx, Cy = P[a, 0], P[a, 1], P[b, 0], P[b, 1], P[c, 0], P[c, 1] + den = (By - Cy) * (Ax - Cx) + (Cx - Bx) * (Ay - Cy) + if abs(den) <= 1e-12: continue + x0 = int(np.floor(min(Ax, Bx, Cx))); y0 = int(np.floor(min(Ay, By, Cy))) + x1 = int(np.ceil(max(Ax, Bx, Cx))); y1 = int(np.ceil(max(Ay, By, Cy))) + w = min(max(x1 - x0, 0), 16); h = min(max(y1 - y0, 0), 16) + for dy in range(h + 1): + py = y0 + dy + if py < 0 or py >= cnt.shape[0]: continue + cy = py + 0.5 + for dx in range(w + 1): + px = x0 + dx + if px < 0 or px >= cnt.shape[1]: continue + cx = px + 0.5 + l0 = ((By - Cy) * (cx - Cx) + (Cx - Bx) * (cy - Cy)) / den + l1 = ((Cy - Ay) * (cx - Cx) + (Ax - Cx) * (cy - Cy)) / den + if l0 >= -1e-4 and l1 >= -1e-4 and 1 - l0 - l1 >= -1e-4: + cnt[py, px] += 1 + if use_z: + z = l0 * Z[a] + l1 * Z[b] + (1 - l0 - l1) * Z[c] + if z < dep[py, px]: dep[py, px] = z + HAVE_NUMBA = True +except ImportError: + HAVE_NUMBA = False + + +def coverage_fast(P, tris, Z=None): + if HAVE_NUMBA: + cnt = np.zeros((CH, CW), np.int64); dep = np.full((CH, CW), np.inf) + _cov_nb(np.ascontiguousarray(P, np.float64), np.ascontiguousarray(tris, np.int64), np.ascontiguousarray(Z if Z is not None else np.zeros(1), np.float64), + Z is not None, cnt, dep) + return cnt.ravel() if Z is None else (cnt.ravel(), dep.ravel()) + return coverage_np(P, tris, Z) + + +def coverage_np(P, tris, Z=None): + """same as coverage_count, flattened (CH * CW,) int64 counts; triangles bucketed by their pixel box so every bucket is one vectorised pass. + With Z (depth per vertex, smaller = nearer) also returns the nearest depth per pixel (inf = empty).""" + T = P[tris] + A, B, C = T[:, 0], T[:, 1], T[:, 2] + mn = np.floor(T.min(1)).astype(np.int64); mx = np.ceil(T.max(1)).astype(np.int64) + den = (B[:, 1] - C[:, 1]) * (A[:, 0] - C[:, 0]) + (C[:, 0] - B[:, 0]) * (A[:, 1] - C[:, 1]) + ok = np.abs(den) > 1e-12 + w = np.clip(mx[:, 0] - mn[:, 0], 0, 16) + 1; h = np.clip(mx[:, 1] - mn[:, 1], 0, 16) + 1 + cnt = np.zeros(CH * CW, np.int64) + dep = np.full(CH * CW, np.inf) if Z is not None else None + ZT = Z[tris] if Z is not None else None + key = w * 32 + h + for k in np.unique(key[ok]): + t = np.nonzero(ok & (key == k))[0]; ww, hh = int(k // 32), int(k % 32) + dx = np.tile(np.arange(ww), hh); dy = np.repeat(np.arange(hh), ww) + px_ = mn[t, 0][:, None] + dx[None]; py_ = mn[t, 1][:, None] + dy[None]; cx, cy = px_ + 0.5, py_ + 0.5 + Ax, Ay, Bx, By, Cx, Cy, dn = (A[t, 0][:, None], A[t, 1][:, None], B[t, 0][:, None], B[t, 1][:, None], C[t, 0][:, None], C[t, 1][:, None], den[t][:, None]) + l0 = ((By - Cy) * (cx - Cx) + (Cx - Bx) * (cy - Cy)) / dn + l1 = ((Cy - Ay) * (cx - Cx) + (Ax - Cx) * (cy - Cy)) / dn + kk = (l0 >= -1e-4) & (l1 >= -1e-4) & (1 - l0 - l1 >= -1e-4) & (px_ >= 0) & (py_ >= 0) & (px_ < CW) & (py_ < CH) + lin = py_[kk] * CW + px_[kk] + cnt += np.bincount(lin, minlength=CH * CW) + if Z is not None: + zz = (l0 * ZT[t, 0][:, None] + l1 * ZT[t, 1][:, None] + (1 - l0 - l1) * ZT[t, 2][:, None])[kk] + np.minimum.at(dep, lin, zz) + return cnt if Z is None else (cnt, dep) + + +def dil8(m): + """3x3 binary dilation of a (CH, CW) bool mask""" + d = m.copy() + d[1:] |= m[:-1]; d[:-1] |= m[1:] + e = d.copy(); e[:, 1:] |= d[:, :-1]; e[:, :-1] |= d[:, 1:] + return e + + +def sil_cost(s, b, bd=None, edge_w=1.0): + """(1 - IoU) + edge_w * (pixels off by >= 2 px from the other silhouette) / union; >= 2 px = outside its 3x3 dilation + (same as distance_transform_edt >= 2 of arm_joint_fit.py / action_smooth_fit.py)""" + u = max((s | b).sum(), 1); c = 1 - (s & b).sum() / u + if edge_w: + bd = dil8(b) if bd is None else bd + c += edge_w * ((s & ~bd).sum() + (b & ~dil8(s)).sum()) / u + return c + + +def err2(s, b): + """pixels off by >= 2 px (model outside the dilated sprite + sprite outside the dilated model)""" + return int((s & ~dil8(b)).sum() + (b & ~dil8(s)).sum()) + + +class SilEval: + """silhouette cost of one UO frame (5 directions) for poses that change only the subtrees of `chain` bones: the rest of the body is + rasterised once (counts), the moving triangles every call""" + + def __init__(self, rig, orig, horse=None, edge_w=1.0): + self.R, self.O, self.edge_w = rig, orig, edge_w # orig: (5, CH, CW) bool + self.OD = np.array([dil8(o) for o in orig]) + self.horse = horse # (5, CH, CW) horse depth inside its sprite (Rig.horse) or None + self.chain = None + + def set_base(self, M, chain_bones): + R = self.R + sub = R.subtree([R.bi[b] for b in chain_bones]) + movb = np.zeros(len(R.bones), bool); movb[sub] = True + mv = (np.where(R.WB >= 0, movb[np.where(R.WB >= 0, R.WB, 0)], False) & (R.W > 0)).any(1) + mt = mv[R.T].any(1) + self.vidx = np.nonzero(mv)[0]; self.tmov = R.T[mt]; self.tsta = R.T[~mt] + X = R.skin(M) + self.Xs = X.copy() + self.static = [] + for d in range(5): + if self.horse is None: + self.static.append(coverage_fast(R.project(X, d), self.tsta)) + else: # mounted: the horse hides the body where it is nearer (depth of the static part kept) + P, Zd = R.project(X, d, True); self.static.append(coverage_fast(P, self.tsta, Zd)) + self.chain = chain_bones + + def _mask(self, d, X): + R = self.R + if self.horse is None: + return ((self.static[d] + coverage_fast(R.project(X, d), self.tmov)) > 0).reshape(CH, CW) + P, Zd = R.project(X, d, True) + c1, z1 = coverage_fast(P, self.tmov, Zd); c0, z0 = self.static[d] + s = ((c0 + c1) > 0).reshape(CH, CW); zb = np.minimum(z0, z1).reshape(CH, CW) + return s & ~(self.horse[d] < zb) + + def masks(self, M): + Xv = self.R.skin(M, self.vidx) + X = self.Xs; X[self.vidx] = Xv + return [self._mask(d, X) for d in range(5)] + + def cost(self, M): + S = self.masks(M) + return float(np.mean([sil_cost(s, self.O[d], self.OD[d], self.edge_w) for d, s in enumerate(S)])) + + def full_masks(self, M): + return full_masks(self.R, M, self.horse) + + +def full_masks(R, M, horse=None): + """body silhouettes (5, CH, CW) of pose M, the horse hiding what it is in front of""" + X = R.skin(M); out = [] + for d in range(5): + if horse is None: + out.append((coverage_fast(R.project(X, d), R.T) > 0).reshape(CH, CW)) + else: + P, Zd = R.project(X, d, True); c, z = coverage_fast(P, R.T, Zd) + out.append((c > 0).reshape(CH, CW) & ~(horse[d] < z.reshape(CH, CW))) + return np.array(out) + + +def coverage_count(P, tris, out=None): + """number of triangles covering each pixel centre (CH, CW) int16; P = projected vertices (n, 2) px; pixel-centre rule like body_part_raster.py""" + cnt = np.zeros((CH, CW), np.int16) if out is None else out + T = P[tris] + A, B, C = T[:, 0], T[:, 1], T[:, 2] + mn = np.floor(T.min(1)).astype(int); mx = np.ceil(T.max(1)).astype(int) + den = (B[:, 1] - C[:, 1]) * (A[:, 0] - C[:, 0]) + (C[:, 0] - B[:, 0]) * (A[:, 1] - C[:, 1]) + ok = np.abs(den) > 1e-12; size = np.clip(mx - mn, 0, 16) + for dy in range(int(size[:, 1].max(initial=0)) + 1): + for dx in range(int(size[:, 0].max(initial=0)) + 1): + t = np.nonzero(ok & (dx <= size[:, 0]) & (dy <= size[:, 1]))[0] + if not len(t): continue + px_ = mn[t, 0] + dx; py_ = mn[t, 1] + dy; cx, cy = px_ + 0.5, py_ + 0.5 + l0 = ((B[t, 1] - C[t, 1]) * (cx - C[t, 0]) + (C[t, 0] - B[t, 0]) * (cy - C[t, 1])) / den[t] + l1 = ((C[t, 1] - A[t, 1]) * (cx - C[t, 0]) + (A[t, 0] - C[t, 0]) * (cy - C[t, 1])) / den[t] + kk = (l0 >= -1e-4) & (l1 >= -1e-4) & (1 - l0 - l1 >= -1e-4) & (px_ >= 0) & (py_ >= 0) & (px_ < CW) & (py_ < CH) + np.add.at(cnt, (py_[kk], px_[kk]), 1) + return cnt + + +# ---------------------------------------------------------------- export / check (bpy) +def export(blend, out): + import bpy + sys.path.insert(0, HERE) + from arm_refit import setup + bpy.ops.wm.open_mainfile(filepath=blend) + sc = bpy.context.scene + rig, body = bpy.data.objects["UO_Rig"], bpy.data.objects["UO_Body"] + sc.camera = bpy.data.objects["UO_Camera"]; setup(sc) + bones = [] + def walk(b): + bones.append(b.name) + for c in b.children: walk(c) + for b in rig.data.bones: + if b.parent is None: walk(b) + bi = {b: i for i, b in enumerate(bones)} + Bd = rig.data.bones + rest = np.array([np.array(Bd[b].matrix_local) for b in bones]) + parent = np.array([bi[Bd[b].parent.name] if Bd[b].parent else -1 for b in bones]) + noscale = np.array([Bd[b].inherit_scale == "NONE" for b in bones]) + assert all(Bd[b].inherit_scale in ("FULL", "NONE") for b in bones) + assert all(Bd[b].use_inherit_rotation for b in bones) + me = body.data + Mba = np.array(rig.matrix_world.inverted() @ body.matrix_world) + rig["uo_direction"] = 0; rig.update_tag(); bpy.context.view_layer.update() + Mba = np.array(rig.matrix_world.inverted() @ body.matrix_world) + co = np.array([v.co[:] for v in me.vertices]); V = co @ Mba[:3, :3].T + Mba[:3, 3] + gname = {g.index: g.name for g in body.vertex_groups} + deform = {b.name for b in Bd if b.use_deform} + rows = [] + for v in me.vertices: + r = [(bi[gname[g.group]], g.weight) for g in v.groups if gname[g.group] in deform and gname[g.group] in bi and g.weight > 0] + rows.append(r) + K = max(len(r) for r in rows) + W = np.zeros((len(rows), K)); WB = -np.ones((len(rows), K), int) + for i, r in enumerate(rows): + s = sum(w for _, w in r) + for k, (b, w) in enumerate(r): W[i, k] = w / s; WB[i, k] = b + me.calc_loop_triangles() + tris = np.array([t.vertices[:] for t in me.loop_triangles]) + cam = [] + for d in range(5): + rig["uo_direction"] = d; rig.update_tag(); bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get(); c = sc.camera.evaluated_get(dg) + cam.append(np.array(c.calc_matrix_camera(dg, x=CW, y=CH)) @ np.array(c.matrix_world.inverted()) @ np.array(rig.matrix_world)) + rig["uo_direction"] = 0; rig.update_tag(); bpy.context.view_layer.update() + P = rig.pose.bones + acts = sorted([a for a in bpy.data.actions if "uo_action" in a], key=lambda a: int(a["uo_action"])) + kq, kl, ks, ka, kf, km = [], [], [], [], [], [] + for act in acts: + rig.animation_data.action = act + for i in range(int(act["uo_frames"])): + sc.frame_set(1 + STEP * i); bpy.context.view_layer.update() + kq.append([P[b].rotation_quaternion[:] for b in bones]); kl.append([P[b].location[:] for b in bones]); ks.append([P[b].scale[:] for b in bones]) + km.append([np.array(P[b].matrix) for b in bones]); ka.append(int(act["uo_action"])); kf.append(i) + import base64, zlib, json + HD = {} + masks = json.loads(bpy.data.texts["uo_horse_masks.json"].as_string())["masks"] if "uo_horse_masks.json" in bpy.data.texts else {} + Mr = np.linalg.inv(np.array(rig.matrix_world)) + for ob in bpy.data.objects: + if not ob.name.startswith("Horse_a") or ob.type != "MESH": continue + a_, f_ = (int(x) for x in ob.name[len("Horse_a"):].split("_f")) + ob.hide_viewport = ob.hide_render = False + bpy.context.view_layer.update() + for ob in bpy.data.objects: + if not ob.name.startswith("Horse_a") or ob.type != "MESH": continue + a_, f_ = (int(x) for x in ob.name[len("Horse_a"):].split("_f")) + dg = bpy.context.evaluated_depsgraph_get(); ev = ob.evaluated_get(dg); m2 = ev.to_mesh() + hv = np.array([v.co[:] for v in m2.vertices]); m2.calc_loop_triangles(); ht = np.array([t.vertices[:] for t in m2.loop_triangles]) + Mw = np.array(ev.matrix_world); ev.to_mesh_clear() + hv = hv @ (Mr @ Mw)[:3, :3].T + (Mr @ Mw)[:3, 3] + HM = np.zeros((5, CH, CW), bool) + for d in range(5): + v = masks.get("%d,%d,%d" % (a_, f_, d)) + if v is None: continue + bits = np.unpackbits(np.frombuffer(zlib.decompress(base64.b64decode(v)), np.uint8))[:120 * 136].reshape(120, 136).astype(bool) + HM[d, 92 - 86:92 - 86 + 120, 75 - 68:75 - 68 + 136] = bits + HD["horse_%d_%d_v" % (a_, f_)] = hv; HD["horse_%d_%d_t" % (a_, f_)] = ht; HD["horse_%d_%d_m" % (a_, f_)] = HM + np.savez_compressed(out, **HD, bones=np.array(bones), parent=parent, rest=rest, noscale=noscale, verts=V, W=W, WB=WB, tris=tris, cam=np.array(cam), + key_q=np.array(kq), key_loc=np.array(kl), key_scale=np.array(ks), key_mat=np.array(km), key_act=np.array(ka), key_frame=np.array(kf), + act_id=np.array([int(a["uo_action"]) for a in acts]), act_name=np.array([a.name for a in acts]), + frames=np.array([int(a["uo_frames"]) for a in acts])) + print("exported", out, len(bones), "bones", len(V), "verts", len(tris), "tris", len(ka), "frames") + + +def check(blend, npz): + """FK against Blender's pose matrices (all frames) and the coverage against body_part_raster.py (a few frames)""" + import bpy + sys.path.insert(0, HERE) + from body_part_raster import raster + from arm_refit import setup + R = Rig(npz); z = R.z + err = 0.0 + for n in range(len(z["key_act"])): + M = R.fk(z["key_q"][n], z["key_loc"][n], z["key_scale"][n]) + err = max(err, np.abs(M - z["key_mat"][n]).max()) + print("FK max abs error vs Blender pose matrices: %.2e" % err) + bpy.ops.wm.open_mainfile(filepath=blend) + sc = bpy.context.scene; rig, body = bpy.data.objects["UO_Rig"], bpy.data.objects["UO_Body"] + sc.camera = bpy.data.objects["UO_Camera"]; setup(sc) + acts = {int(a["uo_action"]): a for a in bpy.data.actions if "uo_action" in a} + from arm_joint_fit import horse_masks + ious = [] + for aid, i in [(0, 2), (9, 3), (17, 4), (21, 4), (26, 1), (23, 3), (29, 1)]: + rig.animation_data.action = acts[aid] + n = np.nonzero((z["key_act"] == aid) & (z["key_frame"] == i))[0][0] + S = full_masks(R, R.fk(z["key_q"][n], z["key_loc"][n], z["key_scale"][n]), R.horse(aid, i)) + hob, HM = horse_masks(aid, i) + for d in range(5): + rig["uo_direction"] = d; sc.frame_set(1 + STEP * i); rig.update_tag(); body.update_tag(); bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get(); cam = sc.camera.evaluated_get(dg) + Pm, Vm = np.array(cam.calc_matrix_camera(dg, x=CW, y=CH)), np.array(cam.matrix_world.inverted()) + lab, dep = raster(body, dg, Pm, Vm, None) + if hob is not None and d in HM: + hl, hd = raster(hob, dg, Pm, Vm, None); lab[(hl >= 0) & (hd < dep) & HM[d]] = -1 + b = lab >= 0; m = S[d] + ious.append((m & b).sum() / max((m | b).sum(), 1)) + print("coverage vs body_part_raster: min IoU %.5f, mean %.5f" % (min(ious), np.mean(ious))) + + +def qa(npz, out=None): + """silhouette IoU and >= 2 px errors of all 1050 frames (keys of the export) against the original body frames""" + from PIL import Image + import json + R = Rig(npz); z = R.z; res = {} + for n in range(len(z["key_act"])): + aid, i = int(z["key_act"][n]), int(z["key_frame"][n]); name = R.actions[aid] + S = full_masks(R, R.fk(z["key_q"][n], z["key_loc"][n], z["key_scale"][n]), R.horse(aid, i)) + for d in range(5): + o = np.array(Image.open(os.path.join(HERE, "..", "client/body_0x190_frames/frames/%s/dir%d/%02d.png" % (name, d, i))).convert("RGBA"))[..., 3] > 0 + r = res.setdefault(aid, [[], 0]) + if o.any(): r[0].append((S[d] & o).sum() / max((S[d] | o).sum(), 1)) + r[1] += err2(S[d], o) + allv = [v for r in res.values() for v in r[0]] + print("frames %d IoU %.4f err2 %d" % (len(allv), np.mean(allv), sum(r[1] for r in res.values()))) + for aid, r in sorted(res.items()): print(" %2d %-26s IoU %.4f err2 %4d" % (aid, R.actions[aid], np.mean(r[0]), r[1])) + if out: json.dump({"iou": float(np.mean(allv)), "err2": int(sum(r[1] for r in res.values())), + "actions": {str(a): {"iou": float(np.mean(r[0])), "err2": int(r[1])} for a, r in res.items()}}, open(out, "w"), indent=1) + + +if __name__ == "__main__": + if sys.argv[1] == "qa": + qa(os.path.abspath(sys.argv[2]), sys.argv[3] if len(sys.argv) > 3 else None) + elif sys.argv[1] == "export": + export(os.path.abspath(sys.argv[2]), os.path.abspath(sys.argv[3])) + elif sys.argv[1] == "check": + check(os.path.abspath(sys.argv[2]), os.path.abspath(sys.argv[3])) diff --git a/third_party/UO_Model3D_v13/pipeline/pose_natural_fit.py b/third_party/UO_Model3D_v13/pipeline/pose_natural_fit.py new file mode 100644 index 0000000..3e2132f --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/pose_natural_fit.py @@ -0,0 +1,442 @@ +"""Natural motion with the same silhouettes: re-fit of the pose keys of an action so that the rig moves like a body in 3D (no zig-zag of the head, +no twisting of the shoulder, no sliding clavicles) while the silhouette against the ORIGINAL body frames (5 directions) is not worse than now. + + python pose_lib.py export ../model/UO_Body_0x190.blend rig.npz + python pose_natural_fit.py rig.npz OUT_DIR [--actions 0,1,2] [--jobs 4] [--sweeps 2] (env: the weights below, e.g. LAMBDA, TWIST_W, SLIDE_W, TOL) + python apply_pose_keys.py ../model/UO_Body_0x190.blend OUT.blend OUT_DIR (writes the keys; bpy 4.2) + +Why: the poses were fitted frame by frame to the silhouettes (arm_joint_fit.py, rounds 1-6). Whatever the outline hardly shows was free to follow +the 1-px noise of the sprites: the head turned and tilted 5-25 deg from frame to frame, the hand flapped (up to 100 deg), the upper arm twisted +about its own axis (internal twist 20-30 deg folds the MakeHuman shoulder: the deltoid turns away from the chest), and the clavicles carried +location keys of 2-8 cm (a clavicle does not deform the mesh, so a shifted one slides the whole arm along the chest skin: a dent at the shoulder). + +What this does per action (UO frames only; loops wrap): + 1. clavicle location keys -> 0; the head one size everywhere (HEAD_SCALE, median of all keys); optional SCALE=1: per-frame girth scales smoothed in time ([1, 2, 1] / 4, loops wrap); + 2. start = the armature-space orientation of every fitted bone smoothed in time (PASSES of [1, 2, 1] / 4; one-shot actions only SMOOTH_ONESHOT + of the way, their ends kept); + 3. Gauss-Seidel sweeps over the frames, chains torso (spine, chest), head (neck, head), armR / armL (clavicle, upper arm, forearm, hand): + Powell on E = LAMBDA * sum_b w_b angle(G_b, mid(G_b(i-1), G_b(i+1)))^2 smooth in time + + TWIST_W * fade * twist_excess^2 upper-arm twist vs the stand pose (only while the arm hangs) + + SLIDE_W * slide_excess^2 shoulder joint moving on the chest (clavicle rotation) + + VALGUS_W * forearm_valgus^2 + FOLD_W * shoulder_crease^2 elbow a hinge, no deeper shoulder crease than in stand + + Sil + 50 * max(0, Sil - Sil0 - TOL) + 0.02 * max(0, err2 - err2_0 - ERR2_TOL) + G_b = armature-space orientation, mid = half way between the neighbours (loops wrap); Sil = (1 - IoU) + (pixels off by >= 2 px) / union, + mean of 5 directions, horse hiding in the mounted actions (as body_part_raster.py); Sil0 / err2_0 = the same for the ORIGINAL keys. + An infeasible result is tried again from the original keys of the chain; the best feasible candidate wins; + 4. guard: a frame still worse than the original (Sil0 + TOL, err2_0 + ERR2_TOL) is blended back towards its original keys (0.75 ... 0). +Item bones (weapon1h.R, polearm.L, axe2h.L, bow.L, shield.L) are not fitted: apply_pose_keys.py keeps their armature-space motion (they were +fitted to the original weapons). Output per action: OUT_DIR/a.json (local quaternions / locations / scales of every bone per frame + log). +Needs: numpy scipy pillow (no bpy). +""" +import sys, os, json, time +import numpy as np +from scipy.optimize import minimize +from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from pose_lib import Rig, SilEval, full_masks, sil_cost, err2, dil8, qmul, qexp, qnorm, qmat, matq, qangle, qslerp, LOOP, CH, CW, HERE + +LAMBDA = float(os.environ.get("LAMBDA", 1.0)) # smoothness weight, per rad^2 +TWIST_W = float(os.environ.get("TWIST_W", 4.0)) # upper-arm twist beyond TWIST_FREE from the stand pose, per rad^2 +TWIST_FREE = np.radians(float(os.environ.get("TWIST_FREE", 3))) +SLIDE_W = float(os.environ.get("SLIDE_W", 300.0)) # shoulder joint sliding on the chest beyond SLIDE_FREE from the stand pose, per m^2 +SLIDE_FREE = float(os.environ.get("SLIDE_FREE", 0.01)) +VALGUS_W = float(os.environ.get("VALGUS_W", 4.0)) # forearm swing about its local Z (the elbow is a hinge), per rad^2 +FOLD_W = float(os.environ.get("FOLD_W", 0.01)) # crease of the shoulder (mean rise of the dihedral angles where chest and upper arm skin meet) beyond + # the stand pose, per deg^2 +GRIP_W = float(os.environ.get("GRIP_W", 0.0)) # right fist away from the grip of the 1H weapon bone (weapon1h.R, fitted to the original weapons), per m^2 +GRIP_FREE = float(os.environ.get("GRIP_FREE", 0.02)) +GRIP_ROT_W = float(os.environ.get("GRIP_ROT_W", 0.0)) # right fist turned against the weapon bone (their rest relation), per rad^2 + # (GRIP_* off: the weapon motion jumps, a fist following it jumps too) +TOL = float(os.environ.get("TOL", 0.001)) # allowed rise of Sil per frame +ERR2_TOL = int(os.environ.get("ERR2_TOL", 0)) # allowed rise of the >= 2 px errors per frame (sum of 5 directions) +HEAD_YAW_ACTIONS = {int(x) for x in os.environ.get("HEAD_YAW_ACTIONS", "23,24").split(",") if x} # mounted walk / run: the rider is hidden under the horse +HEAD_YAW_W = float(os.environ.get("HEAD_YAW_W", 3.0)) # head turned about the chest's own axis beyond HEAD_YAW_FREE from the mounted-stand pose, per rad^2 + # (only for HEAD_YAW_ACTIONS; the heads of fighting actions are left to move) +HEAD_YAW_FREE = np.radians(float(os.environ.get("HEAD_YAW_FREE", 2))) +HEAD_LAMBDA = float(os.environ.get("HEAD_LAMBDA", 1.0)) # multiplier of the smoothness weight of neck / head in those actions +HEAD_TOL = float(os.environ.get("HEAD_TOL", 0.004)) # silhouette tolerance per frame of those actions (the horse hides the rider, the sprite outline hardly pins the head) +HEAD_ERR2_TOL = int(os.environ.get("HEAD_ERR2_TOL", 30)) +MAXFEV = int(os.environ.get("MAXFEV", 400)) +PASSES = int(os.environ.get("PASSES", 1)) +SMOOTH_ONESHOT = float(os.environ.get("SMOOTH_ONESHOT", 0.5)) # one-shot actions (fast attacks...) start only this share of the way to the smoothed orientation +TWIST_DOWN = np.radians(float(os.environ.get("TWIST_DOWN", 60))) # the twist prior fades out as the upper arm leaves the stand direction (0 at this angle) +SCALE = int(os.environ.get("SCALE", 0)) +HEAD_SCALE = int(os.environ.get("HEAD_SCALE", 1)) # 1 = the head keeps one size in every frame of every action (median of all keys; a rigid + # helmet bound to the head scaled 0.74-1.14 from frame to frame before) +CHAINS = {"torso": ["spine", "chest"], "head": ["neck", "head"], + "armR": ["clavicle.R", "upper_arm.R", "forearm.R", "hand.R"], "armL": ["clavicle.L", "upper_arm.L", "forearm.L", "hand.L"]} +BONE_W = {"spine": 1.0, "chest": 1.0, "neck": 1.0, "head": 2.0, "clavicle.R": 1.0, "clavicle.L": 1.0, "upper_arm.R": 1.0, "upper_arm.L": 1.0, + "forearm.R": 0.7, "forearm.L": 0.7, "hand.R": 0.5, "hand.L": 0.5} +ITEM_BONES = ["weapon1h.R", "polearm.L", "axe2h.L", "bow.L", "shield.L"] +GIRTH = ["upper_arm.L", "forearm.L", "hand.L", "upper_arm.R", "forearm.R", "hand.R", "thigh.L", "shin.L", "foot.L", "thigh.R", "shin.R", "foot.R", "head"] +ROOT = os.path.join(HERE, "..") + + +def originals(aid, name, n): + out = [] + for i in range(n): + out.append(np.array([np.array(Image.open(os.path.join(ROOT, "client/body_0x190_frames/frames/%s/dir%d/%02d.png" % (name, d, i))).convert("RGBA"))[..., 3] > 0 + for d in range(5)])) + return out + + +def rot_of(M): + """rotation part (B, 3, 3) of pose matrices (scale removed)""" + return M[:, :3, :3] / np.linalg.norm(M[:, :3, :3], axis=1, keepdims=True) + + +def smooth_seq(Q, loop, passes, pin_ends=True): + """[1, 2, 1] / 4 smoothing of a quaternion sequence (n, 4), sign-aligned; loops wrap, one-shot ends pinned""" + Q = Q.copy(); n = len(Q) + for i in range(1, n): Q[i] *= np.sign(np.sum(Q[i] * Q[i - 1])) or 1 + for _ in range(passes): + P = Q.copy() + for i in range(n): + if not loop and i in (0, n - 1): + if pin_ends or n < 3: continue + a, b = Q[(i - 1) % n], Q[(i + 1) % n] + if not loop: a, b = Q[max(i - 1, 0)], Q[min(i + 1, n - 1)] + a = a * (np.sign(np.sum(a * Q[i])) or 1); b = b * (np.sign(np.sum(b * Q[i])) or 1) + P[i] = qnorm(a + 2 * Q[i] + b) + Q = P + return Q + + +def swing_twist_y(q): + """twist angle (rad) about local Y of quaternion q (w, x, y, z) and the swing about local Z (rad, of the swing part)""" + tw = 2 * np.arctan2(q[2], q[0]); tw = (tw + np.pi) % (2 * np.pi) - np.pi + t = np.array([np.cos(tw / 2), 0, np.sin(tw / 2), 0]) + sw = qmul(q, t * np.array([1, -1, -1, -1])) + v = 2 * np.arctan2(np.linalg.norm(sw[1:]), sw[0]) * sw[1:] / max(np.linalg.norm(sw[1:]), 1e-12) + return tw, v[2] + + +class ActionFit: + def __init__(self, R, aid, log=print): + self.R, self.aid, self.log = R, aid, log + self.name = R.actions[aid] + q, loc, sc = R.keys(aid) + self.n = len(q); self.loop = aid in LOOP + self.q0, self.loc0, self.sc0 = q.copy(), loc.copy(), sc.copy() + self.q, self.loc, self.sc = q.copy(), loc.copy(), sc.copy() + self.O = originals(aid, self.name, self.n) + self.H = [R.horse(aid, i) for i in range(self.n)] + bi = R.bi + self.fit_bones = [b for c in CHAINS.values() for b in c] + # stand pose twist of the upper arms relative to the chest (reference of a natural twist) + qs, ls, ss = R.keys(4); Ms = R.fk(qs[0], ls[0], ss[0]); Gs = rot_of(Ms) + self.tw_ref = {s: self.uarm_twist(Gs, s) for s in "LR"} + self.slide_ref = {s: self.joint_in_chest(Ms, s) for s in "LR"} + self.uarm_dir_ref = {s: Gs[R.bi["chest"]].T @ Gs[R.bi["upper_arm." + s]][:, 1] for s in "LR"} + self.tol, self.err2_tol = (HEAD_TOL, HEAD_ERR2_TOL) if aid in HEAD_YAW_ACTIONS else (TOL, ERR2_TOL) + self.yaw_w = HEAD_YAW_W if aid in HEAD_YAW_ACTIONS else 0.0 + qm, lm, sm = R.keys(25); Gm = rot_of(R.fk(qm[0], lm[0], sm[0])); self.head_yaw_ref = {b: self.head_yaw(Gm, b) for b in ("neck", "head")} + self.fold_setup(); Xs = R.skin(Ms); self.fold_ref = {s: self.fold(Xs, s) for s in "LR"} + self.has_grip = "weapon1h.R" in R.bi + if self.has_grip: + h, w = R.bi["hand.R"], R.bi["weapon1h.R"] + self.grip_off = np.linalg.inv(R.rest[h]) @ R.rest[w] + self.grip_T = [self.M(i, orig=True)[w] for i in range(self.n)] + self.sil0, self.e20 = [], [] + for i in range(self.n): + S = full_masks(R, self.M(i, orig=True), self.H[i]) + self.sil0.append(float(np.mean([sil_cost(S[d], self.O[i][d]) for d in range(5)]))) + self.e20.append(sum(err2(S[d], self.O[i][d]) for d in range(5))) + + def M(self, i, orig=False): + if orig: return self.R.fk(self.q0[i], self.loc0[i], self.sc0[i]) + return self.R.fk(self.q[i], self.loc[i], self.sc[i]) + + def uarm_twist(self, G, side): + R = self.R; c, u = R.bi["chest"], R.bi["upper_arm." + side] + rel = (R.rest[c][:3, :3].T @ R.rest[u][:3, :3]).T @ (G[c].T @ G[u]) + return swing_twist_y(matq(rel))[0] + + def head_yaw(self, G, bone="head"): + """twist (rad) of the head about the chest's own Y, the rest relation removed (+ = same sense as the head turning against the chest)""" + R = self.R; c, h = R.bi["chest"], R.bi[bone] + rel = (R.rest[c][:3, :3].T @ R.rest[h][:3, :3]).T @ (G[c].T @ G[h]) + return swing_twist_y(matq(rel))[0] + + def fold_setup(self): + R = self.R; T = R.T + def wsum(names): + m = np.zeros(len(R.V)) + for b in names: m += (R.W * (R.WB == R.bi[b])).sum(1) + return m + from collections import defaultdict + ef = defaultdict(list) + for f, t in enumerate(T): + for a, b in ((t[0], t[1]), (t[1], t[2]), (t[2], t[0])): + ef[(min(a, b), max(a, b))].append(f) + E = np.array([k for k, v in ef.items() if len(v) == 2]); F = np.array([ef[tuple(k)] for k in E]) + self.fold_E = {} + for s in "LR": + reg = (wsum(["upper_arm." + s]) > 0.05) & (wsum(["chest", "spine", "neck"]) > 0.05) + sel = reg[E].all(1); Fs = F[sel]; tri = np.unique(Fs); remap = {t: j for j, t in enumerate(tri)} + self.fold_E[s] = (T[tri], np.vectorize(remap.get)(Fs)) + self.fold_E[s] = self.fold_E[s] + (self._dihedral(R.V, s),) + + def _dihedral(self, X, s): + T, Fl = self.fold_E[s][0], self.fold_E[s][1] + n = np.cross(X[T[:, 1]] - X[T[:, 0]], X[T[:, 2]] - X[T[:, 0]]); n /= np.linalg.norm(n, axis=1, keepdims=True) + 1e-12 + return np.degrees(np.arccos(np.clip(np.sum(n[Fl[:, 0]] * n[Fl[:, 1]], 1), -1, 1))) + + def fold(self, X, s): + return float(np.mean(np.clip(self._dihedral(X, s) - self.fold_E[s][2], 0, None))) + + def joint_in_chest(self, M, side): + """shoulder joint (head of upper_arm) in the chest bone's frame (m): the clavicle does not deform the skin, so this moving is the arm + sliding along the chest""" + R = self.R; c, u = R.bi["chest"], R.bi["upper_arm." + side] + Rc = M[c][:3, :3] / np.linalg.norm(M[c][:3, :3], axis=0) + return Rc.T @ (M[u][:3, 3] - M[c][:3, 3]) + + def set_local_from_arm(self, i, G_target, bones): + """local quaternions of `bones` (parents first) so that their armature-space orientations become G_target[b] (3x3)""" + R = self.R; M = self.M(i) + for b in bones: + k = R.bi[b]; p = R.parent[k] + Pm = M[p][:3, :3] + if R.noscale[k]: + from pose_lib import ortho_y + Pm = ortho_y(Pm) + A = Pm @ R.offset[k][:3, :3] + U, _, Vt = np.linalg.svd(A); A = U @ Vt # rotation part (parents of fitted bones carry no scale) + self.q[i, k] = matq(A.T @ G_target[b]) + M = R.fk(self.q[i], self.loc[i], self.sc[i], M=M, start=[k]) + + # ------------------------------------------------------------------ steps + def prepare(self): + R = self.R + for s in "LR": + self.loc[:, R.bi["clavicle." + s]] = 0.0 + if HEAD_SCALE: + self.sc[:, R.bi["head"]] = np.median(R.z["key_scale"][:, R.bi["head"]], 0) + if SCALE and self.n > 2: + for b in GIRTH: + k = R.bi[b]; v = self.sc[:, k].copy() + for _ in range(SCALE): + v = 0.25 * (np.roll(v, 1, 0) + 2 * v + np.roll(v, -1, 0)) if self.loop else \ + np.concatenate([v[:1], 0.25 * (v[:-2] + 2 * v[1:-1] + v[2:]), v[-1:]]) + self.sc[:, k] = v + if PASSES and self.n > 2: + G = np.array([rot_of(self.M(i)) for i in range(self.n)]) # (n, B, 3, 3) + order = [b for b in R.bones if b in self.fit_bones] + Gt = {} + for b in order: + Qb = matq(G[:, R.bi[b]]) + Qs = smooth_seq(Qb, self.loop, PASSES) + if not self.loop: Qs = qslerp(Qb, Qs, SMOOTH_ONESHOT) + Gt[b] = qmat(Qs) + for i in range(self.n): + if not self.loop and i in (0, self.n - 1): continue + self.set_local_from_arm(i, {b: Gt[b][i] for b in order}, order) + + def chain_energy_parts(self, i, M, chain, mids, X=None): + R = self.R; G = rot_of(M); e = 0.0 + if X is not None and FOLD_W: + for s in "LR": + if any(b in chain for b in ("spine", "chest", "clavicle." + s, "upper_arm." + s)): + e += FOLD_W * max(0.0, self.fold(X, s) - self.fold_ref[s]) ** 2 + if self.has_grip and "hand.R" in chain and (GRIP_W or GRIP_ROT_W): + H = M[R.bi["hand.R"]].copy(); H[:3, :3] /= np.linalg.norm(H[:3, :3], axis=0) + g = H @ self.grip_off; T = self.grip_T[i] + e += GRIP_W * max(0.0, np.linalg.norm(g[:3, 3] - T[:3, 3]) - GRIP_FREE) ** 2 + Tr = T[:3, :3] / np.linalg.norm(T[:3, :3], axis=0) + e += GRIP_ROT_W * qangle(matq(g[:3, :3]), matq(Tr)) ** 2 + if self.yaw_w and "head" in chain: + for b in ("neck", "head"): # neck too: else the neck takes the turn and waste_neck rises + e += self.yaw_w * max(0.0, abs(self.head_yaw(G, b) - self.head_yaw_ref[b]) - HEAD_YAW_FREE) ** 2 + for b in chain: + if mids is not None and b in mids: + e += LAMBDA * (HEAD_LAMBDA if self.yaw_w and b in ("neck", "head") else 1.0) * BONE_W[b] * qangle(matq(G[R.bi[b]]), mids[b]) ** 2 + for s in "LR": + if "clavicle." + s in chain: + ex = max(0.0, np.linalg.norm(self.joint_in_chest(M, s) - self.slide_ref[s]) - SLIDE_FREE) + e += SLIDE_W * ex ** 2 + if "upper_arm." + s in chain: + ex = max(0.0, abs(self.uarm_twist(G, s) - self.tw_ref[s]) - TWIST_FREE) + c, u = R.bi["chest"], R.bi["upper_arm." + s] + dy = G[c].T @ G[u][:, 1] + fade = max(0.0, 1 - np.arccos(np.clip(dy @ self.uarm_dir_ref[s], -1, 1)) / TWIST_DOWN) + e += TWIST_W * fade * ex ** 2 + if "forearm." + s in chain: + v = swing_twist_y(qnorm(self.q[i, R.bi["forearm." + s]]))[1] + e += VALGUS_W * v ** 2 + return e + + def mids(self, i, chain): + if self.n < 3: return None + R = self.R; n = self.n + if not self.loop and i in (0, n - 1): + j = 1 if i == 0 else n - 2 # ends: stay close to the neighbour's orientation + Gj = rot_of(self.M(j)); return {b: matq(Gj[R.bi[b]]) for b in chain} + Ga, Gb = rot_of(self.M((i - 1) % n)), rot_of(self.M((i + 1) % n)) + return {b: qslerp(matq(Ga[R.bi[b]]), matq(Gb[R.bi[b]]), 0.5) for b in chain} + + def fit_frame(self, i, chain_name): + R = self.R; chain = CHAINS[chain_name]; ks = [R.bi[b] for b in chain] + M = self.M(i) + ev = SilEval(R, self.O[i], self.H[i]); ev.set_base(M, chain) + mids = self.mids(i, chain) + base = self.q[i, ks].copy() + s0, tol = self.sil0[i], self.tol + root = [R.bi[chain[0]]] + + def pose(x): + q = self.q[i].copy() + for j, k in enumerate(ks): + q[k] = qnorm(qmul(base[j], qexp(x[3 * j:3 * j + 3]))) + return q + + e2_0 = self.e20[i] + + def sil_e2(Mx): + S = ev.masks(Mx) # also leaves the posed vertices in ev.Xs + return float(np.mean([sil_cost(S[d], self.O[i][d], ev.OD[d]) for d in range(5)])), sum(err2(S[d], self.O[i][d]) for d in range(5)) + + def penal(sv, e2): + return 50 * max(0.0, sv - s0 - tol) + 0.02 * max(0, e2 - e2_0 - self.err2_tol) + + def energy(x, parts=False): + q = pose(x); Mx = R.fk(q, self.loc[i], self.sc[i], M=M.copy(), start=root) + sv, e2 = sil_e2(Mx) + qs = self.q[i].copy(); self.q[i] = q + pr = self.chain_energy_parts(i, Mx, chain, mids, ev.Xs) + self.q[i] = qs + e = pr + sv + penal(sv, e2) + return (e, sv, pr, e2) if parts else e + + x0 = np.zeros(3 * len(ks)) + e_start, s_start, p_start, e2_start = energy(x0, True) + r = minimize(energy, x0, method="Powell", options={"xtol": 1e-3, "ftol": 1e-6, "maxfev": MAXFEV}) + e1, s1, p1, e21 = energy(r.x, True) + cand = [(e1, s1, p1, e21, pose(r.x)), (e_start, s_start, p_start, e2_start, self.q[i].copy())] + nfev = int(r.nfev) + if not (s1 <= s0 + tol and e21 <= e2_0 + self.err2_tol): # second start: the original keys of the chain + cur = base.copy(); base[:] = self.q0[i, ks] + r2 = minimize(energy, x0, method="Powell", options={"xtol": 1e-3, "ftol": 1e-6, "maxfev": MAXFEV}) + e3, s3, p3, e23 = energy(r2.x, True); cand.append((e3, s3, p3, e23, pose(r2.x))); nfev += int(r2.nfev) + base[:] = cur + # the original keys of this chain (with the clavicle location removed) as a fallback + q_or = self.q[i].copy(); q_or[ks] = self.q0[i, ks] + Mo = R.fk(q_or, self.loc[i], self.sc[i]); so, e2o = sil_e2(Mo) + qs = self.q[i].copy(); self.q[i] = q_or; po = self.chain_energy_parts(i, Mo, chain, mids, ev.Xs); self.q[i] = qs + cand.append((po + so + penal(so, e2o), so, po, e2o, q_or)) + feas = [c for c in cand if c[1] <= s0 + tol and c[3] <= e2_0 + self.err2_tol] + if feas: best = min(feas, key=lambda c: c[0]) + elif self.yaw_w and chain_name == "head": best = min(cand, key=lambda c: c[0]) # the other chains' state may already be over tolerance: the yaw prior still decides + else: best = min(cand, key=lambda c: c[1] + 0.02 * c[3]) + self.q[i] = best[4] + return dict(frame=i, chain=chain_name, sil0=round(s0, 5), sil_start=round(s_start, 5), sil=round(best[1], 5), prior_start=round(p_start, 5), + prior=round(best[2], 5), err2_0=int(e2_0), err2=int(best[3]), nfev=nfev, feasible=bool(feas)) + + def frame_sil(self, i, q, loc): + S = full_masks(self.R, self.R.fk(q, loc, self.sc[i]), self.H[i]) + return float(np.mean([sil_cost(S[d], self.O[i][d]) for d in range(5)])), sum(err2(S[d], self.O[i][d]) for d in range(5)) + + def guard(self): + """no frame ends worse than the original keys: a frame whose silhouette cost rose by more than TOL or whose >= 2 px errors rose is + blended back towards the original keys (fitted bones and clavicle location), the largest share of the new pose that passes""" + R = self.R; ks = [R.bi[b] for b in self.fit_bones]; cl = [R.bi["clavicle.L"], R.bi["clavicle.R"]] + self.reverted = [] + for i in range(self.n): + sv, e2 = self.frame_sil(i, self.q[i], self.loc[i]) + if sv <= self.sil0[i] + self.tol and e2 <= self.e20[i] + self.err2_tol: continue + done = False + for t in (0.75, 0.5, 0.25, 0.0): + q = self.q[i].copy(); loc = self.loc[i].copy() + q[ks] = qslerp(self.q0[i, ks], self.q[i, ks], t); loc[cl] = (1 - t) * self.loc0[i, cl] + t * self.loc[i, cl] + sv2, e22 = self.frame_sil(i, q, loc) + if (sv2 <= self.sil0[i] + self.tol and e22 <= self.e20[i] + self.err2_tol) or t == 0.0: + self.q[i], self.loc[i] = q, loc; done = True + self.reverted.append((i, t)); self.log("guard: frame %d sil %.4f err2 %d -> share %.2f of the new pose: sil %.4f err2 %d (orig %.4f, %d)" % + (i, sv, e2, t, sv2, e22, self.sil0[i], self.e20[i])) + break + + def keep_items(self): + """item bones (weapons, shield) keep their ORIGINAL armature-space matrices: their local keys are solved under the new hands / forearm""" + R = self.R + from pose_lib import ortho_y + for i in range(self.n): + T0 = self.M(i, orig=True); M = self.M(i) + for b in ITEM_BONES: + if b not in R.bi: continue + k = R.bi[b]; p = R.parent[k]; T = T0[k] + if R.noscale[k]: + Po = M[p].copy(); Po[:3, :3] = ortho_y(Po[:3, :3]) + rs = Po @ R.offset[k]; lm = M[p] @ R.offset[k] + A = np.linalg.solve(rs[:3, :3], T[:3, :3]) / self.sc[i, k][None, :] + U, _, Vt = np.linalg.svd(A); self.q[i, k] = matq(U @ Vt) + self.loc[i, k] = np.linalg.solve(lm[:3, :3], T[:3, 3] - lm[:3, 3]) + else: + B = np.linalg.solve(M[p] @ R.offset[k], T) + A = B[:3, :3] / self.sc[i, k][None, :] + U, _, Vt = np.linalg.svd(A); self.q[i, k] = matq(U @ Vt); self.loc[i, k] = B[:3, 3] + M2 = self.M(i) + self.item_err = max(getattr(self, "item_err", 0.0), max(np.abs(M2[R.bi[b]] - T0[R.bi[b]]).max() for b in ITEM_BONES if b in R.bi)) + + def run(self, sweeps): + t = time.time(); logs = [] + self.prepare() + if self.n >= 1: + for sw in range(sweeps): + for ch in CHAINS: + for i in range(self.n): + lg = self.fit_frame(i, ch); lg["sweep"] = sw; logs.append(lg) + self.log("a%d sweep %d %s f%d sil %.4f (orig %.4f) err2 %d (orig %d) prior %.4f -> %.4f nfev %d%s" % + (self.aid, sw, ch, i, lg["sil"], lg["sil0"], lg["err2"], lg["err2_0"], lg["prior_start"], lg["prior"], lg["nfev"], + "" if lg["feasible"] else " INFEASIBLE")) + self.guard() + self.keep_items() + self.log("item bones max matrix error %.2e" % self.item_err) + # final report + fin = [] + for i in range(self.n): + S = full_masks(self.R, self.M(i), self.H[i]) + fin.append((float(np.mean([sil_cost(S[d], self.O[i][d]) for d in range(5)])), sum(err2(S[d], self.O[i][d]) for d in range(5)), + float(np.mean([(S[d] & self.O[i][d]).sum() / max((S[d] | self.O[i][d]).sum(), 1) for d in range(5)])))) + S0 = [full_masks(self.R, self.M(i, True), self.H[i]) for i in range(self.n)] + iou0 = [float(np.mean([(S0[i][d] & self.O[i][d]).sum() / max((S0[i][d] | self.O[i][d]).sum(), 1) for d in range(5)])) for i in range(self.n)] + rep = dict(action=self.aid, name=self.name, loop=self.loop, seconds=round(time.time() - t, 1), + sil0=self.sil0, sil=[f[0] for f in fin], err2_0=self.e20, err2=[f[1] for f in fin], iou0=iou0, iou=[f[2] for f in fin], + bones=self.R.bones, q=self.q.tolist(), loc=self.loc.tolist(), scale=self.sc.tolist(), log=logs, + item_err=float(self.item_err), reverted=self.reverted, + settings=dict(LAMBDA=LAMBDA, ERR2_TOL=ERR2_TOL, HEAD_YAW_ACTIONS=sorted(HEAD_YAW_ACTIONS), HEAD_YAW_W=HEAD_YAW_W, HEAD_LAMBDA=HEAD_LAMBDA, HEAD_YAW_FREE=float(np.degrees(HEAD_YAW_FREE)), HEAD_TOL=HEAD_TOL, HEAD_ERR2_TOL=HEAD_ERR2_TOL, FOLD_W=FOLD_W, GRIP_W=GRIP_W, GRIP_FREE=GRIP_FREE, GRIP_ROT_W=GRIP_ROT_W, SLIDE_W=SLIDE_W, SLIDE_FREE=SLIDE_FREE, TWIST_W=TWIST_W, TWIST_FREE=float(np.degrees(TWIST_FREE)), VALGUS_W=VALGUS_W, TOL=TOL, MAXFEV=MAXFEV, + PASSES=PASSES, SCALE=SCALE, HEAD_SCALE=HEAD_SCALE, sweeps=sweeps)) + return rep + + +def run_one(args): + rig_path, aid, out_dir, sweeps = args + R = Rig(rig_path) + logf = open(os.path.join(out_dir, "a%02d.log" % aid), "w") + def log(s): logf.write(s + "\n"); logf.flush() + f = ActionFit(R, aid, log) + rep = f.run(sweeps) + json.dump(rep, open(os.path.join(out_dir, "a%02d.json" % aid), "w")) + return aid, np.mean(rep["iou0"]), np.mean(rep["iou"]), sum(rep["err2_0"]), sum(rep["err2"]), rep["seconds"] + + +def main(): + a = sys.argv[1:] + rig_path, out_dir = os.path.abspath(a.pop(0)), os.path.abspath(a.pop(0)) + o = {"--actions": "", "--jobs": "4", "--sweeps": "2"} + while a: k = a.pop(0); o[k] = a.pop(0) + os.makedirs(out_dir, exist_ok=True) + R = Rig(rig_path) + acts = [int(x) for x in o["--actions"].split(",")] if o["--actions"] else sorted(R.actions) + jobs = [(rig_path, aid, out_dir, int(o["--sweeps"])) for aid in acts] + if int(o["--jobs"]) > 1: + from multiprocessing import Pool + with Pool(int(o["--jobs"])) as p: + for r in p.imap_unordered(run_one, jobs): + print("action %2d IoU %.4f -> %.4f err2 %d -> %d (%.0f s)" % r, flush=True) + else: + for j in jobs: + print("action %2d IoU %.4f -> %.4f err2 %d -> %d (%.0f s)" % run_one(j), flush=True) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/pose_naturalness.py b/third_party/UO_Model3D_v13/pipeline/pose_naturalness.py new file mode 100644 index 0000000..f0f38b3 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/pose_naturalness.py @@ -0,0 +1,165 @@ +"""How natural the motion of the rig is in 3D (what the silhouette IoU does not see), per action. + + python pose_naturalness.py ../model/UO_Body_0x190.blend [--actions 0,1,15] [--out stats.json] [--per-frame] + +Per action (UO frames = scene frames 1 + 3 i; loops wrap, ends of one-shot actions are skipped in the 2nd differences): + zig_ mean angle (deg) between the bone's armature-space orientation in a frame and the mid-orientation of its two neighbours (zig-zag) + helm_px max jump (px, UO camera, max of 5 directions) of a point rigid on the head, 13 cm above its root, against the mean of the neighbours + hand_px the same for the centre of the right fist (8 cm along hand.R) and wpn_px for a point 0.8 m along the 1H weapon bone weapon1h.R + twist_uarm mean |twist| (deg) of upper_arm.L / .R about its own axis relative to the chest, minus the rest pose (MakeHuman skinning folds the + shoulder from about 15-20 deg of internal twist: the deltoid turns away from the chest) + slide_R/L mean distance (cm) of the shoulder joint from where it sits on the chest in the stand pose (clavicle rotation or location) + clav_cm max |location| (cm) of the clavicles (non-deforming bones: a shifted clavicle slides the arm along the chest skin -> dented shoulder) + scale_jump max frame-to-frame change of the per-frame girth scale (x / z) over the limbs and the head + waste_neck mean (|neck vs chest| + |head vs neck| - |head vs chest|) in deg: neck and head turning against each other +Needs: pip install numpy "bpy==4.2.*". +""" +import sys, os, json, math +import numpy as np +import bpy + +LOOP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 15, 23, 24, 25} +STEP = 3 +ZIG_BONES = ["chest", "neck", "head", "clavicle.L", "clavicle.R", "upper_arm.L", "upper_arm.R", "forearm.L", "forearm.R", "hand.L", "hand.R"] +SCALE_BONES = ["upper_arm.L", "forearm.L", "hand.L", "upper_arm.R", "forearm.R", "hand.R", "thigh.L", "shin.L", "foot.L", "thigh.R", "shin.R", "foot.R", "head"] +CW, CH = 145, 133 + + +def qang(a, b): + """angle (rad) between two unit quaternions given as arrays (w, x, y, z)""" + return 2 * np.arccos(np.clip(np.abs(np.sum(a * b, -1)), 0, 1)) + + +def slerp_mid(a, b): + s = np.where(np.sum(a * b, -1, keepdims=True) < 0, -1.0, 1.0) + m = a + s * b + return m / np.linalg.norm(m, axis=-1, keepdims=True) + + +def mat_quat(R): + """rotation matrices (n, 3, 3) -> quaternions (n, 4) w x y z""" + from scipy.spatial.transform import Rotation + q = Rotation.from_matrix(R).as_quat() + return q[:, [3, 0, 1, 2]] + + +def twist_deg(Rrel, axis=1): + """twist angle (deg) of relative rotations (n, 3, 3) about local axis `axis`""" + q = mat_quat(Rrel) + t = 2 * np.arctan2(q[:, 1 + axis], q[:, 0]) + return np.degrees((t + np.pi) % (2 * np.pi) - np.pi) + + +def second_diff_idx(n, loop): + return list(range(n)) if loop else list(range(1, n - 1)) + + +def main(): + a = sys.argv[1:] + blend = os.path.abspath(a.pop(0)) + only, out, per_frame = None, None, False + while a: + k = a.pop(0) + if k == "--actions": only = {int(x) for x in a.pop(0).split(",")} + elif k == "--out": out = a.pop(0) + elif k == "--per-frame": per_frame = True + bpy.ops.wm.open_mainfile(filepath=blend) + sc = bpy.context.scene + rig = bpy.data.objects["UO_Rig"]; P = rig.pose.bones; Bn = rig.data.bones + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from arm_refit import setup + sc.camera = bpy.data.objects["UO_Camera"]; setup(sc) + # camera and rig world matrices per direction (the rig turns, the camera stays) + CAM = [] + for d in range(5): + rig["uo_direction"] = d; rig.update_tag(); bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get(); cam = sc.camera.evaluated_get(dg) + CAM.append((np.array(cam.calc_matrix_camera(dg, x=CW, y=CH)) @ np.array(cam.matrix_world.inverted()) @ np.array(rig.matrix_world))) + rig["uo_direction"] = 0; rig.update_tag(); bpy.context.view_layer.update() + + def screen(Xa): + """armature-space points (n, 3) -> (5, n, 2) px""" + h = np.c_[Xa, np.ones(len(Xa))] + r = [] + for M in CAM: + p = h @ M.T; p = p[:, :2] / p[:, 3:4] + r.append(np.stack([(p[:, 0] + 1) * 0.5 * CW, (1 - p[:, 1]) * 0.5 * CH], 1)) + return np.array(r) + + rest = {b.name: np.array(b.matrix_local) for b in Bn} + st_act = next(x for x in bpy.data.actions if x.get("uo_action") == 4); rig.animation_data.action = st_act; sc.frame_set(1); bpy.context.view_layer.update() + SLIDE_REF = {} + for side in ("L", "R"): + mc, mu = np.array(P["chest"].matrix), np.array(P["upper_arm." + side].matrix) + SLIDE_REF[side] = (mc[:3, :3] / np.linalg.norm(mc[:3, :3], axis=0)).T @ (mu[:3, 3] - mc[:3, 3]) + has_w = "weapon1h.R" in P + acts = sorted([x for x in bpy.data.actions if "uo_action" in x], key=lambda x: int(x["uo_action"])) + res = {} + for act in acts: + aid = int(act["uo_action"]) + if only is not None and aid not in only: continue + rig.animation_data.action = act + n = int(act["uo_frames"]); loop = aid in LOOP + M = {b: [] for b in set(ZIG_BONES) | {"pelvis", "spine"}} + pts, loc, scl = [], [], [] + for i in range(n): + sc.frame_set(1 + STEP * i); bpy.context.view_layer.update() + for b in M: M[b].append(np.array(P[b].matrix)) + hm, hd = np.array(P["head"].matrix), np.array(P["hand.R"].matrix) + p = [hm[:3, 3] + 0.13 * hm[:3, 1] / np.linalg.norm(hm[:3, 1]), hd[:3, 3] + 0.08 * hd[:3, 1] / np.linalg.norm(hd[:3, 1])] + if has_w: + wm = np.array(P["weapon1h.R"].matrix); p.append(wm[:3, 3] + 0.8 * wm[:3, 1] / np.linalg.norm(wm[:3, 1])) + pts.append(p) + loc.append(max(np.linalg.norm(P["clavicle.L"].location), np.linalg.norm(P["clavicle.R"].location))) + scl.append([[P[b].scale[0], P[b].scale[2]] for b in SCALE_BONES]) + R = {b: np.array([m[:3, :3] / np.linalg.norm(m[:3, :3], axis=0, keepdims=True) for m in v]) for b, v in M.items()} + Q = {b: mat_quat(v) for b, v in R.items()} + idx = second_diff_idx(n, loop) + st = {} + for b in ZIG_BONES: + if n >= 3: + z = [np.degrees(qang(Q[b][i], slerp_mid(Q[b][(i - 1) % n], Q[b][(i + 1) % n]))) for i in idx] + st["zig_" + b] = float(np.mean(z)) + if per_frame: st["zigf_" + b] = [round(float(x), 2) for x in z] + else: + st["zig_" + b] = 0.0 + S = screen(np.array(pts).reshape(-1, 3)).reshape(5, n, -1, 2) + for k, nm in enumerate(["helm_px", "hand_px", "wpn_px"][:S.shape[2]]): + if n >= 3: + j = [np.max(np.linalg.norm(S[:, i, k] - 0.5 * (S[:, (i - 1) % n, k] + S[:, (i + 1) % n, k]), axis=-1)) for i in idx] + st[nm] = float(np.max(j)) + if per_frame: st[nm + "_f"] = [round(float(x), 2) for x in j] + else: + st[nm] = 0.0 + for side in ("L", "R"): + ub, rc, ru = "upper_arm." + side, rest["chest"][:3, :3], rest["upper_arm." + side][:3, :3] + Rrel = np.einsum("ij,njk->nik", (rc.T @ ru).T, np.einsum("nji,njk->nik", R["chest"], R[ub])) + st["twist_uarm_" + side] = float(np.mean(np.abs(twist_deg(Rrel)))) + st["clav_cm"] = float(100 * max(loc)) + for side in ("L", "R"): # shoulder joint moving on the chest (cm, mean over frames, from the stand pose) + c, u = "chest", "upper_arm." + side + J = [R[c][i].T @ (M[u][i][:3, 3] - M[c][i][:3, 3]) for i in range(n)] + st["slide_" + side] = float(100 * np.mean([np.linalg.norm(j - SLIDE_REF[side]) for j in J])) + sc_ = np.array(scl) + st["scale_jump"] = float(np.abs(np.diff(sc_, axis=0)).max()) if n > 1 else 0.0 + st["scale_range"] = float(np.ptp(sc_, axis=0).max()) if n > 1 else 0.0 + + def rel_ang(c, p): + rr = np.einsum("nji,njk->nik", R[p], R[c]); r0 = rest[p][:3, :3].T @ rest[c][:3, :3] + return np.degrees(qang(mat_quat(rr), np.tile(mat_quat(r0[None])[0], (n, 1)))) + st["waste_neck"] = float(np.mean(rel_ang("neck", "chest") + rel_ang("head", "neck") - rel_ang("head", "chest"))) + res[act.name] = st + keys = ["zig_head", "zig_neck", "zig_chest", "zig_clavicle.R", "zig_upper_arm.R", "zig_forearm.R", "zig_hand.R", "zig_hand.L", "helm_px", "hand_px", "wpn_px", + "twist_uarm_R", "twist_uarm_L", "slide_R", "slide_L", "clav_cm", "scale_jump", "waste_neck"] + short = ["head", "neck", "chest", "clvR", "uarmR", "farmR", "handR", "handL", "helm", "hand", "wpn", "twR", "twL", "sldR", "sldL", "clav", "sclJ", "wNeck"] + print("%-26s" % "action" + "".join("%7s" % s for s in short)) + for nm, st in res.items(): + print("%-26s" % nm + "".join("%7.2f" % st.get(k, 0) for k in keys)) + mean = {k: float(np.mean([st.get(k, 0) for st in res.values()])) for k in keys} + print("%-26s" % "MEAN" + "".join("%7.2f" % mean[k] for k in keys)) + if out: + json.dump({"actions": res, "mean": mean}, open(out, "w"), indent=1) + + +if __name__ == "__main__": + main() diff --git a/third_party/UO_Model3D_v13/pipeline/render_uo_layer.py b/third_party/UO_Model3D_v13/pipeline/render_uo_layer.py index c7a4694..09ec019 100644 --- a/third_party/UO_Model3D_v13/pipeline/render_uo_layer.py +++ b/third_party/UO_Model3D_v13/pipeline/render_uo_layer.py @@ -19,7 +19,25 @@ LAYER = "clothing" ONLY = [] # e.g. ["04_stand", "00_walk_unarmed"]; empty = all 35 UO actions -OUTLINE = 0.38 # dark 1-px outline like UO art (measured on the original body); 1.0 = off +OUTLINE = 0.38 # dark 1-px outline like UO art (measured on the original body); 1.0 = off. Items: see OUTLINE_TARGET +OUTLINE_TARGET = 0.38 # items without their own scene "uo_outline": the rim is darkened by one factor for the whole render so that the mean brightness + # of the rim pixels / the pixels one in = this (the client's items 0.33-0.43, mean 0.38, body 0.40; with a fixed 0.38 a small + # textured mask came out 0.24 and lost its texture, our 3D body 0.48). Measured over every frame of the run, applied before + # the .vd is written (a factor per frame jumped up to 0.17 between frames). 0 = the fixed OUTLINE +SAMPLES = 512 # Cycles samples per pixel for items (adaptive sampling; the pixel filter stays a 0.01 px box = the pixel centre, no blur): the + # gloss of metal in UO_Look (Principled) is sampled at random, so at 1 sample a flail had 61 % of its pixels off the converged + # render (64 samples: 2 %, 512: reference; time 11 -> 22 s for 2 actions). The body layer keeps 1 (its colours are the originals) +SHADE_BODY = 0.0 # per item (scene "uo_shade_body", 0..1): contact shadow of the body parts in front of the item (hands, arms, head: what hides the item): the item pixels near them are darkened by up to this + # share, smoothly over SHADE_WIDTH px (no line): the sides of a robe next to the hands. Per frame, in the posed picture +SHADE_SLEEVE = 0.0 # per item (scene "uo_shade_sleeve", 0..1): the same where a sleeve and the torso part of the item meet (needs uo_conform_item.py: attribute uo_region): the torso next to the arm + # (the armpit, the side) and the inside of the sleeve next to the torso +SHADE_WIDTH = 3.0 # px (1 px = 2.8 cm): width of those gradients (scene "uo_shade_width") +CONTRAST = 1.0 # per item (scene "uo_contrast", e.g. 1.6): a contrast curve on the item's own pixels, rgb = pivot + k x (rgb - pivot), after the outline: the UO robes are painted with dark creases and bright + # lights (luminance spread in the sleeves 54, ours 31: the sleeves and hands of a smooth shell read thicker than the original's); 1.8 = 52, 1.5 = 45. 1 = off. Not the light: the body and + # the other items are untouched +CONTRAST_PIVOT = 0.49 # 0..1: the grey that stays (scene "uo_contrast_pivot"; 0.49 = 125/255, about the median of a grey robe) +EDGE_FLOOR = 0 # 0-255: the 1-px outline of an item is never darker than this per channel (0 = off); a black item (leather, a black hilt) got a pure black rim, + # which the client draws as a hard hole-like line; scene property uo_edge_floor (24 = a soft dark grey) OUT_DIR = "//uo_render/" # PNG output root (vdtool 'canvas' layout + meta.json) WRITE_VD = True VD_FILE = "//uo_render/%s.vd" # %s -> LAYER @@ -35,6 +53,12 @@ CLOTH_MOUNTED = 1.0 # loose garments (robe, skirt: custom property uo_cloth) in the mounted actions 23-29: share in which the hanging part follows the legs (thighs above the # knee, shins below it, like skin) instead of hanging from the pelvis with the hull of the legs. A rider sits with the thighs forward and a robe that hangs from # the pelvis would leave them bare; the original robes lie along the legs. 0 = hang and push like on foot. +CLOTH_LIE = 0.0 # loose garments in the falls 21, 22: share in which the hanging part follows the legs as in riding (CLOTH_MOUNTED) instead of hanging from the pelvis + # pushed out by the legs: a robe lying on the ground lies along the legs (the original robe 469 is a narrow shape with a notch between them, the hull and + # the simulation made one wide lump). 0 = off; scene property uo_cloth_lie (uo_cloth_sim.py does not simulate these actions then) +CLOTH_LIE_DROP = 1.0 # with CLOTH_LIE: below the waist the lying garment falls by gravity onto what is under it (the posed legs / body, else the ground): its height above + # that support is cut to this share (smoothed over the cloth; full 0.3 m under the waist, as far as the body lies). The front between the legs is + # weighted to both of them and stood up over the raised knees as a sail. 1 = off; scene property uo_cloth_lie_drop HORSE_HOLDOUT = True # mounted actions: the horse hides what is behind it, clipped to the exact horse sprite EXACT_COLORS = True # body in the UO look: colours projected from the original UO frames packed in the file EXACT_BODY = True # body = original UO frames: the "body" layer reproduces the original exactly, and in @@ -46,6 +70,13 @@ OCCLUDERS = ["head", "upper_arm", "forearm", "hand", "thigh", "shin", "foot"] # body parts that may hide items # (with HOLDOUT_MARGIN > 0); the torso (pelvis, spine, chest, neck) never does, because # items are worn over it and a torso poking through is a model error, not an occluder +HIDE_ERODE = 0 # px: the area where a HAND or the HEAD hides an item is shrunk by this much. The 3D hand / head stand 1-2 px off the + # original sprite, and where they cut the item the ORIGINAL body shows through (EXACT_BODY): a wrist or a shoulder of the + # original in a hole of a sleeve or a collar. 0 = off; scene property uo_hide_erode +LEGS_UNDER = False # the legs (thighs, shins, feet) never hide the item: a long robe covers them in the client, and a leg that comes out in front of it in 3D (a foot + # lifted in a fall, a knee through the front slit) cut a hole that showed the ORIGINAL legs. Scene property uo_legs_under +HEAD_HIDES = False # the head hides the item even when the item is skinned to it: a hood stands off the head, and through its face opening the inside of its back + # wall is behind the face (with the head "worn" it was drawn over the face). Scene property uo_head_hides OWN_PARTS_NEVER_HIDE = True # body parts an item is skinned to (a legs item: thighs, shins, pelvis) never hide it: the item # wraps them, so their skin in front of the item shell is the item's own edge, not an occluder # (it cut 1-px strips off the sides of trousers). False = every OCCLUDERS part may hide it @@ -53,11 +84,17 @@ # Only when a visible item carries the custom property `uo_behind_torso` (a sword, quiver or cape hanging on the back: from the # front the chest hides it, from the sides it stays visible). Off otherwise: UO draws other items over the torso even where they # are behind it (a sleeve of an arm behind the chest) and hiding them cost 0.02-0.07 IoU. 0 = never. -DESPECKLE = 28 # clothing: single dark pixels inside the item darker than their neighbours by more +DESPECKLE = 0 # clothing: single dark pixels inside the item darker than their neighbours by more # than this (0-255) take the colour around them - deep sculpt details / rivets that - # turn into black dots at UO size. 0 = off + # turn into black dots at UO size. 0 = off. Was 28 while items were rendered at 1 sample (it cleaned that noise); + # at SAMPLES it only erased dark texture detail (17 % of the pixels of a textured mask); per item: scene "uo_despeckle" FILL_HOLES = 4 # px: holes INSIDE an item (fully surrounded by it) up to this size are filled - skin - # poking through or a gap in the item mesh, not a real occluder. 0 = off + # poking through or a gap in the item mesh, not a real occluder. 0 = off; scene property uo_fill_holes (a torn robe whose + # rips should show the body: 0) +INNER_LINE = 0.0 # clothing: > 0 = the torso pixels of an item that touch its sleeve (point attribute uo_region of uo_conform_item.py) are darkened to this share: a 1-px + # line that sets the sleeves off the torso like the original robes (an extra label render per frame). 0 = off; scene property uo_inner_line +EDGE_SMOOTH = 0 # clothing: passes of outline smoothing (a pixel with at most 1 of its 4 neighbours in the item is dropped, an empty one with 3-4 is filled with the colour + # around): the 1-px spikes and notches of a ragged hem. 0 = off; scene property uo_edge_smooth BODY_GAP = 0.006 # m: every frame, parts of the bound items closer than this to the arms, hands, legs or # head of the posed body are pushed out (smoothly) before rendering - otherwise a forearm # poking through a sleeve in motion cuts a hole in the item. 0 = off @@ -71,6 +108,24 @@ sc = bpy.context.scene MIN_PIECE = int(sc.get("uo_min_piece", MIN_PIECE)) # uo_make_item.py "min_piece" of a part: an item of thin straps (a vest) needs a bigger limit than 8 px for the bits an arm cuts off +HIDE_ERODE = int(sc.get("uo_hide_erode", HIDE_ERODE)) +LEGS_UNDER = bool(sc.get("uo_legs_under", LEGS_UNDER)) +OCCLUDERS = [x for x in sc["uo_occluders"].split(",") if x] if sc.get("uo_occluders") is not None else OCCLUDERS # per item ("scene": {"uo_occluders": "head,thigh,shin,foot"}): + # a flail held in the hand: arms and hands cut it far more than the original +HEAD_HIDES = bool(sc.get("uo_head_hides", HEAD_HIDES)) # uo_hood_cover.py sets it for a hood +CLOTH_LIE_DROP = float(sc.get("uo_cloth_lie_drop", CLOTH_LIE_DROP)) +LIE_W = {} +FILL_HOLES = int(sc.get("uo_fill_holes", FILL_HOLES)) +INNER_LINE = float(sc.get("uo_inner_line", INNER_LINE)) +EDGE_SMOOTH = int(sc.get("uo_edge_smooth", EDGE_SMOOTH)) +CLOTH_LIE = float(sc.get("uo_cloth_lie", CLOTH_LIE)) # uo_make_item.py "tune": {"scene": {"uo_hide_erode": 1}} +SAMPLES = int(sc.get("uo_samples", SAMPLES)) # an item that wants a cleaner render than the default (1024: gloss of metal, dark leather) +EDGE_FLOOR = int(sc.get("uo_edge_floor", EDGE_FLOOR)) +SHADE_BODY = float(sc.get("uo_shade_body", SHADE_BODY)); SHADE_SLEEVE = float(sc.get("uo_shade_sleeve", SHADE_SLEEVE)); SHADE_WIDTH = float(sc.get("uo_shade_width", SHADE_WIDTH)) +CONTRAST = float(sc.get("uo_contrast", CONTRAST)); CONTRAST_PIVOT = float(sc.get("uo_contrast_pivot", CONTRAST_PIVOT)) +OUTLINE_AUTO = LAYER == "clothing" and OUTLINE_TARGET > 0 and sc.get("uo_outline") is None +OUTLINE = float(sc.get("uo_outline", OUTLINE)) # an item whose 1-px outline should be softer than the body's (a light grey robe: 0.6) +RIM = dict(edge=0.0, inner=0.0, ne=0, ni=0, paths={}) # OUTLINE_AUTO: brightness sums of the rim / one pixel in, and the frames of this run DESPECKLE = int(sc.get("uo_despeckle", DESPECKLE)) # uo_prepare_item.py sets 0 for cloth and leather: the despeckle erased the straps, buttons and stitches of a texture (gambeson front closure) rig = bpy.data.objects["UO_Rig"] body = bpy.data.objects["UO_Body"] @@ -94,7 +149,7 @@ def fix_mesh(o): # Loose garments (robe, skirt; uo_bind_item.py PART "robe" sets the custom property `uo_cloth`): the hanging part follows the pelvis only and the legs push it out to where -# they reach (cloth_lib.hull_push, calibrated on the original robes: docs/qa/robe_physics.md). Computed per UO frame together with BODY_GAP, before it. +# they reach (cloth_lib.hull_push, calibrated on the original robes). Computed per UO frame together with BODY_GAP, before it. CLOTH_CTX = {} @@ -174,7 +229,10 @@ def cloth_push(o, first, dg, Mw, a=0, i=0): d0, d1 = (np.radians(x) * CLOAK_SWING for x in rows[min(i, len(rows) - 1)]) d = cl.cloak_bend(Vr, prm["z_top"], prm["z_hem"], prm["y_top"], d0, d1) return ((d @ skin["chest"][:3, :3].T) @ Dm[:3, :3].T) @ np.linalg.inv(Mw[:3, :3]).T - if 23 <= a <= 29 and CLOTH_MOUNTED > 0: # riding: the hanging part lies along the legs, as the skin under it would move it + share = CLOTH_MOUNTED if 23 <= a <= 29 else CLOTH_LIE if a in (21, 22) else 0.0 + if share > 0: # riding (and lying, CLOTH_LIE): the hanging part lies along the legs, as the skin under it would move it + up = skin["pelvis"][:3, :3].T @ np.array([0.0, 0.0, 1.0]) # how far the body lies (0 upright, 1 lying): CLOTH_LIE_DROP in body_fix + LIE_W[o.name] = float(np.clip((np.linalg.norm(up[:2]) - 0.3) / 0.5, 0, 1)) if a in (21, 22) else 0.0 Vh = np.c_[Vr, np.ones(len(Vr))] nv = len(o.data.vertices); Pp = (Vh @ skin["pelvis"].T)[:, :3]; d0 = np.zeros_like(Vr) have = all(("uo_leg_" + nm) in o.data.attributes for nm in cl.Capsules.NAMES) @@ -186,7 +244,10 @@ def cloth_push(o, first, dg, Mw, a=0, i=0): side = np.clip(Vr[:, 0] / 0.06, -1, 1) * 0.5 + 0.5; ws = np.clip((0.58 - Vr[:, 2]) / 0.15, 0, 1)[:, None] leg = lambda sd: (1 - ws) * (Vh @ skin["thigh." + sd].T)[:, :3] + ws * (Vh @ skin["shin." + sd].T)[:, :3] d0 = np.clip((prm["z_top"] - Vr[:, 2]) / 0.2, 0, 1)[:, None] * ((1 - side)[:, None] * leg("L") + side[:, None] * leg("R") - Pp) - d0 = d0 * CLOTH_MOUNTED + d0 = d0 * share + sim = cloth_sim_table().get("%d_%d" % (a, i)) + if sim is not None and len(sim) == len(o.data.vertices): # the simulated riding cloth (uo_cloth_sim.py mounted=1): what it did on top of the legs' lie + d0 = d0 + sim[first].astype(np.float64) return (d0 @ Dm[:3, :3].T) @ np.linalg.inv(Mw[:3, :3]).T d0 = cl.hull_push(Vr, skin["pelvis"], heads[sel], tails[sel], caps.radius[sel], centre_xy=tuple(prm["centre"]), margin=prm["margin"], kappa=prm["kappa"], z_top=prm["z_top"], z_hem=prm["z_hem"], ramp=prm.get("ramp", 0.15), drop=prm.get("drop", 1.0)) @@ -256,6 +317,72 @@ def evaluated_co(ob, dg): return ev, me, co.reshape(-1, 3).astype(np.float64) +FLAIL = {} + + +def flail_fix(a, i): + """flails (custom property uo_flail, uo_flail.py): the chain and the ball moved to the simulated nodes of this frame (flail_sim.npz next to the .blend), the haft as bound""" + obs = [o for o in clothes if not o.hide_render and o.get("uo_flail")] + if not obs: + return + if "sim" not in FLAIL: + path = os.path.join(os.path.dirname(bpy.data.filepath), "flail_sim.npz") if bpy.data.filepath else "" + FLAIL["sim"] = dict(np.load(path)) if path and os.path.exists(path) else {} + print("render_uo_layer: flail simulation %s (%d frames)" % (path, len(FLAIL["sim"]))) + X = FLAIL["sim"].get("%d_%d" % (a, i)) + for o in obs: + fix_off(o) + if X is None: + return + bpy.context.view_layer.update(); dg = bpy.context.evaluated_depsgraph_get() + Mrig = np.array(rig.matrix_world) + for o in obs: + prm = json.loads(o["uo_flail"]); R = np.array(prm["nodes"]); n = len(R) + if (o.name, a, i) not in FIX_CACHE: + nv = len(o.data.vertices) + s = np.empty(nv, np.float32); o.data.attributes["uo_flail_s"].data.foreach_get("value", s) + part = np.empty(nv, np.int32); o.data.attributes["uo_flail_part"].data.foreach_get("value", part) + co = np.empty(nv * 3, np.float32); o.data.vertices.foreach_get("co", co); co = co.reshape(-1, 3).astype(np.float64) + Mo = np.linalg.inv(Mrig) @ np.array(o.matrix_world) # object -> armature (does not change with the direction) + Vr = co @ Mo[:3, :3].T + Mo[:3, 3] + Ms = FLAIL["sim"].get("%d_%d_M" % (a, i)) # the hand's turn smoothed over the action (uo_flail.py HAND_SMOOTH): the haft drawn with it too + pb = rig.pose.bones[prm["bone"]]; F = (np.array(pb.matrix) @ np.linalg.inv(np.array(pb.bone.matrix_local)))[:3, :3] if Ms is None else Ms[:3, :3] + seg = (prm["ball_start"] - prm["haft_end"]) / (n - 2) + piece = np.where(part == 2, n - 2, np.clip(((s - prm["haft_end"]) / seg).astype(int), 0, n - 3)) + ev, me, cur = evaluated_co(o, dg); Mw = np.array(ev.matrix_world); ev.to_mesh_clear() + pos = cur.copy(); Mi = np.linalg.inv(Mw) @ Mrig + if Ms is not None: + m0 = part == 0; pos[m0] = (Vr[m0] @ Ms[:3, :3].T + Ms[:3, 3]) @ Mi[:3, :3].T + Mi[:3, 3] + for k in range(n - 1): # each piece turned like its segment, the turn carried down from the haft (no twist jumps) + c = F @ (R[k + 1] - R[k]); x = X[k + 1] - X[k] + F = np.array(Vector(c.tolist()).rotation_difference(Vector(x.tolist())).to_matrix()) @ F + m = (piece == k) & (part > 0) + if m.any(): + P = X[k] + (Vr[m] - R[k]) @ F.T + pos[m] = P @ Mi[:3, :3].T + Mi[:3, 3] + FIX_CACHE[(o.name, a, i)] = pos.astype(np.float32) + fix_on(o, FIX_CACHE[(o.name, a, i)].astype(np.float64)) + + +def lie_drop(o, first, X, bvh, bco, E, deg): + """CLOTH_LIE_DROP: the lying skirt (body space X) falls onto the posed body under it (ray straight down) or the ground; displacement in body space""" + prm = json.loads(o["uo_cloth"]); n = len(o.data.vertices) + co = np.empty(n * 3, np.float32); o.data.vertices.foreach_get("co", co); Mb_ = np.array(o.matrix_basis) + zr = ((co.reshape(-1, 3) @ Mb_[:3, :3].T + Mb_[:3, 3])[first])[:, 2] + k = (1.0 - CLOTH_LIE_DROP) * LIE_W[o.name] * np.clip((prm["z_top"] - zr) / 0.3, 0, 1) + ground = float(bco[:, 2].min()); top = float(X[:, 2].max()) + 0.1 + sup = np.full(len(X), ground) + for j in np.nonzero(k > 0)[0]: + loc = bvh.ray_cast(Vector((X[j, 0], X[j, 1], top)), Vector((0, 0, -1)), top - X[j, 2] + 0.5)[0] + if loc is not None and loc.z < X[j, 2]: # the highest body surface under the cloth (the cloth itself above it) + sup[j] = max(ground, loc.z) + dz = -k * np.maximum(X[:, 2] - sup, 0.0) + for _ in range(6): # smoothed over the cloth: no tearing between a fallen and a held vertex + acc = np.zeros_like(dz); np.add.at(acc, E[:, 0], dz[E[:, 1]]); np.add.at(acc, E[:, 1], dz[E[:, 0]]) + dz = np.where(deg > 0, 0.5 * dz + 0.5 * acc / np.maximum(deg, 1), dz) + return np.c_[np.zeros((len(X), 2)), dz] + + def body_fix(a, i): """BODY_GAP for UO frame i of action a: the pose does not depend on the direction, so each frame is solved once""" items = [o for o in clothes if not o.hide_render and not o.get("uo_no_body_gap")] # uo_no_body_gap: a rigid item (sword, shield) is never bent @@ -289,6 +416,9 @@ def body_fix(a, i): Dc = cloth_push(o, first, dg, Mw, a, i) # loose garment: the legs push it out first if Dc is not None: X = X + Dc @ M[:3, :3].T + if CLOTH_LIE_DROP < 1.0 and LIE_W.get(o.name, 0.0) > 0: + G = lie_drop(o, first, X, bvh, bco, E, deg) + X = X + G; Dc = Dc + G @ np.linalg.inv(M[:3, :3]).T if o.get("uo_conform") and cbvh is not None and "uo_region" in o.data.attributes: lab = np.empty(len(o.data.vertices), np.int32); o.data.attributes["uo_region"].data.foreach_get("value", lab) gap = max(float(json.loads(o["uo_conform"]).get("gap", BODY_GAP)), BODY_GAP) @@ -446,7 +576,10 @@ def shadow_sun(on): sc.cycles.pixel_filter_type = "BOX" # one sample point per pixel centre, like the UO art (no blur) sc.cycles.filter_width = 0.01 sc.cycles.use_denoising = False - sc.cycles.samples = 1 # 1 sample = exactly the pixel centre (the UO shading is noise-free) + sc.cycles.samples = 1 if LAYER == "body" else SAMPLES # every sample at the pixel centre (filter 0.01 px): more samples only average the + sc.cycles.use_adaptive_sampling = LAYER != "body" and SAMPLES > 1 # random parts of the shading (the gloss of metal); adaptive with a strict + sc.cycles.adaptive_threshold = 0.002; sc.cycles.adaptive_min_samples = 64 # noise threshold: = fixed 512 samples (0.03 % of the pixels > 2 levels off) in a quarter + # of the time; the default threshold (0.01) left 3.9 % of the pixels noisy (> 2 levels) ORIG = {} if EXACT_ANY and "uo_original_frames.json" in bpy.data.texts: import base64, zlib @@ -511,7 +644,18 @@ def uo_post(px, keep=None): if keep is not None: edge &= ~keep rgb[keep & m] = px[..., :3][keep & m] - rgb[edge] *= OUTLINE + if OUTLINE_AUTO: # the rim is darkened in finish(), by one factor for the whole render + ring = inner & ~(np.pad(~edge, 1, constant_values=True)[1:-1, 2:] & np.pad(~edge, 1, constant_values=True)[1:-1, :-2] + & np.pad(~edge, 1, constant_values=True)[2:, 1:-1] & np.pad(~edge, 1, constant_values=True)[:-2, 1:-1]) + y = rgb.mean(-1) + RIM["edge"] += float(y[edge].sum()); RIM["ne"] += int(edge.sum()); RIM["inner"] += float(y[ring].sum()); RIM["ni"] += int(ring.sum()) + else: + rgb[edge] *= OUTLINE + if EDGE_FLOOR: + rgb[edge] = np.maximum(rgb[edge], EDGE_FLOOR / 255.0) + if CONTRAST != 1.0: # contrast curve on the item's own pixels (not those that carry original UO colours) + sel = m if keep is None else (m & ~keep) + rgb[sel] = np.clip(CONTRAST_PIVOT + CONTRAST * (rgb[sel] - CONTRAST_PIVOT), 0.0, 1.0) out = np.zeros(px.shape, np.uint8) out[..., :3] = np.clip(rgb * 255 + 0.5, 0, 255).astype(np.uint8) out[..., 3] = m * 255 @@ -597,8 +741,14 @@ def worn_parts(share=0.04): WORN = worn_parts() if (LAYER == "clothing" and OWN_PARTS_NEVER_HIDE) else set() +if LAYER == "clothing" and LEGS_UNDER: + WORN |= {"thigh", "shin", "foot"} +if HEAD_HIDES: + WORN.discard("head") OCCLUDER_TRIS = body_part_mask(set(OCCLUDERS)) # parts BODY_GAP keeps the items away from HIDER_TRIS = body_part_mask(set(OCCLUDERS) - WORN) # parts that may hide an item in the holdout +HANDHEAD_TRIS = body_part_mask({"hand", "head"}) # HIDE_ERODE shrinks what these hide (a hand only next to its sleeve) +HEAD_TRIS = body_part_mask({"head"}) BEHIND_TORSO = TORSO_HIDE_MARGIN > 0 and LAYER == "clothing" and any(o.get("uo_behind_torso") for o in clothes if not o.hide_render) TORSO_TRIS = body_part_mask({"pelvis", "spine", "chest", "neck", "clavicle"}) if BEHIND_TORSO else None print("render_uo_layer: items wrap %s; body parts that may hide them: %s" % (sorted(WORN), sorted(set(OCCLUDERS) - WORN))) @@ -652,7 +802,7 @@ def body_occlusion(free, margin): """the clothing render without the body, with the body in front of the item removed. The body hides a pixel only where it is more than `margin` in front of the visible item surface. Returns (hold, body coverage).""" cov, _ = raster([body]) - occ, zb = raster([body], HIDER_TRIS) # only the parts that may hide items (not the torso) + occ, zb = raster([body], HIDER_TRIS & ~HANDHEAD_TRIS if HIDE_ERODE > 0 else HIDER_TRIS) # only the parts that may hide items (not the torso) _, zi = raster([o for o in clothes if not o.hide_render]) seen = free[..., 3] >= 0.5 for _ in range(2): # item pixels Cycles drew but the raster missed: neighbour depth @@ -664,6 +814,20 @@ def body_occlusion(free, margin): zi = np.where(miss, nb, zi) hold = free.copy() hold[occ & (zb < zi - margin)] = 0.0 + if HIDE_ERODE > 0: # hands and head: their hiding area shrunk (they stand off the original sprite by a pixel or two) + vis = [o for o in clothes if not o.hide_render] + for part_tris, near_sleeve in ((HIDER_TRIS & HEAD_TRIS, False), (HIDER_TRIS & HANDHEAD_TRIS & ~HEAD_TRIS, True)): + occh, zh = raster([body], part_tris) + hh = occh & (zh < zi - margin) + shrunk = ~binary_dilation(~hh, HIDE_ERODE) + if near_sleeve and len(vis) == 1 and "uo_region" in vis[0].data.attributes: # a hand: only next to its sleeve (the wrist at the cuff), a hand in front of the robe keeps its size + me = vis[0].data; me.calc_loop_triangles() + rv = np.empty(len(me.vertices), np.int32); me.attributes["uo_region"].data.foreach_get("value", rv) + tv = np.empty(len(me.loop_triangles) * 3, np.int32); me.loop_triangles.foreach_get("vertices", tv) + sl, zs = raster(vis, rv[tv.reshape(-1, 3)].max(1) > 0) + near = binary_dilation(sl & (zs <= zi + 0.005), HIDE_ERODE + 1) + shrunk = np.where(near, shrunk, hh) + hold[shrunk] = 0.0 if TORSO_TRIS is not None: # items behind the torso (on the back): clearly behind = hidden occt, zt = raster([body], TORSO_TRIS) hold[occt & (zt < zi - TORSO_HIDE_MARGIN)] = 0.0 @@ -774,6 +938,89 @@ def despeckle(img, thr, passes=2): return out +def region_labels(a, i, d): + """INNER_LINE: which pixels of the clothing layer are a sleeve (1) and which the torso part (0) of the items with uo_region; -1 elsewhere""" + obs = [o for o in clothes if not o.hide_render and "uo_region" in o.data.attributes] + if not obs: + return None + m = bpy.data.materials.get("uo_region_label") + if m is None: + m = bpy.data.materials.new("uo_region_label"); m.use_nodes = True; nt = m.node_tree; nt.nodes.clear() + at = nt.nodes.new("ShaderNodeAttribute"); at.attribute_type = "GEOMETRY"; at.attribute_name = "uo_region" + dv = nt.nodes.new("ShaderNodeMath"); dv.operation = "DIVIDE"; dv.inputs[1].default_value = 4.0 + cr = nt.nodes.new("ShaderNodeValToRGB"); cr.color_ramp.interpolation = "CONSTANT"; el = cr.color_ramp.elements + el[0].position = 0.0; el[0].color = (1, 0, 0, 1); el[1].position = 0.125; el[1].color = (0, 1, 0, 1) + e = el.new(0.375); e.color = (0, 0, 1, 1); e = el.new(0.625); e.color = (0, 1, 0, 1); e = el.new(0.875); e.color = (0, 0, 1, 1) + em = nt.nodes.new("ShaderNodeEmission"); out = nt.nodes.new("ShaderNodeOutputMaterial") + nt.links.new(at.outputs["Fac"], dv.inputs[0]); nt.links.new(dv.outputs[0], cr.inputs["Fac"]); nt.links.new(cr.outputs["Color"], em.inputs["Color"]); nt.links.new(em.outputs[0], out.inputs["Surface"]) + keep = {o.name: [sl.material for sl in o.material_slots] for o in obs} + for o in obs: + for sl in o.material_slots: + sl.material = m + ns = (sc.cycles.samples, sc.cycles.use_adaptive_sampling); sc.cycles.samples = 1; sc.cycles.use_adaptive_sampling = False # flat labels: 1 sample is exact + try: + px = render_frame(a, i, d, "holdout") + finally: + sc.cycles.samples, sc.cycles.use_adaptive_sampling = ns + for o in obs: + for sl, mt in zip(o.material_slots, keep[o.name]): + sl.material = mt + lab = np.full(px.shape[:2], -1, np.int8); on = px[..., 3] >= 0.5 + c = px[..., :3].argmax(2) + lab[on & (c == 0)] = 0; lab[on & (c > 0)] = 1 + return lab + + +def inner_line(px, lab, k): + """INNER_LINE: torso pixels next to a sleeve pixel darkened to k""" + on = px[..., 3] >= 0.5 + line = on & (lab == 0) & binary_dilation(on & (lab == 1), 1) + px = px.copy(); px[line, :3] *= k + return px + + +def contact_shade(px, free, lab): + """SHADE_BODY / SHADE_SLEEVE: smooth contact shadows (1 - s (1 - d / SHADE_WIDTH)^2 within SHADE_WIDTH px of the occluder): near the body parts that hide the item (pixels of the unhidden render + `free` that the item layer `px` lacks), and where the sleeve and the torso part meet (labels `lab`: sleeve 1, torso 0)""" + from scipy.ndimage import distance_transform_edt + on = px[..., 3] >= 0.5 + f = np.ones(on.shape, np.float32) + + def near(src): + d = distance_transform_edt(~src) + t = np.clip(1.0 - d / SHADE_WIDTH, 0.0, 1.0) + return t * t + if SHADE_BODY > 0: + hid = (free[..., 3] >= 0.5) & ~on + if hid.any(): + f *= 1.0 - SHADE_BODY * near(hid) + if SHADE_SLEEVE > 0 and lab is not None: + sl = on & (lab == 1); to = on & (lab == 0) + if sl.any() and to.any(): + f[to] *= 1.0 - SHADE_SLEEVE * near(sl)[to] + f[sl] *= 1.0 - SHADE_SLEEVE * near(to)[sl] + px = px.copy(); px[on, :3] *= f[on][:, None] + return px + + +def edge_smooth(px, passes): + """EDGE_SMOOTH: 1-px spikes of the outline dropped, 1-px notches filled with the mean colour of their neighbours""" + px = px.copy() + for _ in range(passes): + on = px[..., 3] >= 0.5 + n = np.zeros(on.shape, np.int8) + n[1:] += on[:-1]; n[:-1] += on[1:]; n[:, 1:] += on[:, :-1]; n[:, :-1] += on[:, 1:] + spike = on & (n <= 1); notch = ~on & (n >= 3) + if notch.any(): + acc = np.zeros(px.shape[:2] + (3,), np.float32); w = on.astype(np.float32) + for sl_to, sl_from in (((slice(1, None),), (slice(None, -1),)), ((slice(None, -1),), (slice(1, None),)), + ((slice(None), slice(1, None)), (slice(None), slice(None, -1))), ((slice(None), slice(None, -1)), (slice(None), slice(1, None)))): + acc[sl_to] += px[sl_from][..., :3] * w[sl_from][..., None] + px[notch, :3] = acc[notch] / np.maximum(n[notch], 1)[:, None]; px[notch, 3] = 1.0 + px[spike, 3] = 0.0 + return px + + def drop_small_pieces(img, min_px): """uint8 RGBA: detached pieces of the item (8-connected) smaller than min_px -> transparent, except the biggest""" m = img[..., 3] > 0; H, W = m.shape @@ -847,6 +1094,8 @@ def frame_job(): sc.frame_set(1 + i * STEP) if (BODY_GAP > 0 or HAS_CLOTH) and LAYER != "body": body_fix(a, i) + if LAYER != "body": + flail_fix(a, i) orig = original(a, i, d) if EXACT_ANY else None orig_m = orig[..., 3] > 0 if orig is not None else None if LAYER == "body" and EXACT_BODY and orig is not None: @@ -857,6 +1106,13 @@ def frame_job(): px = exact_clothing(hold, free, orig_m, cov) if (EXACT_BODY and orig is not None) else hold if FILL_HOLES > 0: px = fill_small_holes(px, free, FILL_HOLES) + lab = region_labels(a, i, d) if (INNER_LINE > 0 or SHADE_SLEEVE > 0) else None + if INNER_LINE > 0 and lab is not None: + px = inner_line(px, lab, INNER_LINE) + if SHADE_BODY > 0 or SHADE_SLEEVE > 0: + px = contact_shade(px, free, lab) + if EDGE_SMOOTH > 0: + px = edge_smooth(px, EDGE_SMOOTH) rgba = uo_post(px) if DESPECKLE > 0: rgba = despeckle(rgba, DESPECKLE) @@ -879,6 +1135,7 @@ def frame_job(): png.filepath_raw = os.path.join(fdir, "%02d.png" % i); png.file_format = "PNG"; png.save() bpy.data.images.remove(png) meta_frames.append(dict(file="%02d.png" % i)) + RIM["paths"][(a, d, i)] = os.path.join(fdir, "%02d.png" % i) done_frames += 1 yield "%d / %d frames (%s, direction %d)" % (done_frames, total_frames, act.name, d) blocks[(a, d)] = frames @@ -925,6 +1182,20 @@ def finish(): frames.append(np.clip(px.reshape(h, w, 4)[::-1] * 255 + 0.5, 0, 255).astype(np.uint8)) blocks[(a, d)] = frames meta_blocks.append(dict(action=a, dir=d, name=act.name, frames=[dict(file=f) for f in files])) + if OUTLINE_AUTO and RIM["ne"] and RIM["ni"]: # one rim factor for the whole render: rim / one pixel in = OUTLINE_TARGET + r = (RIM["edge"] / RIM["ne"]) / max(RIM["inner"] / RIM["ni"], 1e-6) + k = float(np.clip(OUTLINE_TARGET / max(r, 1e-6), 0.25, 1.0)) + print("render_uo_layer: rim / one pixel in %.2f before the outline, outline factor %.2f (target %.2f)" % (r, k, OUTLINE_TARGET)) + for (a, d, i), path in RIM["paths"].items(): + fr = blocks[(a, d)][i]; m = fr[..., 3] > 0 + inner = m.copy(); inner[1:] &= m[:-1]; inner[:-1] &= m[1:]; inner[:, 1:] &= m[:, :-1]; inner[:, :-1] &= m[:, 1:] + edge = m & ~inner + fr[..., :3][edge] = np.clip(fr[..., :3][edge].astype(np.float32) * k + 0.5, 0, 255).astype(np.uint8) + if EDGE_FLOOR: + fr[..., :3][edge] = np.maximum(fr[..., :3][edge], EDGE_FLOOR) + h, w = fr.shape[:2]; png = bpy.data.images.new("uo_tmp_out", w, h, alpha=True) + png.pixels.foreach_set((fr[::-1].astype(np.float32) / 255.0).ravel()) + png.filepath_raw = path; png.file_format = "PNG"; png.save(); bpy.data.images.remove(png) print("actions in this .vd:", sorted({a for a, _ in blocks})) # vdtool-compatible meta.json (canvas mode) so the PNGs can also be packed with: python vdtool.py pack out.vd done_blocks = {(b["action"], b["dir"]): b for b in meta_blocks} diff --git a/third_party/UO_Model3D_v13/pipeline/robe_cloth_sim_test.py b/third_party/UO_Model3D_v13/pipeline/robe_cloth_sim_test.py index 7e5e53c..9592bd4 100644 --- a/third_party/UO_Model3D_v13/pipeline/robe_cloth_sim_test.py +++ b/third_party/UO_Model3D_v13/pipeline/robe_cloth_sim_test.py @@ -1,4 +1,4 @@ -"""Blender's cloth simulation against the leg hull, on the replica of the original robe 469 (EXPERIMENT: nothing in the render uses it; result in docs/qa/robe_physics.md, "Sesja 16, symulacja"). +"""Blender's cloth simulation against the leg hull, on the replica of the original robe 469 (EXPERIMENT: nothing in the render uses it). python robe_cloth_sim_test.py sim POSES.npz OUT.npz ACTIONS [key=value ...] # bpy: simulate the replica tube (robe_calib.tube, rest shape fitted on the stand frames) python robe_cloth_sim_test.py score POSES.npz OUT.npz [OUT2.npz ...] # numpy: lower-body IoU of the simulated frames, of the hull and of the rigid pelvis hang diff --git a/third_party/UO_Model3D_v13/pipeline/robe_hull_eval.py b/third_party/UO_Model3D_v13/pipeline/robe_hull_eval.py index 9f19cd4..57af8cc 100644 --- a/third_party/UO_Model3D_v13/pipeline/robe_hull_eval.py +++ b/third_party/UO_Model3D_v13/pipeline/robe_hull_eval.py @@ -2,10 +2,10 @@ python robe_hull_eval.py POSES.npz [--garments 469,447,970,455,971] [--rel-kappa 0.15,0.25,0.4,0.6] -POSES.npz from pose_capture.py. Per garment a replica tube whose rest shape is fitted on the stand frames without hull (as in robe_physics.md), then robe_calib.evaluate on all actions except +POSES.npz from pose_capture.py. Per garment a replica tube whose rest shape is fitted on the stand frames without hull, then robe_calib.evaluate on all actions except the mounted 23-29: no hull / the production hull (absolute: the legs push the cloth out to their reach + margin; margin / kappa by hem height as in uo_bind_item.py) / `hull_push_rel` (EXPERIMENT, not used in production: only what the legs reach beyond their REST reach pushes, by kappa times the excess). Sprites: body13/mul/anim_0447 (dress), 0455 (kilt), -0469 (robe), 0970 (shroud), 0971 (skirt), all from anim.mul. Result and why the relative hull was not adopted: docs/qa/robe_physics.md, "Sesja 16". +0469 (robe), 0970 (shroud), 0971 (skirt), all from anim.mul. """ import argparse, os, sys import numpy as np diff --git a/third_party/UO_Model3D_v13/pipeline/run_qa.py b/third_party/UO_Model3D_v13/pipeline/run_qa.py index 7648121..1d2e70b 100644 --- a/third_party/UO_Model3D_v13/pipeline/run_qa.py +++ b/third_party/UO_Model3D_v13/pipeline/run_qa.py @@ -2,11 +2,11 @@ python run_qa.py [--quick] [--skip canvas,items,...] - items test_items.py mean IoU of the 6 replicas against the original sprites >= 0.715 (baseline 0.718, docs/qa/items_after_fingers.json) + items test_items.py mean IoU of the 6 replicas against the original sprites >= 0.715 (baseline 0.722, docs/qa/baselines.md) canvas test_canvas.py every case pixel-identical to the reference render (CANVAS / ANCHOR) all OK - autofit test_autofit.py mean vertex error of 12 foreign replicas (wrap) <= 20 mm (13.6 mm, docs/qa/autofit.json) - robe test_robe.py lower-body IoU of the robe replica against the original robe 469 >= 0.74 (0.757, docs/qa/robe_physics.json) - cloak test_cloak.py silhouette IoU of a cape replica against the original cloak 468 (swing from cloak_pitch.json) >= 0.47 (0.507, docs/qa/cloak_physics.md) + autofit test_autofit.py mean vertex error of 12 foreign replicas (wrap) <= 20 mm (13.6 mm) + robe test_robe.py lower-body IoU of the robe replica against the original robe 469 >= 0.74 (0.757) + cloak test_cloak.py silhouette IoU of a cape replica against the original cloak 468 (swing from cloak_pitch.json) >= 0.47 (0.507) materials test_materials.py "RESULT OK" import test_import_item.py the chain from a foreign file runs and puts the item where the old method did (bbox) (runs without error) --quick drops items and canvas (about 6 minutes less). Needs the same as the tests (bpy 4.2, numpy, scipy, pillow); each test is the script of the same name in this folder. diff --git a/third_party/UO_Model3D_v13/pipeline/test_canvas.py b/third_party/UO_Model3D_v13/pipeline/test_canvas.py index 1205f0e..e394460 100644 --- a/third_party/UO_Model3D_v13/pipeline/test_canvas.py +++ b/third_party/UO_Model3D_v13/pipeline/test_canvas.py @@ -26,8 +26,11 @@ def render(blend, script, out, case, actions, canvas=None): sets = dict(s.split("=", 1) for s in CASES[case][0].split("; ")) sets["ONLY"] = repr(actions) - if "OWN_PARTS_NEVER_HIDE" in open(script).read(): # the reference (commit ea55c0b) has no such rule: switch it off, this test only checks the canvas + src = open(script).read() + if "OWN_PARTS_NEVER_HIDE" in src: # the reference (commit ea55c0b) has no such rule: switch it off, this test only checks the canvas sets["OWN_PARTS_NEVER_HIDE"] = "False" + if "OUTLINE_TARGET" in src: # nor 512 samples, the outline set per render and DESPECKLE 0 (item texture quality, 2026-10-14): the reference look + sets.update(SAMPLES="1", OUTLINE_TARGET="0", DESPECKLE="28") if canvas: sets["CANVAS"], sets["ANCHOR"] = repr(canvas[0]), repr(canvas[1]) cmd = [sys.executable, os.path.join(HERE, "run_render_headless.py"), blend, out, "--script", script] + ["%s=%s" % kv for kv in sets.items()] diff --git a/third_party/UO_Model3D_v13/pipeline/uo_autofit_item.py b/third_party/UO_Model3D_v13/pipeline/uo_autofit_item.py index 81595b1..8986715 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_autofit_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_autofit_item.py @@ -1,12 +1,12 @@ # Size and place the SELECTED item on the UO body by what the item has to do: wrap the skin it covers, at a small distance, without the skin poking out. # uo_import_item.py scales an item from ONE number per KIND (the height of a typical original), which is only right for items shaped like that original: a chest piece -# that stops at the ribs, a cuirass with huge pauldrons or a short jacket came out too big or too small (docs/qa/autofit.md). This script instead looks at the +# that stops at the ribs, a cuirass with huge pauldrons or a short jacket came out too big or too small. This script instead looks at the # skin of the slot's body parts (PARTS_BY_KIND) and finds the uniform scale and the move (3 numbers, optionally a half turn) for which # - the item's inner surface lies about `GAP` from the skin where it covers it (distance from every skin point to the nearest item surface point), and # - no skin point lies on the outer side of the surface it is nearest to (the skin pokes through): that costs 3x more than a gap that is too large, # - where the item does not cover the skin at all (the item is short, the neck / arm holes) nothing is asked, so a short item is not stretched to cover everything. # The vertical place of an item that fits equally well anywhere on the torso (a short cuirass) is settled by the edge of the slot that is stable in the original items -# (EDGE_BY_KIND: top of shirts, trousers, hair, bottom of boots, gloves, arm guards; docs/AUDYT_2026-10-03.md, point 5), a weak pull of the same weight as ~5 cm of misfit. +# (EDGE_BY_KIND: top of shirts, trousers, hair, bottom of boots, gloves, arm guards), a weak pull of the same weight as ~5 cm of misfit. # The size units are found first (a file in cm, mm): the unit that puts the longest side of the item in 0.15..3 m (UNIT = 0 -> automatic). # Run it with the item(s) selected, after uo_import_item.py with FIT = "wrap" (which only converts units and puts the item near the body), before uo_fit_item.py # (that one pushes whatever is still in the skin out). Rest Position. Prints the result and the alternatives that were nearly as good ("AMBIGUOUS"). @@ -36,16 +36,17 @@ PARTS_BY_KIND = {"shirt": ("pelvis", "spine", "chest", "neck"), "plate": ("pelvis", "spine", "chest", "neck"), "harness": ("pelvis", "spine", "chest", "neck"), "arms": ("upper_arm", "forearm"), "pants": ("pelvis", "thigh", "shin"), "legs": ("pelvis", "thigh", "shin"), "boots": ("shin", "foot"), "gloves": ("forearm", "hand"), "helm": ("head",), "hat": ("head",), "hair": ("head",), "beard": ("head",), "neck": ("neck", "chest", "head"), - "robe": ("pelvis", "spine", "chest", "neck"), "skirt": ("pelvis", "spine"), "cloak": ("pelvis", "spine", "chest", "neck"), "waist": ("pelvis", "spine"), "vest": ("pelvis", "spine", "chest", "neck")} + "robe": ("pelvis", "spine", "chest", "neck"), "skirt": ("pelvis", "spine"), "cloak": ("pelvis", "spine", "chest", "neck"), "waist": ("pelvis", "spine"), "vest": ("pelvis", "spine", "chest", "neck"), + "hood": ("head", "neck")} # the edge of the slot that is the same in most original items, and where it is (m, from uo_import_item.py EXTENTS, measured on the original sprites) EDGE_BY_KIND = {"shirt": ("top", 1.656), "plate": ("top", 1.643), "harness": ("top", 1.55), "pants": ("top", 1.170), "legs": ("top", 1.138), "boots": ("bottom", -0.069), "gloves": ("bottom", 0.797), "arms": ("bottom", 0.929), "helm": ("top", 1.903), "hat": ("top", 1.92), "hair": ("top", 1.89), "beard": ("top", 1.72), - "neck": ("top", 1.64), "robe": ("top", 1.64), "skirt": ("top", 1.17), "cloak": ("top", 1.52), "waist": ("top", 1.19), "vest": ("top", 1.66)} -GAP_BY_KIND = {"shirt": 0.015, "pants": 0.015, "boots": 0.015, "gloves": 0.015, "plate": 0.03, "legs": 0.03, "arms": 0.03, "helm": 0.03, "neck": 0.03, "harness": 0.015, "robe": 0.03, "skirt": 0.02, "cloak": 0.03, "waist": 0.015, "vest": 0.02} + "neck": ("top", 1.64), "robe": ("top", 1.64), "skirt": ("top", 1.17), "cloak": ("top", 1.52), "waist": ("top", 1.19), "vest": ("top", 1.66), "hood": ("top", 1.97)} +GAP_BY_KIND = {"shirt": 0.015, "pants": 0.015, "boots": 0.015, "gloves": 0.015, "plate": 0.03, "legs": 0.03, "arms": 0.03, "helm": 0.03, "neck": 0.03, "harness": 0.015, "robe": 0.03, "skirt": 0.02, "cloak": 0.03, "waist": 0.015, "vest": 0.02, "hood": 0.03} # where the slot lives on the body (m, z of the lowest / highest point of the typical original item, uo_import_item.py EXTENTS): the item may not drift out of this zone # (by more than ZONE_MARGIN), and its height may differ from the typical one only by HEIGHT_RATIO - far-away "solutions" fit nothing and cost nothing ZONE_BY_KIND = {"shirt": (0.999, 1.656), "plate": (0.651, 1.643), "harness": (0.9, 1.65), "arms": (0.929, 1.694), "pants": (0.057, 1.170), "legs": (-0.107, 1.138), - "boots": (-0.069, 0.563), "gloves": (0.797, 1.245), "helm": (1.504, 1.903), "hair": (1.52, 1.89), "beard": (1.52, 1.72), "hat": (1.52, 1.92), "neck": (1.48, 1.64), "robe": (0.0, 1.64), "skirt": (0.4, 1.25), "cloak": (0.0, 1.60), "waist": (0.74, 1.19), "vest": (0.68, 1.66)} + "boots": (-0.069, 0.563), "gloves": (0.797, 1.245), "helm": (1.504, 1.903), "hair": (1.52, 1.89), "beard": (1.52, 1.72), "hat": (1.52, 1.92), "neck": (1.48, 1.64), "robe": (0.0, 1.64), "skirt": (0.4, 1.25), "cloak": (0.0, 1.60), "waist": (0.74, 1.19), "vest": (0.68, 1.66), "hood": (1.40, 1.97)} ZONE_MARGIN = 0.15 HEIGHT_RATIO = (0.4, 1.8) ZONE_WEIGHT = 4.0 # residual per metre outside the zone / the ratio @@ -54,13 +55,13 @@ CAP_WIDE = 0.40 # the same for the first, coarse stage EDGE_WEIGHT = 0.05 # 1/m^2: tie-breaker only (10 cm off costs as much as a 2 cm misfit): the wrap itself fixes the height of every item that has any structure SCALE_PRIOR = 0.04 # a pull towards the size the model was made in (after the units): 20 % off costs as much as a 1 cm misfit of every skin point. Models are authored 1:1 far - # more often than not (the helmet of docs/qa/autofit.md came out 41 % too big without it); 0 = the data alone (test_autofit.py measures with 0) + # more often than not (a helmet came out 41 % too big without it); 0 = the data alone (test_autofit.py measures with 0) # how far from the skin an item may stand (m) before it counts as too thick: the p90 of the distance of the item's pixels from the body silhouette in the original -# items of the slot (docs/qa/layer_analysis.md: plate / InnerTorso 5.6-8.1 cm, shirt 5.6, pants 3.9-5.6, arms 4.7-10, helm 8.3, hair 5.6-7.9, boots 3.3) with a margin; +# items of the slot (measured: plate / InnerTorso 5.6-8.1 cm, shirt 5.6, pants 3.9-5.6, arms 4.7-10, helm 8.3, hair 5.6-7.9, boots 3.3) with a margin; # beyond it the part is pulled back by a smaller scale (OUT_WEIGHT) - big pauldrons and flares are what makes an item "too thick" PRIOR_BY_KIND = {"gloves": 0.0, "arms": 0.0} # pairs far from the middle (two gloves, two sleeves): the prior pulled the fit onto one of the two; the data alone finds both (test_autofit.py) OUT_BY_KIND = {"shirt": 0.09, "plate": 0.12, "harness": 0.09, "pants": 0.08, "legs": 0.09, "arms": 0.10, "boots": 0.07, "gloves": 0.07, "helm": 0.12, "hat": 0.12, - "hair": 0.12, "beard": 0.08, "neck": 0.08, "waist": 0.07, "vest": 0.07} # loose garments (robe, skirt) have no limit + "hair": 0.12, "beard": 0.08, "neck": 0.08, "waist": 0.07, "vest": 0.07, "hood": 0.12} # loose garments (robe, skirt) have no limit OUT_WEIGHT = 3.0 # weight of the "too thick" term: 5 cm too far, over the whole surface, costs like 2.5 cm of misfit of the wrap OUT_POINTS = 5000 # item points used for it PIERCE = 3.0 # a skin point poking out of the item costs this much more than the same gap too large diff --git a/third_party/UO_Model3D_v13/pipeline/uo_bind_item.py b/third_party/UO_Model3D_v13/pipeline/uo_bind_item.py index b48060d..900aa30 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_bind_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_bind_item.py @@ -36,10 +36,12 @@ "boots": (["shin.L", "foot.L", "shin.R", "foot.R"], {}), # boots, greaves "helm": (["head"], {}), # helmet, hat, mask "neck": (["neck", "chest", "head"], {}), # gorget, collar + "hood": (["head", "neck", "chest", "upper_arm.L", "upper_arm.R"], {"upper_arm": 0.5}), # hood with a cowl round the neck: the hood on the head, the cowl on the neck and chest; a cowl + # over the shoulders takes half the shoulder's motion (on the chest only, the shoulders came through it in the attacks and spells) "belt": (["pelvis", "spine", "chest", "thigh.L", "thigh.R"], {}), # belt with straps and hangers that reach down over the hips: what hangs below the waist follows the thigh under it # loose garments (robe, dress, skirt, kilt): above the hips like "all"; the hanging part follows the PELVIS only (it does not stick to the legs like trousers): the weight # of thighs, shins and feet goes to the pelvis. The legs push it out when they reach it: render_uo_layer.py does that per frame (custom property `uo_cloth`, set below; - # cloth_lib.hull_push, calibrated on the original robes, docs/qa/robe_physics.md). FOLLOW also takes (share, t0, t1, end share): share near the joint -> end share along the bone. + # cloth_lib.hull_push, calibrated on the original robes). FOLLOW also takes (share, t0, t1, end share): share near the joint -> end share along the bone. "robe": (None, {"hand": 0.0, "foot": 0.0, "shin": 0.0, "thigh": 0.0}), "skirt": (None, {"hand": 0.0, "foot": 0.0, "shin": 0.0, "thigh": 0.0}), "cloak": (None, {"hand": 0.0, "foot": 0.0, "shin": 0.0, "thigh": 0.0}), # cape from the shoulders: hangs from the chest, the rest follows the pelvis; swings back per action (render_uo_layer.py) @@ -230,8 +232,10 @@ def smooth(x): CLOTH_PARTS = ("robe", "skirt", "cloak") LEG_BONES = ("thigh.L", "shin.L", "foot.L", "thigh.R", "shin.R", "foot.R") CLOTH_DROP = 1.0 # m of radius the hem may narrow per m of height below a push (0 = hangs straight down from it, larger = tapers back in sooner) -CLOTH_MARGIN_SHORT, CLOTH_KAPPA_SHORT = 0.02, 0.5 # a garment that ends at the knee or above (kilt, short skirt): only the thighs push it, and less (robe_calib.py on the originals 455, 971) -CLOTH_MARGIN, CLOTH_KAPPA = 0.05, 0.8 # a long robe (hem below 0.15 m); between 0.15 and 0.30 m of hem height the values blend into the short ones; how far past the legs the hem goes, how much of the way to the legs the cloth is pushed (robe_calib.py: best of the sweep on robe 469) +CLOTH_MARGIN_SHORT = 0.02 +CLOTH_KAPPA_SHORT = 0.5 # a garment that ends at the knee or above (kilt, short skirt): only the thighs push it, and less (robe_calib.py on the originals 455, 971) +CLOTH_MARGIN = 0.05 # a long robe (settings on separate lines: a recipe overrides them by name, "tune": {"uo_bind_item.py": {"CLOTH_KAPPA": 1.0}}) +CLOTH_KAPPA = 0.8 # a long robe (hem below 0.15 m); between 0.15 and 0.30 m of hem height the values blend into the short ones; how far past the legs the hem goes, how much of the way to the legs the cloth is pushed (robe_calib.py: best of the sweep on robe 469) def mark_cloak(ob): diff --git a/third_party/UO_Model3D_v13/pipeline/uo_cloth_sim.py b/third_party/UO_Model3D_v13/pipeline/uo_cloth_sim.py index 66f323b..7e17a7f 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_cloth_sim.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_cloth_sim.py @@ -7,12 +7,14 @@ (a plain mesh) is simulated with Blender's Cloth: every vertex is held by a spring ("goal", the pin weight) to the target of the current frame, 1 at the waist and above (the cloth follows the body there), falling to GOAL at the hem (the hanging part is free to swing and fold), and the cloth collides with the posed UO_Body (legs, arms, hands). The result is saved as the DIFFERENCE between the simulation and the target (cloth_sim.npz next to the item, keys "_"): render_uo_layer.py adds it to the leg push (CLOTH_SIM = 1), then BODY_GAP -pushes whatever is still inside the body out, as before. Mounted actions (23-29) are not simulated (the hem follows the legs there). +pushes whatever is still inside the body out, as before. Mounted actions (23-29) are not simulated (the hem follows the legs there), nor the falls 21, 22 when the scene has uo_cloth_lie > 0. Every action is simulated on its own: PREROLL frames in its first pose (looping actions: the pose of the loop), looping actions (walk, run, stand, combat idle) CYCLES cycles and the last one is -kept, so the loop closes without a jump. Settings (key=value): goal (0.4), upper (1.0), ramp (0.30 m from the waist down to GOAL), quality (8), mass (0.3), tension / compression (15), shear (5), bending (0.5), -damp_air (1), tdamp (5), dist (0.005), preroll (40), cycles (3), self_collision (0), friction (0), arm_goal (0.7; -1 = sleeves as the rest above the waist; 0-1 = their own goal), arm_x (0.19 m from the middle: where the sleeves start), max_move (0.12 m: the most the cloth may differ from the kinematic target, soft limit; 0 = none). Measured on the replica of the original robe 469 (robe_cloth_sim_test.py, hybrid=1): -lower-body IoU walk 0.754 -> 0.765, run 0.731 -> 0.735 against the kinematic result (docs/qa/robe_physics.md); the simulation alone (no goal) collapses in walk and run. +kept, so the loop closes without a jump. Settings (key=value): fabric (silk, cotton, wool, denim, leather: mass, stiffness, bending, damping at once; the keys below override it), cull (0; 1 = the collider is single sided: the cloth collision respects the collider's normals, Blender: improves penetration recovery), usenormal (0; 1 = collision impulses along the collider's normals), cq (4; collision quality of Blender's cloth: iterations of the contact solver), env (0; kappa of a last pass that keeps the simulated cloth inside-out of the shadow of the legs, cloth_lib.hull_push on the simulated points: a leg in full stride does not come out of the hem), env_margin (0.02 m beyond the legs' reach), env_tent (0 = no tent: the cloth above is not pulled out), mounted (0; 1 = the riding actions 23-29 are simulated too: the target is the rider's, the hanging part lies along the legs as render_uo_layer.py CLOTH_MOUNTED does, and the cloth collides with the horse proxy Horse_a_f; horse=0: no collision with the horse, the renderer's HORSE_HOLDOUT only cuts the cloth where the horse is in front), pushw (1; the pin holds skinned + pushw x the push of the legs: 0 = the cloth hangs from the pelvis and the legs push it out by collision only, the result is still stored against the full target), gravity (1; x the scene's gravity: a heavier fall of the free cloth), bscale (1; x the bending stiffness: like the damping it is not scaled with the vertex mass, with mscale 0.033 the cloth is ~30 x too stiff against its weight and the hem a leg lifted does not fall back: walk +0.26 m cotton, +0.47 m wool, silk +0.04), dscale (1; x the damping (air and internal): damping is not scaled with the mass of a vertex, so with mscale 0.033 take dscale 0.033 too, or the cloth is ~30 x over-damped and does not fall back after a leg lifts it), mscale (1; x the mass of a vertex: Blender's presets assume ~0.3 kg per vertex, a 24k-vertex robe hangs with ~0.01 kg = mscale 0.033; free cloth, goal 0.2, thick 0.006, dist 0.002), goal (0.4), upper (1.0), ramp (0.30 m from the waist down to GOAL), quality (8), mass (0.3 per vertex), density (kg/m2 of the cloth; > 0 = the mass of a vertex is density x area of the item / vertices, so a fine mesh weighs what the fabric weighs; the fabrics set it, 0 = mass as given), tension / compression (15), shear (5), bending (0.5), +damp_air (1), tdamp (5), dist (0.005), preroll (40), cycles (3), self_collision (0), friction (0), arm_goal (0.7; -1 = sleeves as the rest above the waist; 0-1 = their own goal), arm_x (0.19 m from the middle: where the sleeves start), arm_zmin (the sleeves are the vertices above this height; default 0.05 m below the waist: long bell sleeves that hang below it need e.g. 0.55), thick (0.02 m: the outer thickness of the body as a collider; a fast leg tunnels through the cloth with a thin one: leg pixels through the robe in the run 466 at 0.005 m, 196 at 0.02, 147 with dist 0.02 too but 3 x slower), smooth (0 m; > 0 = the simulation's move averaged over this distance in space, 2 passes: neighbouring pleats that cross each other in the simulation opened slits +through which the legs showed; 0.04 m on a robe made by a cloth simulator: leg pixels through the robe in walk 44 -> 12), in_max (0.01 m: below the waist the cloth may move at most this much TOWARDS the axis of the pelvis against the target: the target already holds it just outside the legs' reach, and a +simulated hem that lagged inwards let a foot come out through it in the run; < 0 = no limit), max_move (0.12 m: the most the cloth may differ from the kinematic target, soft limit; 0 = none). Measured on the replica of the original robe 469 (robe_cloth_sim_test.py, hybrid=1): +lower-body IoU walk 0.754 -> 0.765, run 0.731 -> 0.735 against the kinematic result; the simulation alone (no goal) collapses in walk and run. """ import json, os, sys, time import numpy as np @@ -20,18 +22,35 @@ HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE) import cloth_lib as cl # noqa: E402 +from bpy_compat import action_fcurves # noqa: E402 (Blender 5.x: no Action.fcurves) STEP = 3 +MOUNTED = False # key mounted=1: the riding actions 23-29 are simulated too (against the horse of the frame) LOOP = {0, 1, 2, 3, 4, 5, 6, 7, 8} -CFG = dict(goal=0.4, upper=1.0, ramp=0.30, quality=8, mass=0.3, tension=15.0, compression=15.0, shear=5.0, bending=0.5, damp_air=1.0, tdamp=5.0, dist=0.005, preroll=40, cycles=3, - self_collision=0, friction=0.0, arm_goal=0.7, arm_x=0.19, max_move=0.12) +FABRIC = { # mass / stiffness / bending / damping per fabric, after Blender's cloth presets (scripts/presets/cloth: Cotton, Denim, Leather, Silk; wool is ours, between cotton and denim). + # The in-plane stiffness (tension, compression, shear) is 5 for all of them, not the presets' 15-80: with the leg-hull target stretched under the legs (edges up to 2 x), + # a stiffer cloth is pulled up towards the waist by the pin and does not fall back (cotton at 15: the hem +0.26 m in the walk, wool at 25: +0.47 m, at 5: +0.04 m); mass, bending + # and damping are what tell the fabrics apart. Stiffer values can be given as keys (tension=...). + "silk": dict(mass=0.15, tension=5.0, compression=5.0, shear=5.0, bending=0.05, tdamp=1.0), + "cotton": dict(mass=0.3, tension=5.0, compression=5.0, shear=5.0, bending=0.5, tdamp=5.0), + "wool": dict(mass=0.5, tension=5.0, compression=5.0, shear=5.0, bending=2.0, tdamp=10.0), + "denim": dict(mass=1.0, tension=5.0, compression=5.0, shear=5.0, bending=10.0, tdamp=25.0), + "leather": dict(mass=0.4, tension=5.0, compression=5.0, shear=5.0, bending=150.0, tdamp=25.0), +} +CFG = dict(goal=0.4, upper=1.0, ramp=0.30, quality=8, mass=0.3, tension=15.0, compression=15.0, shear=5.0, bending=0.5, damp_air=1.0, tdamp=5.0, density=0.0, mscale=1.0, dscale=1.0, bscale=1.0, cq=4.0, cull=0.0, usenormal=0.0, mounted=0.0, horse=1.0, env=0.0, env_margin=0.02, env_tent=0.0, pushw=1.0, gravity=1.0, dist=0.005, preroll=40, cycles=3, + self_collision=0, friction=0.0, arm_goal=0.7, arm_x=0.19, arm_zmin=-9.0, max_move=0.12, thick=0.02, in_max=0.01, smooth=0.0) + + +def skip(a): + """actions not simulated: riding (the hem follows the legs there) and the falls when the item lies along the legs (render_uo_layer.py CLOTH_LIE, scene property uo_cloth_lie)""" + return (23 <= a <= 29 and not MOUNTED) or (a in (21, 22) and float(bpy.context.scene.get("uo_cloth_lie", 0)) > 0) def run_parallel(blend, out, only, jobs, extra): """the actions are independent: split them over `jobs` processes (the same script) and merge the results""" import subprocess, tempfile bpy.ops.wm.open_mainfile(filepath=blend) - names = [a.name for a in sorted([a for a in bpy.data.actions if "uo_action" in a], key=lambda a: int(a["uo_action"])) if not 23 <= int(a["uo_action"]) <= 29 and (only is None or a.name in only)] + names = [a.name for a in sorted([a for a in bpy.data.actions if "uo_action" in a], key=lambda a: int(a["uo_action"])) if not skip(int(a["uo_action"])) and (only is None or a.name in only)] groups = [names[k::jobs] for k in range(jobs)] tmp = tempfile.mkdtemp(prefix="uo_cloth_sim_"); procs = [] for k, g in enumerate(groups): @@ -52,7 +71,7 @@ def run_parallel(blend, out, only, jobs, extra): def main(): args = sys.argv[1:] - blend = os.path.abspath(args.pop(0)); out = None; only = None; cfg = dict(CFG); jobs = 1; extra = [] + blend = os.path.abspath(args.pop(0)); out = None; only = None; cfg = dict(CFG); jobs = 1; extra = []; own = {} while args: a = args.pop(0) if a == "--out": @@ -61,8 +80,15 @@ def main(): v = args.pop(0); only = None if v == "all" else set(v.split(",")) elif a == "--jobs": jobs = int(args.pop(0)) + elif a.startswith("fabric="): + fab = a.split("=", 1)[1]; extra.append(a) + if fab not in FABRIC: + raise SystemExit("fabric %s unknown: %s" % (fab, ", ".join(FABRIC))) + cfg.update(FABRIC[fab]) elif "=" in a: - k, v = a.split("=", 1); cfg[k] = float(v); extra.append(a) + k, v = a.split("=", 1); own[k] = float(v); extra.append(a) + global MOUNTED + cfg.update(own); MOUNTED = bool(cfg["mounted"]) # fabric preset first, then the settings named one by one out = out or os.path.join(os.path.dirname(blend), "cloth_sim.npz") if jobs > 1: return run_parallel(blend, out, only, jobs, extra) @@ -99,23 +125,56 @@ def main(): Mi = np.array(item.matrix_basis); Vr = co.reshape(-1, 3).astype(np.float64) @ Mi[:3, :3].T + Mi[:3, 3] rig.data.pose_position = "POSE"; bpy.context.view_layer.update() - def hull_push_now(): + def leg_state(): dg = bpy.context.evaluated_depsgraph_get(); evr = rig.evaluated_get(dg) skin = {n: Mr0 @ np.array(evr.pose.bones[n].matrix) @ inv_local[n] @ np.linalg.inv(Mr0) for n in names} heads = np.array([(skin[names[k]] @ np.append(caps.head[j], 1))[:3] for j, k in enumerate(caps.idx)]) tails = np.array([(skin[names[k]] @ np.append(caps.tail[j], 1))[:3] for j, k in enumerate(caps.idx)]) + return skin, heads, tails + + def envelope(X): + """the simulated cloth never pokes out of the shadow of the legs (cloth_lib.hull_push on the simulated points, in the rest frame of the pelvis): a leg in full stride does not come out of the hem""" + skin, heads, tails = leg_state(); S = skin["pelvis"] + Xr = (np.c_[X, np.ones(len(X))] @ np.linalg.inv(S).T)[:, :3] + return X + cl.hull_push(Xr, S, heads[sel], tails[sel], caps.radius[sel], centre_xy=tuple(prm["centre"]), margin=cfg["env_margin"], kappa=cfg["env"], z_top=prm["z_top"], z_hem=prm["z_hem"], + ramp=prm.get("ramp", 0.15), drop=prm.get("drop", 1.0), use_tent=bool(cfg["env_tent"])) + + def hull_push_now(): + skin, heads, tails = leg_state() if prm.get("hull") == "rel": return cl.hull_push_rel(Vr, skin["pelvis"], heads[sel], tails[sel], caps.radius[sel], caps.head[sel], caps.tail[sel], centre_xy=tuple(prm["centre"]), kappa=prm["kappa"], z_top=prm["z_top"], z_hem=prm["z_hem"], ramp=prm.get("ramp", 0.15), drop=prm.get("drop", 1.0)) return cl.hull_push(Vr, skin["pelvis"], heads[sel], tails[sel], caps.radius[sel], centre_xy=tuple(prm["centre"]), margin=prm["margin"], kappa=prm["kappa"], z_top=prm["z_top"], z_hem=prm["z_hem"], ramp=prm.get("ramp", 0.15), drop=prm.get("drop", 1.0)) + state = {"a": 0} + + def mounted_lie(): + """what render_uo_layer.cloth_push gives the hanging part in the mounted actions (CLOTH_MOUNTED = 1): it follows the legs of the skin under it, rest-world coordinates""" + dg = bpy.context.evaluated_depsgraph_get(); evr = rig.evaluated_get(dg) + skin = {n: Mr0 @ np.array(evr.pose.bones[n].matrix) @ inv_local[n] @ np.linalg.inv(Mr0) for n in names} + Vh = np.c_[Vr, np.ones(nv)]; Pp = (Vh @ skin["pelvis"].T)[:, :3]; d0 = np.zeros_like(Vr) + if all(("uo_leg_" + nm) in item.data.attributes for nm in cl.Capsules.NAMES): + for nm in cl.Capsules.NAMES: + w = np.empty(nv, np.float32); item.data.attributes["uo_leg_" + nm].data.foreach_get("value", w) + d0 += w[:, None] * ((Vh @ skin[nm].T)[:, :3] - Pp) + else: + side = np.clip(Vr[:, 0] / 0.06, -1, 1) * 0.5 + 0.5; ws = np.clip((0.58 - Vr[:, 2]) / 0.15, 0, 1)[:, None] + leg = lambda sd: (1 - ws) * (Vh @ skin["thigh." + sd].T)[:, :3] + ws * (Vh @ skin["shin." + sd].T)[:, :3] + d0 = np.clip((prm["z_top"] - Vr[:, 2]) / 0.2, 0, 1)[:, None] * ((1 - side)[:, None] * leg("L") + side[:, None] * leg("R") - Pp) + return d0 + def kinematic(): - """the item as the renderer draws it before the simulation: skinned + the push of the legs, world coordinates""" + """the item as the renderer draws it before the simulation: skinned + the push of the legs, world coordinates; and the target the pin holds: skinned + pushw x the push""" dg = bpy.context.evaluated_depsgraph_get(); ev = item.evaluated_get(dg) c = np.empty(nv * 3, np.float32); ev.data.vertices.foreach_get("co", c) M = np.array(item.matrix_world) - return c.reshape(-1, 3).astype(np.float64) @ M[:3, :3].T + M[:3, 3] + hull_push_now() + sk = c.reshape(-1, 3).astype(np.float64) @ M[:3, :3].T + M[:3, 3] + if 23 <= state["a"] <= 29: # riding: the hanging part lies along the legs (the same d0 as render_uo_layer.cloth_push), the pin holds all of it + push = mounted_lie() + return sk + push, sk + push + push = hull_push_now() + return sk + push, sk + cfg["pushw"] * push # --- the simulated copy ------------------------------------------------------------------------------------------------------------------------------------------- sim_me = item.data.copy() @@ -129,67 +188,131 @@ def kinematic(): w = np.where(z >= prm["z_top"] - 0.02, cfg["upper"], 1.0 - (1.0 - cfg["goal"]) * np.clip((prm["z_top"] - z) / cfg["ramp"], 0.0, 1.0)) w = np.where(z >= prm["z_top"] - 0.02, cfg["upper"], np.minimum(w, cfg["upper"])) if cfg["arm_goal"] >= 0: # sleeves: free to drape over the arm (collision with the arm), not rigid on the skeleton - arm = (np.abs(Vr[:, 0] - np.median(Vr[:, 0])) > cfg["arm_x"]) & (z > prm["z_top"] - 0.05) + reg = item.data.attributes.get("uo_region") + if reg is not None: # a conformed item (uo_conform_item.py) knows its sleeves: 1 / 2 the sleeves, 3 / 4 their roots + rv = np.empty(nv, np.int32); reg.data.foreach_get("value", rv); arm = rv > 0 + else: + arm = (np.abs(Vr[:, 0] - np.median(Vr[:, 0])) > cfg["arm_x"]) & (z > (cfg["arm_zmin"] if cfg["arm_zmin"] > -9 else prm["z_top"] - 0.05)) w = np.where(arm, cfg["arm_goal"], w) - print("uo_cloth_sim: %d vertices of the sleeves (|x| > %.2f m above the waist) get goal %.2f" % (int(arm.sum()), cfg["arm_x"], cfg["arm_goal"]), flush=True) + print("uo_cloth_sim: %d vertices of the sleeves (%s) get goal %.2f" % (int(arm.sum()), "uo_region of the conformed item" if reg is not None else "|x| > %.2f m above the waist" % cfg["arm_x"], cfg["arm_goal"]), flush=True) pin = sim.vertex_groups.new(name="goal") for wv in np.unique(np.round(w, 3)): pin.add([int(i) for i in np.nonzero(np.round(w, 3) == wv)[0]], float(wv), "REPLACE") cm = sim.modifiers.new("Cloth", "CLOTH"); st = cm.settings - st.quality = int(cfg["quality"]); st.mass = cfg["mass"]; st.tension_stiffness = cfg["tension"]; st.compression_stiffness = cfg["compression"]; st.shear_stiffness = cfg["shear"] - st.bending_stiffness = cfg["bending"]; st.air_damping = cfg["damp_air"]; st.tension_damping = st.compression_damping = st.shear_damping = cfg["tdamp"] + area = sum(pl.area for pl in item.data.polygons) + mass = cfg["density"] * area / nv if cfg["density"] > 0 else cfg["mass"] * cfg["mscale"] + print("uo_cloth_sim: mass %.6f kg per vertex (%s; area %.2f m2, %d vertices)" % (mass, "density %.2f kg/m2" % cfg["density"] if cfg["density"] > 0 else "as given", area, nv), flush=True) + st.quality = int(cfg["quality"]); st.mass = mass; st.tension_stiffness = cfg["tension"]; st.compression_stiffness = cfg["compression"]; st.shear_stiffness = cfg["shear"] + st.bending_stiffness = cfg["bending"] * cfg["bscale"]; st.air_damping = cfg["damp_air"] * cfg["dscale"]; st.tension_damping = st.compression_damping = st.shear_damping = cfg["tdamp"] * cfg["dscale"] st.vertex_group_mass = "goal"; st.pin_stiffness = 1.0 - cs = cm.collision_settings; cs.use_collision = True; cs.distance_min = cfg["dist"]; cs.collision_quality = 4; cs.use_self_collision = bool(cfg["self_collision"]) + cs = cm.collision_settings; cs.use_collision = not cfg.get("nocollide"); cs.distance_min = cfg["dist"]; cs.collision_quality = int(cfg["cq"]); cs.use_self_collision = bool(cfg["self_collision"]) if cfg["self_collision"]: cs.self_distance_min = 0.004 - body.modifiers.new("Collision", "COLLISION"); body.collision.thickness_outer = 0.005; body.collision.use_culling = False; body.collision.damping = 0.5; body.collision.cloth_friction = cfg["friction"] + body.modifiers.new("Collision", "COLLISION"); body.collision.thickness_outer = cfg["thick"]; body.collision.use_culling = bool(cfg["cull"]); body.collision.use_normal = bool(cfg["usenormal"]); body.collision.damping = 0.5; body.collision.cloth_friction = cfg["friction"] + sc.gravity = (0.0, 0.0, -9.81 * cfg["gravity"]) res = {} acts = sorted([a for a in bpy.data.actions if "uo_action" in a], key=lambda a: int(a["uo_action"])) for act in acts: a = int(act["uo_action"]) - if 23 <= a <= 29 or (only is not None and act.name not in only): + if skip(a) or (only is not None and act.name not in only): continue - t0 = time.time(); rig.animation_data.action = act; rig["uo_direction"] = 0 + t0 = time.time(); rig.animation_data.action = act; rig["uo_direction"] = 0; state["a"] = a nfr = int(act["uo_frames"]); loop = a in LOOP period = nfr * STEP if loop else (nfr - 1) * STEP + 1 - for fc in act.fcurves: + for fc in action_fcurves(act): for m in list(fc.modifiers): fc.modifiers.remove(m) m = fc.modifiers.new("CYCLES"); m.mode_before = "REPEAT" if loop else "NONE"; m.mode_after = "REPEAT" if loop else "NONE" sim.hide_viewport = True - T = [] + T = []; TP = []; AX = [] # the axis of the pelvis in every frame (centre, up) for i in range(nfr): # the kinematic target of every UO frame (the simulated copy is hidden: only the item and the rig count) sc.frame_set(1 + i * STEP); rig.update_tag(); bpy.context.view_layer.update() item.hide_viewport = False; bpy.context.view_layer.update() - T.append(kinematic()) + k, kp = kinematic(); T.append(k); TP.append(kp) + evr = rig.evaluated_get(bpy.context.evaluated_depsgraph_get()); Pm = np.array(rig.matrix_world) @ np.array(evr.pose.bones["pelvis"].matrix) + AX.append((Pm[:3, 3].copy(), Pm[:3, 1] / np.linalg.norm(Pm[:3, 1]))) # the pelvis bone points up its spine: its Y axis item.hide_viewport = True sim.hide_viewport = False; bpy.context.view_layer.update() - def setpos(scene, dg=None, T=T, nfr=nfr, loop=loop): - u = (scene.frame_current - 1) / STEP - u = u % nfr if loop else min(max(u, 0.0), nfr - 1) - i0 = int(np.floor(u)); fr = u - i0; i1 = (i0 + 1) % nfr if loop else min(i0 + 1, nfr - 1) - X = T[i0 % nfr] * (1 - fr) + T[i1] * fr - sim.data.vertices.foreach_set("co", X.astype(np.float32).ravel()); sim.data.update() - bpy.app.handlers.frame_change_pre[:] = [setpos] + # the target of every UO frame is a shape key of the simulated copy (1 on its own frame, 0 on the others, linear in between = the mix of two neighbours in between): the pin of Blender's + # cloth (goal) follows what the modifiers BEFORE it make of the mesh, and only an ANIMATED shape key reaches it (coordinates or key values written in a frame handler, as this script did + # before, leave the pinned vertices on the first target: measured in Blender 4.2 and 5.2) + if sim.data.shape_keys: + if sim.data.shape_keys.animation_data: + sim.data.shape_keys.animation_data_clear() + sim.shape_key_clear() + sim.shape_key_add(name="Basis", from_mix=False) + sim.data.shape_keys.key_blocks["Basis"].data.foreach_set("co", TP[0].astype(np.float32).ravel()) + keys = [] + for j in range(nfr): + kb = sim.shape_key_add(name="t%d" % j, from_mix=False); kb.data.foreach_set("co", TP[j].astype(np.float32).ravel()); keys.append(kb) pre = int(cfg["preroll"]); cyc = int(cfg["cycles"]) if loop else 1 f0 = 1 - pre; f1 = 1 + period * cyc if loop else 1 + (nfr - 1) * STEP + for ui in range(int(np.floor((f0 - 1) / STEP)), int(np.ceil((f1 - 1) / STEP)) + 1): + idx = ui % nfr if loop else min(max(ui, 0), nfr - 1) + for j, kb in enumerate(keys): + kb.value = 1.0 if j == idx else 0.0; kb.keyframe_insert("value", frame=1 + ui * STEP) + for fc in action_fcurves(sim.data.shape_keys.animation_data.action): + for kp in fc.keyframe_points: + kp.interpolation = "LINEAR" + horses = [] + if 23 <= a <= 29 and cfg["horse"] > 0: # riding: the horse proxy of the UO frame is a collider while that frame lasts (hidden objects do not collide: visibility keyed, constant) + horses = [(j, bpy.data.objects.get("Horse_a%d_f%d" % (a, j))) for j in range(nfr)] + horses = [(j, h) for j, h in horses if h is not None] + for j, h in horses: + if not any(m.type == "COLLISION" for m in h.modifiers): + h.modifiers.new("Collision", "COLLISION") + h.collision.thickness_outer = cfg["thick"]; h.collision.use_culling = bool(cfg["cull"]); h.collision.use_normal = bool(cfg["usenormal"]); h.collision.damping = 0.5; h.collision.cloth_friction = cfg["friction"] + h.animation_data_create() + for ui in range(int(np.floor((f0 - 1) / STEP)), int(np.ceil((f1 - 1) / STEP)) + 1): + h.hide_viewport = min(max(ui, 0), nfr - 1) != j; h.keyframe_insert("hide_viewport", frame=1 + ui * STEP - 1) + for fc in action_fcurves(h.animation_data.action): + for kp in fc.keyframe_points: + kp.interpolation = "CONSTANT" + print("uo_cloth_sim: action %d: %d horse colliders" % (a, len(horses)), flush=True) sc.frame_start = f0; sc.frame_end = f1; cm.point_cache.frame_start = f0; cm.point_cache.frame_end = f1 - setpos(sc); sc.frame_set(f0) + sc.frame_set(f0) base = 1 + (cyc - 1) * period if loop else 1 want = {base + i * STEP: i for i in range(nfr)} for f in range(f0, f1 + 1): sc.frame_set(f) + if cfg.get("trace"): + ev = sim.evaluated_get(bpy.context.evaluated_depsgraph_get()); c = np.empty(nv * 3, np.float32); ev.data.vertices.foreach_get("co", c); c = c.reshape(-1, 3) + if cfg.get("trace") == 2 and f == f1: + bev = body.evaluated_get(bpy.context.evaluated_depsgraph_get()); bc = np.empty(len(bev.data.vertices) * 3, np.float32); bev.data.vertices.foreach_get("co", bc); Mb2 = np.array(body.matrix_world) + np.savez(out + ".trace.npz", c=c, T=T[0], Vr=Vr, body=bc.reshape(-1, 3) @ Mb2[:3, :3].T + Mb2[:3, 3], arm=np.array(0)) + lo = Vr[:, 2] < 0.4; print("trace f%d: hem z mean %.3f (rest %.3f), radius %.3f (rest %.3f), key sum %.2f" % (f, c[lo, 2].mean(), Vr[lo, 2].mean(), np.hypot(c[lo, 0], c[lo, 1]).mean(), np.hypot(Vr[lo, 0], Vr[lo, 1]).mean(), sum(k.value for k in keys)), flush=True) if f in want: ev = sim.evaluated_get(bpy.context.evaluated_depsgraph_get()) c = np.empty(nv * 3, np.float32); ev.data.vertices.foreach_get("co", c) - i = want[f]; d = c.reshape(-1, 3).astype(np.float64) - T[i] + i = want[f]; c = c.reshape(-1, 3).astype(np.float64) + if cfg["env"] > 0: + c = envelope(c) + d = c - T[i] + if cfg["in_max"] >= 0: # below the waist: not more than in_max towards the axis of the pelvis (a foot through a lagging hem) + c0, up = AX[i]; rr = T[i] - c0; rr = rr - np.outer(rr @ up, up); ru = rr / np.maximum(np.linalg.norm(rr, axis=1, keepdims=True), 1e-9) + din = (d * ru).sum(1); low = Vr[:, 2] < prm["z_top"] - 0.05 + cut = np.where(low & (din < -cfg["in_max"]), -cfg["in_max"] - din, 0.0); d = d + cut[:, None] * ru if cfg["max_move"] > 0: # the cloth never wanders further than this from the body (a fast fall or blow lets it lag far behind and leaves the body bare) n = np.maximum(np.linalg.norm(d, axis=1), 1e-9); d = d * (cfg["max_move"] * np.tanh(n / cfg["max_move"]) / n)[:, None] res["%d_%d" % (a, i)] = d.astype(np.float32) + for j, h in horses: + h.animation_data_clear(); h.hide_viewport = True d = np.array([np.linalg.norm(res["%d_%d" % (a, i)], axis=1).max() for i in range(nfr)]) print("action %2d %-26s %d frames, loop %s, %.0f s, largest cloth move %.2f m" % (a, act.name, nfr, loop, time.time() - t0, d.max()), flush=True) + if cfg["smooth"] > 0 and res: # the move averaged in space (rest positions): pleats move together, they do not cut through each other + from scipy.spatial import cKDTree + from scipy.sparse import coo_matrix + r = cfg["smooth"]; pr = cKDTree(Vr).query_pairs(r, output_type="ndarray") + dd = np.linalg.norm(Vr[pr[:, 0]] - Vr[pr[:, 1]], axis=1); wk = np.exp(-(dd / (0.5 * r)) ** 2) + K = coo_matrix((np.r_[wk, wk, np.ones(nv)], (np.r_[pr[:, 0], pr[:, 1], np.arange(nv)], np.r_[pr[:, 1], pr[:, 0], np.arange(nv)])), shape=(nv, nv)).tocsr() + ks = np.asarray(K.sum(1)).ravel() + for k in res: + D = res[k].astype(np.float64) + for _ in range(2): + D = (K @ D) / ks[:, None] + res[k] = D.astype(np.float32) np.savez_compressed(out, **res) print("wrote", out, "(%d frames)" % len(res)) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_conform_item.py b/third_party/UO_Model3D_v13/pipeline/uo_conform_item.py index c139d4e..e98a597 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_conform_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_conform_item.py @@ -6,7 +6,7 @@ ITEM.blend is the output of uo_make_item.py (the item in the collection Clothing, placed and sized by uo_autofit_item.py, "prepare": {"FIT": false, "DENSIFY": false}; uo_make_item.py "conform" does that and runs this script). Its fit and weights are replaced. Why: a model made on another mannequin (the Jedi tunic: sleeves 9-18 cm above the UO arms, the chest of the UO body through its front and back) cannot be pushed into place in the rest pose: the push of uo_fit_item.py moved parts up to -21 cm, the sleeves stayed off the arms and in the actions the arms came out of them (docs/qa/jedi_tunic.md). Here: +21 cm, the sleeves stayed off the arms and in the actions the arms came out of them. Here: 1. sleeves onto the arms: per side the centre line of the sleeve (centroids of slices) is turned and moved onto the line of the UO arm (shoulder -> wrist); the move fades in over BLEND from the root of the sleeve and out beyond the radius of the sleeve tube, so the torso part stays where it is; 2. wrap: the item is pulled onto the body like a membrane, to h = GAP + a little of the model's looseness (LOOSE_K, at most LOOSE_MAX) and folds (FOLD_MAX): @@ -34,6 +34,12 @@ FOLD_SMOOTH = 12 # passes that make the smooth part of the stand-off (bigger = broader things count as folds) MOVE = (0.0, 0.0, 0.0) # m: the whole item is moved by this first SLEEVES = True # step 1 +SLEEVE_END = -1.0 # m: >= 0 = each sleeve is shortened or lengthened along its arm (only its last SLEEVE_SPAN before the end is squeezed / stretched, the cuff keeps its shape) so that it ends this far past the + # wrist (the head of the hand bone): a bell sleeve of a longer-armed mannequin hid the hands. < 0 = as the model +SLEEVE_SPAN = 0.25 # m +SLEEVE_CUFF = -1.0 # m: >= 0 = the end of each sleeve is narrowed to at most this far from the axis of the forearm (a bell sleeve: its wide mouth seen from the front, arms forward on + # the reins, was a ring round the hand; the original robes have narrow sleeves). < 0 = as the model +CUFF_SPAN = 0.20 # m: along the forearm before the end of the sleeve over which the narrowing grows from none to full BLEND = 0.10 # m: along the sleeve from its root, over which it goes from where the model has it to the UO arm TUBE_MARGIN = 0.05 # m: beyond the radius of the sleeve tube the move fades out over this (the side of the torso under the arm stays) SKIRT = True # below the crotch: hangs (the model's shape, pushed out of the legs), follows the pelvis, the legs push it in the render @@ -54,6 +60,16 @@ STRETCH = 1.25 # the most an edge may be stretched against the model while it is wrapped WRAP_FLATTEN = 0.2 # share of the smoothing across the surface (0 = only along it) WRAP_STEP = 0.01 # m: the most a vertex moves in one pass (the smoothing keeps up: no vertex flies off on its own) +SPACE_SMOOTH = 0.0 # m: > 0 = the move of the wrap is averaged over everything within this distance in SPACE (not along the mesh): a model of separate overlapping panels (a closure of two + # flaps, knots on it, a stand-up collar) moves as one piece, its panels do not cut through each other and open holes +SPACE_PASSES = 2 # passes of that averaging +FINAL_ITERS = 40 # passes of the last push out of the skin (to 0.9 GAP; inside an arm away from its bone) +OCCLUSION = 0.0 # 0..1: > 0 = the colour darker where the item's own folds, frills and layers (and the body) close the view around a point: x (1 - OCCLUSION x share of OCC_RAYS rays + # over the half sphere of the normal that hit within OCC_DIST), in the rest shape after the wrap (attribute uo_shade, uo_hood_cover.py occlusion_share). The UO light gives + # a smooth shell next to no shading (it comes from about the camera); the user asked for depth on the dress (2026-10-14). 0 = off +OCC_RAYS = 24 +OCC_DIST = 0.06 # m +SHADE_MIN = 0.35 # the darkening never takes the colour below this share (deep shade, never black) REGIONS = ("torso", "arm.L", "arm.R", "leg.L", "leg.R") CLOTH_MARGIN_SHORT, CLOTH_KAPPA_SHORT, CLOTH_MARGIN, CLOTH_KAPPA, CLOTH_DROP = 0.02, 0.5, 0.05, 0.8, 1.0 # uo_bind_item.py mark_cloth @@ -131,6 +147,26 @@ def rot_between(a, b): return np.eye(3) + K + K @ K / (1 + c) +def sleeve_piece(X, G, sel, J, touch=0.02): + """of the vertices `sel` (out past the shoulder) only the piece of the mesh that starts nearest the shoulder joint J and what touches it (a separate cuff panel); + returns (mask, vertices left out)""" + import scipy.sparse as sp + from scipy.sparse.csgraph import connected_components + from scipy.spatial import cKDTree + idx = np.nonzero(sel)[0]; pos = np.full(len(X), -1); pos[idx] = np.arange(len(idx)) + e = G.E[sel[G.E[:, 0]] & sel[G.E[:, 1]]] + _, comp = connected_components(sp.coo_matrix((np.ones(len(e)), (pos[e[:, 0]], pos[e[:, 1]])), shape=(len(idx), len(idx))), directed=False) + d = np.linalg.norm(X[idx] - J, axis=1) + keep = comp == comp[np.argmin(d)] + tree = cKDTree(X[idx][keep]) + for c in np.unique(comp[~keep]): + m = comp == c + if tree.query(X[idx][m], distance_upper_bound=touch)[0].min() < touch: + keep |= m + out = np.zeros(len(X), bool); out[idx[keep]] = True + return out, int((~keep).sum()) + + def align_sleeves(X, rig, G): """turn and move each sleeve so that its centre line lies on the UO arm (shoulder joint -> wrist); fades in over BLEND from the root of the sleeve""" X = X.copy(); member = np.zeros(len(X)) @@ -141,6 +177,9 @@ def align_sleeves(X, rig, G): sel = (X[:, 0] * sg > abs(J[0]) + 0.05) & (X[:, 2] > J[2] - 0.6) if sel.sum() < 50: print("uo_conform_item: no sleeve on side %s" % sd); continue + sel, dropped = sleeve_piece(X, G, sel, J) # a ball gown: the top of its skirt is also out past the shoulder and above J - 0.6 (taken for the sleeve, + if dropped: # it was squeezed onto the arm and the sleeve tore) + print("uo_conform_item: sleeve %s: %d vertices out past the shoulder left out (not joined to the sleeve: the skirt)" % (sd, dropped)) S = X[sel] far = S[np.argmax(np.linalg.norm(S - J, axis=1))] d0 = (far - J) / np.linalg.norm(far - J) @@ -159,6 +198,7 @@ def align_sleeves(X, rig, G): Rt = np.percentile(perp[sel & (sx > 0)], 95) wt = np.clip((Rt + TUBE_MARGIN - perp) / TUBE_MARGIN, 0, 1) s = (X - root) @ ds + wt = wt * np.clip((np.percentile(s[sel], 99.5) + TUBE_MARGIN - s) / TUBE_MARGIN, 0, 1) # and not past the end of the sleeve (the skirt further along its line) w = np.clip(s / BLEND, 0, 1) * wt * (X[:, 0] * sg > 0) w = G.smooth(w, 6); w = w * w * (3 - 2 * w) Y = (X - cs) @ R.T + target @@ -257,6 +297,36 @@ def main(): X0 = X0 + np.array(MOVE) # a nudge of the whole item (e.g. a model that sits too high on the UO shoulders) # 1. sleeves onto the arms X, sleeve = align_sleeves(X0, rig, G) if SLEEVES else (X0.copy(), np.zeros(len(X0))) + if SLEEVE_END >= 0: # sleeves shortened to end SLEEVE_END past the wrist + for sd, sg in (("L", 1.0), ("R", -1.0)): + J = np.array(rig.matrix_world @ rig.data.bones["upper_arm." + sd].head_local); Hd = np.array(rig.matrix_world @ rig.data.bones["hand." + sd].head_local) + ax = (Hd - J) / np.linalg.norm(Hd - J); tw = (Hd - J) @ ax + m = (sleeve > 0.5) & (X[:, 0] * sg > 0) + if m.sum() < 20: + continue + t = (X - J) @ ax; tend = float(np.percentile(t[m], 99.5)); want = tw + SLEEVE_END + if abs(tend - want) < 0.002: + continue + t0 = want - SLEEVE_SPAN; k = (want - t0) / (tend - t0) + w = np.clip(sleeve, 0, 1) * (X[:, 0] * sg > 0) * (t > t0) + X = X - (w * (t - t0) * (1 - k))[:, None] * ax + print("uo_conform_item: sleeve %s resized along the arm: ended %.1f cm past the wrist, now %.1f cm (last %.0f cm squeezed x%.2f)" % (sd, 100 * (tend - tw), 100 * SLEEVE_END, 100 * SLEEVE_SPAN, k), flush=True) + + if SLEEVE_CUFF >= 0: # the mouth of the sleeve narrowed round the forearm + for sd, sg in (("L", 1.0), ("R", -1.0)): + El = np.array(rig.matrix_world @ rig.data.bones["forearm." + sd].head_local); Hd = np.array(rig.matrix_world @ rig.data.bones["hand." + sd].head_local) + ax = (Hd - El) / np.linalg.norm(Hd - El) + m = (sleeve > 0.5) & (X[:, 0] * sg > 0) + if m.sum() < 20: + continue + t = (X - El) @ ax; tend = float(np.percentile(t[m], 99.5)) + R = (X - El) - t[:, None] * ax; r = np.linalg.norm(R, axis=1) + w = np.clip(sleeve, 0, 1) * (X[:, 0] * sg > 0) * np.clip((t - (tend - CUFF_SPAN)) / CUFF_SPAN, 0, 1) + w = w * w * (3 - 2 * w) + rn = np.where(r > SLEEVE_CUFF, SLEEVE_CUFF + (r - SLEEVE_CUFF) * 0.15, r) # beyond the limit only a little of the flare stays (folds keep their order) + k = 1 - w * (1 - rn / np.maximum(r, 1e-9)) + X = X - ((1 - k) * 1.0)[:, None] * R + print("uo_conform_item: sleeve %s cuff: radius at the end %.1f cm (p90) -> at most ~%.1f cm" % (sd, 100 * float(np.percentile(r[m & (t > tend - 0.03)], 90)), 100 * SLEEVE_CUFF), flush=True) # 2. wrap: the target stand-off h from the model's own (aligned) stand-off l, _, f, _ = nearest(bvh_any, X) @@ -344,12 +414,27 @@ def wrap_pass(Xs, attract): sd = nearest(bvh_col, Xs, 0.5)[3] print("uo_conform_item: %s: wrapped (%d passes): stand-off minus target p50 %.1f p90 %.1f max %.1f mm; edge length / model's p01 %.2f p99 %.2f max %.2f" % ( o.name, WRAP_ITERS, *(1000 * np.percentile(sd - h, [50, 90, 100])), *np.percentile(r1, [1, 99, 100])), flush=True) - for it in range(10): # what the blend or a concave spot (armpit, crotch) brought too close: out to GAP + if SPACE_SMOOTH > 0: # the move of the wrap averaged in space: overlapping panels (flaps, knots, collar) move together + from scipy.spatial import cKDTree + from scipy.sparse import coo_matrix + pr = cKDTree(X).query_pairs(SPACE_SMOOTH, output_type="ndarray") + dd = np.linalg.norm(X[pr[:, 0]] - X[pr[:, 1]], axis=1); wk = np.exp(-(dd / (0.5 * SPACE_SMOOTH)) ** 2) + K = coo_matrix((np.r_[wk, wk, np.ones(n)], (np.r_[pr[:, 0], pr[:, 1], np.arange(n)], np.r_[pr[:, 1], pr[:, 0], np.arange(n)])), shape=(n, n)).tocsr() + ks = np.asarray(K.sum(1)).ravel() + D = Xs - X + for _ in range(SPACE_PASSES): + D = (K @ D) / ks[:, None] + Xs = X + D + print("uo_conform_item: %s: the wrap's move averaged over %.0f mm in space (%d passes, %.0f neighbours per vertex)" % (o.name, 1000 * SPACE_SMOOTH, SPACE_PASSES, 2 * len(pr) / n), flush=True) + for it in range(FINAL_ITERS): # what the blend or a concave spot (armpit, crotch) brought too close: out to GAP _, nr2, _, S1 = nearest(bvh_any, Xs, 0.3) + nr2 = np.where((S1 < 0)[:, None], limb_out(Xs, nr2), nr2) # inside an arm: straight away from its bone need = np.clip(GAP * 0.9 - S1, 0, None) if need.max() < 5e-4: break Xs = Xs + G.smooth(need[:, None] * nr2, 2) + need[:, None] * nr2 + S1 = nearest(bvh_any, Xs, 0.3)[3] + print("uo_conform_item: %s: after the last push (%d passes): %d vertices inside the skin, deepest %.1f mm" % (o.name, it + 1, int((S1 < 0).sum()), -1000 * min(S1.min(), 0)), flush=True) X1 = Xs S1 = nearest(bvh_any, X1, 0.3)[3] print("uo_conform_item: %s: %d vertices; shell %.0f mm + looseness / folds: stand-off p50 %.1f / p90 %.1f / max %.1f cm (model %.1f / %.1f / %.1f), min %.1f mm%s" % ( @@ -409,6 +494,14 @@ def wrap_pass(Xs, attract): lab = rs.argmax(1) lab = np.where((lab > 0) & (rs[:, 0] >= ROOT_SHARE), lab + 2, lab) # the root of a sleeve (some weight of the torso): kept off both at.data.foreach_set("value", lab[node].astype(np.int32)) # 0 torso part, 1 / 2 left / right sleeve, 3 / 4 their roots: cloth_lib.conform_push, render_uo_layer.py + if OCCLUSION > 0: # depth: folds, frills, the side under a sleeve darker (rest shape; the vertices carry it into every frame) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from uo_hood_cover import occlusion_share, apply_shade + occ = occlusion_share(X1, orient * item_normals(X1), itri, E, Vb, tri, OCC_RAYS, OCC_DIST) + fsh = np.maximum(1 - OCCLUSION * occ, SHADE_MIN) + apply_shade(o.data, fsh[node]) + print("uo_conform_item: %s: occlusion x%.2f (rays %d, %.0f mm): share hit p50 %.2f p90 %.2f, colour x p10 %.2f, %d vertices at SHADE_MIN %.2f" % ( + o.name, OCCLUSION, OCC_RAYS, 1000 * OCC_DIST, np.percentile(occ, 50), np.percentile(occ, 90), np.percentile(fsh, 10), int((fsh <= SHADE_MIN).sum()), SHADE_MIN), flush=True) o["uo_conform"] = json.dumps(dict(gap=POSE_GAP)) if SKIRT and (Wleg.sum(1) > 0.05).any(): o["uo_cloth"] = json.dumps(cloth_params(rig, X1)) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_densify_item.py b/third_party/UO_Model3D_v13/pipeline/uo_densify_item.py index f013371..60600eb 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_densify_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_densify_item.py @@ -14,12 +14,12 @@ KIND = "" # kind of item (as in uo_fit_item.py): picks TARGET_EDGE from EDGE_BY_KIND when TARGET_EDGE < 0 ("" = not given) TARGET_EDGE = -1.0 # m, mean edge length to reach; < 0 = from KIND (EDGE_BY_KIND, else 0.03); 0 = off # TARGET_EDGE by KIND. Measured (test_density.py, replicas decimated to 10 % and densified, IoU with the sprite and px/frame from the full-density -# render, `docs/qa/density.json`): shirt, pants, boots reach the full-density result at a mean edge <= 5 cm (no gain below), gloves need 2 cm (fingers; at 5 cm +# render, `test_density.py`): shirt, pants, boots reach the full-density result at a mean edge <= 5 cm (no gain below), gloves need 2 cm (fingers; at 5 cm # nothing is subdivided and the gloves stay 16 px/frame off, at 2 cm 4 px). The others are set by analogy (limbs bend like pants / shirt), NOT measured: # 4 cm for limbs and trunk, 2 cm for small parts, 0 for what does not bend (helmets, rigid items ride on one bone; the push of uo_fit_item.py only needs faces # not much bigger than the gap, 5 cm). EDGE_BY_KIND = {"shirt": 0.04, "pants": 0.04, "legs": 0.04, "arms": 0.03, "plate": 0.04, "boots": 0.03, "gloves": 0.02, "neck": 0.02, - "helm": 0.05, "hair": 0.05, "beard": 0.03, "robe": 0.04, "skirt": 0.04, "cloak": 0.04, "harness": 0.04, "waist": 0.03, "vest": 0.04} + "helm": 0.05, "hair": 0.05, "beard": 0.03, "robe": 0.04, "skirt": 0.04, "cloak": 0.04, "harness": 0.04, "waist": 0.03, "vest": 0.04, "hood": 0.04} MAX_LEVEL = 3 # subdivision steps at most MAX_VERTS = 30000 # vertices at most after densifying SMOOTH = 0.0 # 0..1, see above diff --git a/third_party/UO_Model3D_v13/pipeline/uo_fit_item.py b/third_party/UO_Model3D_v13/pipeline/uo_fit_item.py index ff8a017..c1f406b 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_fit_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_fit_item.py @@ -24,15 +24,15 @@ # MIN_GAP by KIND, measured on thin body-hugging replicas against the original sprites (test_items.py --fit, IoU at 0.015 / 0.03 / 0.045): # close-fitting cloth and leather is best at 0.015 (shirt .746/.727/.681, pants .801/.793/.747, boots .771/.755/.693, gloves .579/.576/.526); thick armour, # helmets and sleeves at 0.03 (plate .702/.725/.706, helm .770/.783/.658, arms .615/.619/.598, legs .667/.673/.651). The sprites agree: shirts, trousers, boots -# stand 1 px (2.8 cm) off the silhouette of the body, plate / helmets / robes 2-4 px (docs/qa/layer_analysis.md). "neck" (gorget, a hard slot in uo_prepare_item.py) is 0.03 by +# stand 1 px (2.8 cm) off the silhouette of the body, plate / helmets / robes 2-4 px. "neck" (gorget, a hard slot in uo_prepare_item.py) is 0.03 by # its class, not measured. -GAP_BY_KIND = {"shirt": 0.015, "pants": 0.015, "boots": 0.015, "gloves": 0.015, "plate": 0.03, "legs": 0.03, "arms": 0.03, "helm": 0.03, "neck": 0.03, "robe": 0.02, "skirt": 0.02, "cloak": 0.03, "waist": 0.015, "vest": 0.02} +GAP_BY_KIND = {"shirt": 0.015, "pants": 0.015, "boots": 0.015, "gloves": 0.015, "plate": 0.03, "legs": 0.03, "arms": 0.03, "helm": 0.03, "neck": 0.03, "robe": 0.02, "skirt": 0.02, "cloak": 0.03, "waist": 0.015, "vest": 0.02, "hood": 0.03} MAX_GAP = 0.0 # m, > 0: pull parts standing off more than this towards the skin (0 = off) LIMIT = -1.0 # m, soft limit of how far from the skin any part of the item may stand: what is farther is brought closer (S -> LIMIT + (S - LIMIT) * LIMIT_K), smoothly over the # mesh, so details are squashed, not cut. This is what keeps big pauldrons, flared cuffs and fat collars "UO-thin": the original items of a slot stand - # out of the body silhouette by this much at most (docs/qa/layer_analysis.md: plate 5.6-8.1 cm, shirt 5.6, helm 8.3); < 0 = LIMIT_BY_KIND, 0 = off + # out of the body silhouette by this much at most (plate 5.6-8.1 cm, shirt 5.6, helm 8.3); < 0 = LIMIT_BY_KIND, 0 = off LIMIT_K = 0.3 # what is left of the excess: 0 = cut off at LIMIT, 1 = no limit -LIMIT_BY_KIND = {"shirt": 0.07, "plate": 0.05, "harness": 0.06, "arms": 0.07, "pants": 0.06, "legs": 0.07, "boots": 0.05, "gloves": 0.05, "helm": 0.10, "neck": 0.06, "waist": 0.05, "vest": 0.07} +LIMIT_BY_KIND = {"shirt": 0.07, "plate": 0.05, "harness": 0.06, "arms": 0.07, "pants": 0.06, "legs": 0.07, "boots": 0.05, "gloves": 0.05, "helm": 0.10, "neck": 0.06, "waist": 0.05, "vest": 0.07, "hood": 0.12} RADIUS = 0.04 # m, smallest area a push spreads over SPREAD = 3.0 # a push of d spreads over at least SPREAD * d (bigger = broader, gentler swelling) ITERATIONS = 12 # push rounds at most (it stops as soon as nothing is too close) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_flail.py b/third_party/UO_Model3D_v13/pipeline/uo_flail.py new file mode 100644 index 0000000..92c4aca --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_flail.py @@ -0,0 +1,370 @@ +"""A flail (haft + chain + ball): the haft stays rigid in the hand, the chain and the ball swing with the motion of the hand (own simulation, per action). + + python uo_flail.py ITEM.blend [OUT.blend] [--out flail_sim.npz] [--actions 09_attack_1h_slash,...|all] [--set NAME=VALUE ...] + +ITEM.blend = a weapon made by uo_make_item.py ("weapon": {"part": "weapon1h", ...}): rigid on weapon1h.R, laid along the hand line with the butt at the hand and the +ball at the far end. uo_make_item.py runs this for a recipe with "flail": {...} (its settings). +1. Parts along the weapon axis (rest pose; s = m from the butt): the haft is the thin solid part at the butt (few vertices per cm), the chain the thin part with many + vertices (links), the ball the wide end (radius > BALL_R); HAFT_END / BALL_START (m) override. BALL_SCALE scales the ball about its point on the chain. + CHAIN_SCALE stretches the chain along the axis (the ball moves with it). +2. The chain is SEGMENTS rigid pieces between nodes on the axis, the ball one more rigid piece from the last node to its centre. Per vertex: attribute uo_flail_s (s) + and uo_flail_part (0 haft, 1 chain, 2 ball); the object gets the custom property uo_flail (JSON: the rest nodes in armature space, ...). +3. Simulation (Verlet, armature space of direction 0; the direction is a turn about z, which does not change gravity): node 0 is the end of the haft, carried by + the bone weapon1h.R; the others fall (GRAVITY), lose speed (DAMP per s), keep their distances (ITER passes, the ball MASS x a link) and stay out of the body + (capsules of pelvis, spine, chest, neck, head, thighs, shins, left arm; GAP). A UO frame lasts FRAME_TIME s; the hand moves smoothly between the scene frames + (STEP per UO frame, interpolated). A looping action (walk, run, stand, idle) runs CYCLES times, the last one is kept; any other starts after PREROLL s + hanging still in its first pose. The nodes per action and frame go to flail_sim.npz next to the item ("_": (n, 3) armature space), + render_uo_layer.py moves the chain and ball by them (rotation of each piece = the turn of its segment carried down the chain from the haft: no twist jumps). +Calibration against an original flail .vd: flail_fit.py. +""" +import json, os, sys +import numpy as np +import bpy +from mathutils import Matrix, Vector, Quaternion + +HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE) +from bpy_compat import action_fcurves # noqa: E402,F401 + +HAFT_END = 0.0 # m from the butt where the chain starts; 0 = found from the mesh +BALL_START = 0.0 # m from the butt where the ball starts; 0 = found +BALL_R = 0.07 # m: a cross-section wider than this (95th percentile radius) is the ball +BALL_SCALE = 1.0 # the ball scaled about the point where the chain holds it +CHAIN_SCALE = 1.0 # the chain stretched along the axis (the ball moves on) +HAFT_SCALE = 1.0 # the haft stretched along the axis from the butt (the chain and the ball move on) +STIFF = 0.0 # 1/s: each piece of the chain is pulled towards the line of the one before it (the first: the haft); 0 = a free chain. The original flail is drawn + # with a chain that mostly keeps the line of the haft +SEGMENTS = 6 # rigid pieces of the chain +FRAME_TIME = 0.1 # s per UO frame +SUB = 12 # simulation steps per scene frame +GRAVITY = 9.81 +DAMP = 1.5 # 1/s: velocity lost (air, the friction of the links) +MASS = 6.0 # the ball's mass in links +ITER = 20 +GAP = 0.02 # m outside the body capsules +BALL_GAP = 0.0 # m: extra radius of the ball node against the body (0 = from the ball's size x 0.6) +INELASTIC = True # the push out of the body does not turn into speed (with it, the ball bounced off the head, the chest and the saddle: 2-5 x faster than the original + # in eat, salute, mounted walk, the bow attacks) +WEAPON_BONE_ACTIONS = "" # actions ("18,23,26") in which the haft is carried by weapon1h.R (its turn fitted to the original weapons) instead of HOLD; needs HAND_SMOOTH > 0 + # (the renderer draws the haft with the stored carrier). Measured: mounted 1H attack (26): with the hand the haft crossed the body, with weapon1h.R it swings outside +ALIGN_ACTIONS = "" # actions in which the haft continues the line of the forearm (the hand's turn kept otherwise, the grip in the hand): a swing or a raised fist. Mounted 1H + # attack (26): the original flail is the arm's extension and the ball flies out far forward; with weapon1h.R the haft pointed into the body. Area spell (17): + # the original holds it up along the raised forearm; with the hand it turned about the arm's axis (user, 2026-10-13) +UP_ACTIONS = "" # actions in which the haft stands upright in the hand (salute, eat: user, 2026-10-13) +HANG_ACTIONS = "" # actions in which the chain does not swing: every frame it hangs straight down from the end of the haft, along the haft when the haft is upright, the ball + # beside it on the outer side (salute, eat: "static, the chain hangs along the haft"; the free chain whirled by the head) +ACTION_SET = "" # per-action settings of the simulation: "26:STIFF=40,DAMP=2;9:DAMP=3" (STIFF, DAMP, FRAME_TIME, ALIGN_FWD, LEAN_OUT, LEAN_BACK, GRIP_TWIST) +ALIGN_FWD = 0.0 # ALIGN_ACTIONS / UP_ACTIONS: the forearm's line (the upright) tipped towards the front of the body by this (0 = none; 1 = half way at right angles; + # < 0 = towards the back). Eat (34): upright from the hand at the hip the haft went up through the side of the torso in frame 0 (user, 2026-10-13) +LEAN_OUT = 0.0 # ALIGN_ACTIONS / UP_ACTIONS: tipped towards the character's right (the side of the hand) by this, and LEAN_BACK towards its back, both in the frame +LEAN_BACK = 0.0 # of the direction the character faces (the rig; in walk war the torso is turned ~70 deg from it). The free chain hangs straight down from the top of + # the haft, so a haft leaning out and back carries the ball outside and behind it. Combat advance (15, UP): the ball hung between the haft and the + # head; the user wants it to the left of the character seen from the front and towards its back (2026-10-13; a mirror of the hand's haft line + # across the torso's front-back plane only moved it into the picture) +GRIP_TWIST = 0.0 # deg: the haft turned about the hand's axis through the grip in the actions carried by the hand. Walk (0, 1: ACTION_SET "1:GRIP_TWIST=20"): the original + # haft points out to the side, ours pointed forward and in, so seen from behind the ball hung in front of the belly, hidden; in every action it made the + # attacks worse (slash ball +4.9 px), so per action only +HAND_SMOOTH = 0 # passes of smoothing of the hand's turn over the frames of an action (about the grip; loops wrap): the turn of our wrist is fitted to silhouettes and + # shakes in some actions (mounted walk: the end of the haft jumped 0.3-0.5 m a frame); the haft is drawn with the smoothed turn too +RIGID = "" # actions (numbers, "9,23") in which the flail is held gathered: the chain hangs settled in the first pose and then moves rigidly with the hand + # (the original draws the ball by the haft in riding, salute, eat, spells; a free chain there whirled); "all" = every action +PREROLL = 1.5 # s hanging in the first pose before a one-shot action +CYCLES = 3 +STEP = 3 +LOOP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 15, 23, 24, 25} +BONE = "weapon1h.R" # the weapon line the haft was laid on (uo_place_weapon.py, weapon_motion.json) +HOLD = "weapon1h.R" # the bone the haft is bound to (and carried by): "hand.R" rebinds it rigidly to the hand. On weapon1h.R the far end of a long haft jumped 0.4-0.8 m + # between frames in some actions (mounted walk, eat): that bone is fitted per frame to short original weapons, its small turns are big at 0.5 m +CAPSULES = ("pelvis", "spine", "chest", "neck", "head", "thigh.L", "shin.L", "thigh.R", "shin.R", "upper_arm.L", "forearm.L") + + +GRIP = [] + + +def rest_axis(rig): + """butt point and direction of the weapon line (armature space), as uo_place_weapon.py laid the weapon""" + wm = json.loads(bpy.data.texts["weapon_motion.json"].as_string())[BONE] + H = np.array(rig.data.bones[wm["parent"]].matrix_local) + piv = (H @ np.append(wm["pivot"], 1))[:3]; d = H[:3, :3] @ np.array(wm["dir"]); d /= np.linalg.norm(d) + return piv, d + + +def prepare(ob, rig): + Mo = np.linalg.inv(np.array(rig.matrix_world)) @ np.array(ob.matrix_world) # object -> armature + co = np.array([v.co for v in ob.data.vertices]); V = co @ Mo[:3, :3].T + Mo[:3, 3] + piv, d = rest_axis(rig); GRIP[:] = [piv] + s0 = (V - piv) @ d; butt = piv + d * s0.min(); s = (V - butt) @ d + radial = np.linalg.norm((V - butt) - np.outer(s, d), axis=1) + L = s.max(); nb = 60; edges = np.linspace(0, L, nb + 1); k = np.clip((s / L * nb).astype(int), 0, nb - 1) + cnt = np.bincount(k, minlength=nb); rr = np.array([np.percentile(radial[k == i], 95) if (k == i).any() else 0 for i in range(nb)]) + if BALL_START > 0: + sb = BALL_START + else: # the ball: the wide cross-sections in the far half (spike tips at the very end are narrow) + wide = np.nonzero((rr > BALL_R) & (np.arange(nb) >= nb // 2))[0] + sb = edges[wide.min()] if len(wide) else L + if HAFT_END > 0: + sh = HAFT_END + else: # the haft: thin and plain (few vertices); the chain: thin with many (links) + thin = np.nonzero(rr[:int(sb / L * nb)] <= BALL_R)[0] + dens = cnt[thin]; lo = np.median(dens[:max(3, len(dens) // 6)]); hi = np.median(dens) + cut = (lo * hi) ** 0.5 + i = 0 + while i < len(thin) - 2 and cnt[thin[i]] > cut: # past a pommel / knob at the butt + i += 1 + while i < len(thin) - 2 and not (cnt[thin[i]] > cut and cnt[thin[i + 1]] > cut): + i += 1 + sh = edges[thin[i]] + part = np.where(s >= sb, 2, np.where(s >= sh, 1, 0)) + ball = V[part == 2]; tip = butt + d * sb + if HAFT_SCALE != 1.0: # longer / shorter haft from the butt: the chain and the ball move on + ext = sh * (HAFT_SCALE - 1); m0 = part == 0 + V[m0] += np.outer(s[m0] * (HAFT_SCALE - 1), d); V[~m0] += d * ext; sh += ext; sb += ext; tip = butt + d * sb + s = (V - butt) @ d + if CHAIN_SCALE != 1.0: # longer / shorter chain: the chain stretched, the ball moved on + m = part >= 1; ext = (sb - sh) * (CHAIN_SCALE - 1) + sc = np.clip((s - sh) / max(sb - sh, 1e-6), 0, 1) + V[m] += np.outer(np.where(part[m] == 2, ext, sc[m] * ext), d); sb += ext; tip = butt + d * sb + if BALL_SCALE != 1.0: + m = part == 2; V[m] = tip + (V[m] - tip) * BALL_SCALE + s = (V - butt) @ d + bc = V[part == 2].mean(0) if (part == 2).any() else tip + nodes = [butt + d * (sh + (sb - sh) * j / SEGMENTS) for j in range(SEGMENTS + 1)] + [bc] + Mi = np.linalg.inv(Mo) + ob.data.vertices.foreach_set("co", (V @ Mi[:3, :3].T + Mi[:3, 3]).astype(np.float32).ravel()) + ob.data.update() + for name, val, typ in (("uo_flail_s", s, "FLOAT"), ("uo_flail_part", part, "INT")): + a = ob.data.attributes.get(name) or ob.data.attributes.new(name, typ, "POINT") + a.data.foreach_set("value", val.astype(np.float32 if typ == "FLOAT" else np.int32)) + br = float(np.percentile(np.linalg.norm(V[part == 2] - bc, axis=1), 70)) if (part == 2).any() else 0.05 + ob["uo_flail"] = json.dumps(dict(nodes=np.round(nodes, 6).tolist(), haft_end=float(sh), ball_start=float(sb), ball_r=br, bone=HOLD, grip=np.round(piv, 6).tolist())) + if HOLD != BONE: # rebind rigidly to HOLD + for gname in [g.name for g in ob.vertex_groups]: + ob.vertex_groups.remove(ob.vertex_groups[gname]) + ob.vertex_groups.new(name=HOLD).add(list(range(len(ob.data.vertices))), 1.0, "REPLACE") + ob["uo_no_body_gap"] = 1 # rigid pieces: the renderer must not bend them round the limbs + print("uo_flail: %s: haft 0-%.3f m, chain %.3f-%.3f m (%d pieces), ball %.3f-%.3f m (radius %.3f m, x%.2f), %d / %d / %d vertices" + % (ob.name, sh, sh, sb, SEGMENTS, sb, s.max(), br, BALL_SCALE, (part == 0).sum(), (part == 1).sum(), (part == 2).sum())) + return np.array(nodes), br + + +def capsules(rig, body): + """rest capsules (armature space): bone head / tail and the median distance of the skin the bone dominates""" + me = body.data; Mb = np.linalg.inv(np.array(rig.matrix_world)) @ np.array(body.matrix_world) + V = np.array([v.co for v in me.vertices]) @ Mb[:3, :3].T + Mb[:3, 3] + gn = {g.index: g.name for g in body.vertex_groups} + dom = [gn[max(v.groups, key=lambda g: g.weight).group] if len(v.groups) else "" for v in me.vertices] + out = [] + for n in CAPSULES: + b = rig.data.bones.get(n) + if b is None: + continue + h, t = np.array(b.head_local), np.array(b.tail_local) + P = V[np.array([x == n for x in dom])] + if not len(P): + continue + ab = t - h; u = np.clip(((P - h) @ ab) / (ab @ ab), 0, 1) + r = float(np.percentile(np.linalg.norm(P - (h + u[:, None] * ab), axis=1), 60)) + out.append((n, h, t, r)) + return out + + +def seg_push(X, caps, mats, rad): + """points X out of the posed capsules (each from the one it is deepest in)""" + for n, h, t, r in caps: + M = mats[n]; A = M[:3, :3] @ h + M[:3, 3]; B = M[:3, :3] @ t + M[:3, 3] + ab = B - A; u = np.clip(((X - A) @ ab) / max(ab @ ab, 1e-9), 0, 1); C = A + u[:, None] * ab + dv = X - C; dist = np.linalg.norm(dv, axis=1); pen = r + rad - dist + m = pen > 0 + if m.any(): + X[m] += dv[m] / np.maximum(dist[m], 1e-9)[:, None] * pen[m, None] + return X + + +def pose_mats(rig, names): + """skinning matrices (armature space) of the bones in the current frame""" + out = {} + for n in names: + pb = rig.pose.bones[n]; out[n] = np.array(pb.matrix) @ np.linalg.inv(np.array(pb.bone.matrix_local)) + return out + + +def interp(M0, M1, t): + q0 = Matrix(M0.tolist()).to_quaternion(); q1 = Matrix(M1.tolist()).to_quaternion() + if q0.dot(q1) < 0: + q1 = -q1 + R = np.array(q0.slerp(q1, t).to_matrix()) + out = np.eye(4); out[:3, :3] = R; out[:3, 3] = (1 - t) * M0[:3, 3] + t * M1[:3, 3] + return out + + +def simulate(rig, act, nodes, br, caps): + """the nodes in every UO frame of the action (armature space); the hand between two UO frames is interpolated (slerp), the body is that of the nearer frame""" + sc = bpy.context.scene + a = int(act["uo_action"]); nf = int(act["uo_frames"]) + rig.animation_data.action = act; rig["uo_direction"] = 0 + loc = dict(STIFF=STIFF, DAMP=DAMP, FRAME_TIME=FRAME_TIME, ALIGN_FWD=ALIGN_FWD, LEAN_OUT=LEAN_OUT, LEAN_BACK=LEAN_BACK, GRIP_TWIST=GRIP_TWIST) + for part_ in ACTION_SET.split(";"): + if ":" in part_ and int(part_.split(":")[0]) == a: + for kv in part_.split(":")[1].split(","): + k_, v_ = kv.split("="); loc[k_.strip()] = float(v_) + names = list(dict.fromkeys([HOLD, BONE, "forearm.R", "spine"] + [c[0] for c in caps])) + keys = [] + nums = lambda s: {int(x) for x in s.split(",") if x.strip()} + use_w = HAND_SMOOTH > 0 and a in nums(WEAPON_BONE_ACTIONS) + mode = "align" if a in nums(ALIGN_ACTIONS) else "up" if a in nums(UP_ACTIONS) else "weapon" if use_w else "hand" + hd = (nodes[1] - nodes[0]) / np.linalg.norm(nodes[1] - nodes[0]) # the haft's direction at rest (grip -> chain) + fy0 = np.array(rig.data.bones["forearm.R"].matrix_local)[:3, 1] + ha = np.array(rig.data.bones[HOLD].matrix_local)[:3, 1]; ha /= np.linalg.norm(ha) + Tw = np.eye(4) + if loc["GRIP_TWIST"]: # the haft turned about the hand's axis through the grip (rest space) + Rt = np.array(Matrix.Rotation(np.radians(loc["GRIP_TWIST"]), 3, Vector(ha.tolist()))) + Tw[:3, :3] = Rt; Tw[:3, 3] = GRIP[0] - Rt @ GRIP[0] + for i in range(nf): + sc.frame_set(1 + i * STEP); k = pose_mats(rig, names) + Mh = k[HOLD]; g_ = Mh[:3, :3] @ GRIP[0] + Mh[:3, 3] # where the hand holds the grip + if mode == "weapon": # the turn of weapon1h.R, the grip where the hand holds it (the bone also moves away from the hand: + R = k[BONE][:3, :3] # in the mounted 1H attack the haft left the hand) + elif mode in ("align", "up"): + dh = Mh[:3, :3] @ hd + to = k["forearm.R"][:3, :3] @ fy0 if mode == "align" else np.array([0.0, 0.0, 1.0]) + if loc["ALIGN_FWD"]: # tipped towards the front of the body (where the enemy is; eat: off the torso) + fw = -(k["spine"][:3, :3] @ np.array([0.0, 1.0, 0.0])); fw[2] = 0; fw /= max(np.linalg.norm(fw), 1e-9) + to = to / np.linalg.norm(to) + loc["ALIGN_FWD"] * fw + to = to / np.linalg.norm(to) + np.array([-loc["LEAN_OUT"], loc["LEAN_BACK"], 0.0]) # the rig faces -Y, its right is -X + R = np.array(Vector(dh.tolist()).rotation_difference(Vector((to / np.linalg.norm(to)).tolist())).to_matrix()) @ Mh[:3, :3] + else: + R = (Mh @ Tw)[:3, :3] + M = np.eye(4); M[:3, :3] = R; M[:3, 3] = g_ - R @ GRIP[0]; k[HOLD] = M + keys.append(k) + loop = a in LOOP and nf > 1 + if HAND_SMOOTH > 0 and nf > 2: # the hand's turn smoothed over the frames, about the grip + grip = GRIP[0] + Q = [Matrix(k[HOLD][:3, :3].tolist()).to_quaternion() for k in keys] + for j in range(1, nf): + if Q[j].dot(Q[j - 1]) < 0: + Q[j] = -Q[j] + Qv = np.array([[q.w, q.x, q.y, q.z] for q in Q]) + for _ in range(HAND_SMOOTH): + prv = np.roll(Qv, 1, 0); nxt = np.roll(Qv, -1, 0) + if not loop: + prv[0] = Qv[0]; nxt[-1] = Qv[-1] + prv *= np.sign((prv * Qv).sum(1))[:, None]; nxt *= np.sign((nxt * Qv).sum(1))[:, None] + Qv = 0.25 * prv + 0.5 * Qv + 0.25 * nxt; Qv /= np.linalg.norm(Qv, axis=1)[:, None] + for j in range(nf): + M = keys[j][HOLD]; R = np.array(Quaternion(Qv[j]).to_matrix()); g = M[:3, :3] @ grip + M[:3, 3] + Ms = np.eye(4); Ms[:3, :3] = R; Ms[:3, 3] = g - R @ grip; keys[j] = dict(keys[j]); keys[j][HOLD] = Ms + n = len(nodes) + w = np.ones(n); w[0] = 0.0; w[-1] = 1.0 / MASS # inverse masses: node 0 is carried by the haft + rest = np.linalg.norm(np.diff(nodes, axis=0), axis=1) + rad = np.full(n, 0.01); rad[-1] = BALL_GAP or br * 0.6 + sub = STEP * SUB; dt = loc["FRAME_TIME"] / sub; g = np.array([0, 0, -GRAVITY]); keep = np.exp(-loc["DAMP"] * dt) + kb = 1 - np.exp(-loc["STIFF"] * dt) + + def step(M, X, Xp, mats): + Xn = X + (X - Xp) * keep + g * dt * dt * (w > 0)[:, None] + Xn[0] = M[:3, :3] @ nodes[0] + M[:3, 3] + hit = np.zeros_like(Xn) + for _ in range(ITER): + for k in range(n - 1): + dv = Xn[k + 1] - Xn[k]; dist = np.linalg.norm(dv); ws = w[k] + w[k + 1] + if dist < 1e-9 or ws == 0: + continue + corr = (dist - rest[k]) / dist / ws * dv + Xn[k] += w[k] * corr; Xn[k + 1] -= w[k + 1] * corr + before = Xn[1:].copy(); Xn[1:] = seg_push(Xn[1:], caps, mats, rad[1:] + GAP); hit[1:] += Xn[1:] - before + if kb > 0: # bending: each piece pulled towards the line of the one before (the first: the haft) + prev = M[:3, :3] @ hd + for k in range(n - 1): + tgt = Xn[k] + prev * rest[k] + Xn[k + 1] += kb * (tgt - Xn[k + 1]) + dv = Xn[k + 1] - Xn[k]; Xn[k + 1] = Xn[k] + dv / max(np.linalg.norm(dv), 1e-9) * rest[k] + prev = (Xn[k + 1] - Xn[k]) / rest[k] + if INELASTIC: # a push out of the body takes the speed into the body away instead of adding a bounce + return Xn, X + hit + return Xn, X + + M0 = keys[0][HOLD]; X = (M0[:3, :3] @ nodes.T).T + M0[:3, 3]; Xp = X.copy() + if not loop: # settle: hanging still in the first pose + for _ in range(int(PREROLL / dt)): + X, Xp = step(M0, X, Xp, keys[0]) + out = {} + if a in nums(HANG_ACTIONS): # static: the chain hangs straight down from the end of the haft, the ball beside the haft outside + sp0 = np.array(rig.data.bones["spine"].head_local) + for i in range(nf): + M = keys[i][HOLD]; X = np.zeros_like(nodes); X[0] = M[:3, :3] @ nodes[0] + M[:3, 3] + sp = keys[i]["spine"][:3, :3] @ sp0 + keys[i]["spine"][:3, 3] + side = (M[:3, :3] @ GRIP[0] + M[:3, 3]) - sp; side[2] = 0; side /= max(np.linalg.norm(side), 1e-9) + for j in range(n - 1): + dv = side + np.array([0, 0, -0.35]) if j == 0 else np.array([0, 0, -1.0]) + X[j + 1] = X[j] + dv / np.linalg.norm(dv) * rest[j] + out["%d_%d" % (a, i)] = X; out["%d_%d_M" % (a, i)] = M + return out + rig_set = set(range(35)) if RIGID == "all" else {int(x) for x in RIGID.split(",") if x.strip()} + if a in rig_set: # gathered: settled in the first pose, then rigid on the hand + for _ in range(int(PREROLL / dt)): + X, Xp = step(M0, X, Xp, keys[0]) + L = np.linalg.inv(M0); Xl = (L[:3, :3] @ X.T).T + L[:3, 3] + for i in range(nf): + M = keys[i][HOLD]; out["%d_%d" % (a, i)] = (M[:3, :3] @ Xl.T).T + M[:3, 3] + if HAND_SMOOTH > 0: + out["%d_%d_M" % (a, i)] = M + return out + cycles = CYCLES if loop else 1 + for c in range(cycles): + for i in range(nf): + if c == cycles - 1: + out["%d_%d" % (a, i)] = X.copy() + if HAND_SMOOTH > 0: + out["%d_%d_M" % (a, i)] = keys[i][HOLD] + if i == nf - 1 and not loop: + break + j = (i + 1) % nf + for s_ in range(sub): + M = interp(keys[i][HOLD], keys[j][HOLD], s_ / sub) + X, Xp = step(M, X, Xp, keys[i] if s_ < sub / 2 else keys[j]) + return out + + +def main(src, dst, npz, only, sets): + g = globals() + for k, v in sets: + g[k] = type(g[k])(v) if not isinstance(g[k], (tuple, set)) else g[k] + bpy.ops.wm.open_mainfile(filepath=src) + rig = bpy.data.objects["UO_Rig"]; body = bpy.data.objects["UO_Body"] + rig.data.pose_position = "REST"; bpy.context.view_layer.update() + ob = next(o for o in bpy.data.collections["Clothing"].all_objects if o.type == "MESH" and not o.hide_render) + if "uo_flail" in ob and dst is None: + prm = json.loads(ob["uo_flail"]); nodes = np.array(prm["nodes"]); br = prm["ball_r"]; GRIP[:] = [np.array(prm["grip"])] + else: + nodes, br = prepare(ob, rig) + caps = capsules(rig, body) + rig.data.pose_position = "POSE" + acts = [x for x in bpy.data.actions if x.get("uo_action") is not None] + if only and only != ["all"]: + acts = [x for x in acts if x.name in only] + table = {"rest": nodes} + for act in acts: + table.update(simulate(rig, act, nodes, br, caps)) + rig.data.pose_position = "REST" + if dst: + bpy.ops.wm.save_as_mainfile(filepath=dst) + print("uo_flail: saved", dst) + np.savez_compressed(npz, **table) + print("uo_flail: wrote %s (%d frames, %d actions)" % (npz, len(table) - 1, len(acts))) + + +if __name__ == "__main__": + args = sys.argv[1:]; sets = []; pos = []; npz = None; only = None; i = 0 + while i < len(args): + if args[i] == "--set": + sets.append(tuple(args[i + 1].split("=", 1))); i += 2 + elif args[i] == "--out": + npz = args[i + 1]; i += 2 + elif args[i] == "--actions": + only = args[i + 1].split(","); i += 2 + elif args[i] == "--sim-only": + pos.append(None); i += 1 + else: + pos.append(args[i]); i += 1 + src = pos[0]; dst = pos[1] if len(pos) > 1 else src + main(src, dst, npz or os.path.join(os.path.dirname(os.path.abspath(dst or src)), "flail_sim.npz"), only, sets) + sys.stdout.flush() diff --git a/third_party/UO_Model3D_v13/pipeline/uo_grey_metal.py b/third_party/UO_Model3D_v13/pipeline/uo_grey_metal.py new file mode 100644 index 0000000..0dfb709 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_grey_metal.py @@ -0,0 +1,45 @@ +"""The metal of an item's animation frames made exactly grey (R = G = B in 15 bits), so that a dye with the tiledata flag PartialHue (0x00040000) takes all of it. + + python uo_grey_metal.py IN_DIR OUT_DIR [--chroma 0.25] + +IN_DIR: a frame folder of render_uo_layer.py / vdtool.py extract (meta.json + frames//dir/NN.png); OUT_DIR gets a copy with every pixel whose +chroma (max - min of RGB) is <= CHROMA x its luma set to that luma (a grey of the same level), the rest (wood, coloured parts) unchanged; pack it with +vdtool.py pack OUT_DIR OUT.vd. The client dyes only the pixels with R = G = B in 15 bits when PartialHue is set: a metal pixel with a slight tint stays undyed (a speck; +the client's mace has 6% such pixels). Flail v9: 26% of the metal pixels had a tint up to 8/255. +""" +import os, shutil, sys +import numpy as np +from PIL import Image + + +def grey_metal(a, k): + """RGBA uint8 -> the same with the metal (chroma <= k x luma) as the grey of its luma, quantised to 15 bits; returns (image, changed pixels)""" + q = a[..., :3].astype(np.int32); m = a[..., 3] > 0 + y = 0.299 * q[..., 0] + 0.587 * q[..., 1] + 0.114 * q[..., 2] + met = m & ((q.max(-1) - q.min(-1)) <= k * np.maximum(y, 1.0)) + g = (np.clip(np.round(y), 8, 255).astype(np.int32) >> 3) << 3 + out = a.copy() + for c in range(3): + out[..., c] = np.where(met, g, a[..., c]) + changed = int((met & np.any(out[..., :3] != a[..., :3], -1)).sum()) + return out, changed + + +def main(src, dst, k): + if os.path.exists(dst): + raise SystemExit("uo_grey_metal: %s exists" % dst) + shutil.copytree(src, dst) + n = ch = 0 + for root, _, files in os.walk(os.path.join(dst, "frames")): + for f in files: + if f.endswith(".png"): + p = os.path.join(root, f); a = np.array(Image.open(p).convert("RGBA")) + b, c = grey_metal(a, k); Image.fromarray(b).save(p); n += 1; ch += c + print("uo_grey_metal: %d frames, %d pixels made grey -> %s" % (n, ch, dst)) + + +if __name__ == "__main__": + a = sys.argv[1:]; k = 0.25 + if "--chroma" in a: + i = a.index("--chroma"); k = float(a[i + 1]); del a[i:i + 2] + main(a[0], a[1], k) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_hood_cover.py b/third_party/UO_Model3D_v13/pipeline/uo_hood_cover.py new file mode 100644 index 0000000..e47121d --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_hood_cover.py @@ -0,0 +1,450 @@ +"""Put a hood on the head: brought down onto the skull, out of the shoulders, pushed out of the head skin it does not cover (the ears), radially from the head centre, symmetrically, smoothly. + + python uo_hood_cover.py ITEM.blend [OUT.blend] [--set NAME=VALUE ...] # e.g. --set GAP=0.015 --set SIGMA=18 + +ITEM.blend is the output of uo_make_item.py with "kind": "hood" (uo_make_item.py runs this script for a hood, recipe "hood_cover": false = off, {"GAP": 0.015} = settings). +Why: the hood of a free model sits right after uo_autofit_item.py, but the ears of the UO head stand out of its sides (Meshy hood: 52 head skin vertices outside +the hood, both ears). The push of uo_fit_item.py (out along the skin normal, vertex by vertex) bent the sides of the hood unevenly +(mirror p90 2.6 -> 19 mm) and is off for a hood (uo_prepare_item.py FIT None). Here the hood is seen from the head centre C (head bone + 8 cm up): + 1. the head skin behind the face (y > C - FACE_Y; the face opening stays as the model made it) and its mirror image (so both sides get the same push); + 2. each hood vertex whose direction from C is within CONE of a skin point needs to be at least GAP farther from C than that skin point; + 3. the need spreads to the neighbouring directions (gaussian, SIGMA) and over the mesh (SMOOTH passes, never below the vertex's own need): the whole wall of the + hood in that direction moves out together (no lumps, the layers of a thick model stay in order); + 4. each vertex takes the larger push of itself and its mirror twin; shape keys move with the mesh, the weights stay. +Before that (TOP_GAP > 0) the whole hood comes down until the inside of its top, straight above C, is TOP_GAP over the skull (Meshy hood after the autofit: +118 mm, it looked too high and its cowl did not lie on the shoulders; user, 2026-10-12), rigidly; the drop is corrected until the top lands at TOP_GAP after the +pushes below (they lift the top a little). After the ears, the hood (the cowl now in the shoulders and chest) is pushed out of the whole body to BODY_GAP along +the skin normal (BODY_ROUNDS rounds; the moves form a smooth field in space, FIELD_SIGMA, mirrored): the cowl lies on the shoulders. Rejected: a per-vertex +vertical drop stopping on the body and a per-vertex push smoothed over the mesh (both crumpled the cloth: the inner and outer layer moved apart). Then the hood is bound again (uo_bind_item.py, PART hood): its weights were taken where it stood before; and from the chin up (HEAD_RIGID above the head bone) +it follows the head only, blended into the skin weights over HEAD_BLEND below: with skin weights the rim round the chin and cheeks (head weight 0.64 on average at +z 1.52-1.58 m, some 0) stayed with the neck when the head tilted, the opening slid over the face (user, 2026-10-12). +Check: the closeups of the head and item_body_holes.py (the layer on the original body). +""" +import sys +import numpy as np +import bpy +from scipy.spatial import cKDTree + +GAP = 0.012 # m: the hood at least this far out of the head skin (radially from C) +CONE = 4.0 # deg: a hood vertex and a skin point in the same direction from C +SIGMA = 14.0 # deg: spread of the push over directions (7 left lumps on the side of the hood) +SMOOTH = 30 # passes of smoothing of the push over the mesh +FACE_Y = 0.02 # m: skin in front of C - FACE_Y is the face (left open) +BROW_Z = 0.06 # m above C: front skin higher than this (the forehead) that the hood covers (a ray from C through it hits the hood) is pushed out too: the pointed brow + # of an Assassin-style hood went into the forehead and the skin showed through it as a strip (2026-10-12); 0 = off (the whole front is the face) +CENTRE_UP = 0.08 # m: C above the head bone head +GROUP_MIN = 0.3 # head skin: the weight of the head bone at least this +TOP_GAP = 0.012 # m: the whole hood moved down until the inside of its top is this far above the skull (0 = leave it where the autofit put it) +BODY_GAP = 0.008 # m: the hood at least this far out of the body skin (cowl on the shoulders), along the skin normal; 0 = off +FIELD_SIGMA = 0.03 # m: the push out of the body is a smooth field in space (gaussian): the inner and outer layer of the cloth move together, nothing crumples +BODY_ROUNDS = 20 # rounds of that push +REBIND = True # bind again after the moves (uo_bind_item.py) +BIND_SMOOTH = 12 # uo_bind_item.py SMOOTH (passes of smoothing of the weights over the hood) +BIND_ARM = -1.0 # share of the shoulder (upper arm) motion the cowl takes (uo_bind_item.py PARTS "hood" FOLLOW upper_arm); -1 = as in uo_bind_item.py, 0 = the cowl on the chest only +TILT_BACK = 0.0 # deg: the hood is first turned back about the x axis through C (the brim goes up and back, the face is framed, not shaded) +SYMMETRIZE = 4 # passes: the hood is first turned and shifted onto its own best mirror plane (yaw, roll, x), which is put on the body's midplane, then each vertex goes + # half way to the nearest point of the mirrored surface (same side of the cloth, at most SYM_MAX away). A free AI model is not symmetric: the Assassin-style + # hood was turned 3 deg and its mirror p90 was 21.6 mm, it looked crooked (user, 2026-10-12). 0 = off +SYM_MAX = 0.03 # m +SYM_SIGMA = 0.02 # m: the symmetrizing moves form a smooth field in space (gaussian); a per-vertex snap tore the thin brim (layers matched across) +TILT_FADE = 0.0 # m: > 0 = the turn is full above the head bone head and fades to none over this height below it (smoothstep): the hood turns, the cowl stays on the + # shoulders as the model made it (a whole-hood turn of 25 deg lifted the front flaps of the cowl off the chest, Assassin-style hood, 2026-10-12) +SHORTEN = 1.0 # < 1: a shorter cowl: what hangs below SHORTEN_Z (the top of the shoulders) is squeezed up towards it, heights below it x SHORTEN, so the hem keeps its shape + # and only just reaches over the shoulders (user, 2026-10-12: the Assassin hood's cowl reached mid-chest, z 1.22 m); then the push out of the body puts it back on the + # skin. Drawing it in towards the neck instead left the front long and wrapped the cowl tight round the chest like a vest. 1 = off +SHORTEN_Z = 1.55 +FILL_NECK = 0.006 # m: the V of the cowl under the chin (neck, upper chest seen through it: a second opening under the face) is closed by an inner panel of the hood's + # cloth this far off the skin: a copy of the skin seen through it (from the front and from above), widened by FILL_RINGS rings so its edges tuck under the cowl; 0 = off +FILL_RINGS = 4 +FILL_ZMIN = 1.25 # m: the panel does not go below this +INNER_SHADE = 0.0 # 0..1: > 0 = the inside of the hood (cloth facing the head centre) and the neck panel get this share of their colour (attribute uo_shade x albedo): + # the UO light comes almost from the camera and has no occlusion, so the inside of a hood was as light as its outside, a flat grey blob round the face + # (user: "no depth", 2026-10-12). 0 = off +INNER_DIST = 0.10 # m: cloth whose normal hits the body within this distance is the inside (the hood round the head, the cowl lining over the neck) +INNER_SMOOTH = 3 # passes of smoothing of the darkening over the mesh +OCCLUSION = 0.0 # 0..1: > 0 = also darker where the hood's own folds and layers (and the body) close the view around a point: under the pointed brow, between the layers of the + # cowl, in the folds (OCC_RAYS rays over the half sphere of the normal, hits within OCC_DIST); colour x (1 - OCCLUSION x share of rays hit). Asked for by the user + # for this item ("give the hood shading, depth", 2026-10-12); the UO light itself stays as measured (no AO in the renderer). 0 = off +SHADE_MIN = 0.35 # the darkening (inside x occlusion) never takes the colour below this share: deep shade, never black (user, 2026-10-12: "no true black") +OCC_RAYS = 24 +OCC_DIST = 0.06 # m +HEAD_RIGID = 0.0 # m above the head bone head: from here up the hood follows the head only (the face opening, chin to crown, keeps to the face) +HEAD_BLEND = 0.07 # m: below that, over this height, the head weight fades into the skin weights (neck, chest) of the cowl +HEAD_NEAR = 0.03 # m: > 0 = the head weight also fades where the hood is nearer the skin of the shoulders / chest than that of the head (over this difference of distance): + # the lower rim of a cowl round the neck and shoulders (z 1.48-1.55 m, 40-70 % on the head by height alone) went into the shoulders when the head moved + # (attack, spell, run: 1300-3800 hood vertices > 5 mm in the body, Assassin-style hood, 2026-10-12); the rim round the chin stays on the head. 0 = by height only + + +def symmetrize(VI, F, c, name): + """onto the best mirror plane, then symmetric (see SYMMETRIZE)""" + from mathutils import Vector + from mathutils.bvhtree import BVHTree + sub = VI[::max(1, len(VI) // 6000)] + + def tr(V, yaw, roll, dx): + a, b = np.radians(yaw), np.radians(roll) + Rz = np.array([[np.cos(a), -np.sin(a), 0], [np.sin(a), np.cos(a), 0], [0, 0, 1]]) + Ry = np.array([[np.cos(b), 0, np.sin(b)], [0, 1, 0], [-np.sin(b), 0, np.cos(b)]]) + return (V - c) @ (Ry @ Rz).T + c + [dx, 0, 0] + + def score(p): + U = tr(sub, *p); return np.percentile(cKDTree(U * [-1, 1, 1]).query(U)[0], 90) + s0 = score((0.0, 0.0, 0.0)); best = ((0.0, 0.0, 0.0), s0) + for step in (2.0, 1.0, 0.5, 0.25): # coordinate search (deg, deg, m) + moved = True + while moved: + moved = False + for k, dlt in ((0, step), (1, step), (2, step / 200)): + for sg in (-1, 1): + q = list(best[0]); q[k] += sg * dlt; s = score(tuple(q)) + if s < best[1] - 1e-5: + best = (tuple(q), s); moved = True + VI = tr(VI, *best[0]) + print("uo_hood_cover: %s: onto its mirror plane: yaw %.2f deg, roll %.2f deg, x %+.1f mm (mirror p90 %.1f -> %.1f mm)" + % (name, best[0][0], best[0][1], best[0][2] * 1e3, s0 * 1e3, best[1] * 1e3)) + tri = np.array([(f[0], f[k], f[k + 1]) for f in F for k in range(1, len(f) - 1)]) + for _ in range(SYMMETRIZE): + Mv = VI * [-1, 1, 1] + bh = BVHTree.FromPolygons([tuple(v) for v in Mv], [tuple(f[::-1]) for f in tri]) # mirrored: reversed winding keeps the normals outward + fn = np.cross(VI[tri[:, 1]] - VI[tri[:, 0]], VI[tri[:, 2]] - VI[tri[:, 0]]) + vn = np.zeros_like(VI) + for k in range(3): + np.add.at(vn, tri[:, k], fn) + vn /= np.maximum(np.linalg.norm(vn, axis=1, keepdims=True), 1e-12) + D = np.zeros_like(VI); ok = np.zeros(len(VI), bool) + for i, p in enumerate(VI): + loc, nor, _, d = bh.find_nearest(Vector(p), SYM_MAX) + if loc is not None and np.dot(np.array(nor), vn[i]) > 0.5: + D[i] = 0.5 * (np.array(loc) - p); ok[i] = True + n_ok = int(ok.sum()) + P = VI[ok]; Dk = D[ok]; tp = cKDTree(P); Ds = np.zeros_like(VI) # a smooth field in space: both layers of the cloth and the thin brim move together + for i, nb in enumerate(tp.query_ball_point(VI, 3 * SYM_SIGMA)): + if nb: + wgt = np.exp(-0.5 * (np.linalg.norm(P[nb] - VI[i], axis=1) / SYM_SIGMA) ** 2) + Ds[i] = (wgt[:, None] * Dk[nb]).sum(0) / wgt.sum() + VI = VI + Ds + d = cKDTree(VI * [-1, 1, 1]).query(VI)[0] + print("uo_hood_cover: %s: symmetrized (%d passes, %d of %d vertices), mirror p90 %.1f mm" % (name, SYMMETRIZE, n_ok, len(VI), np.percentile(d, 90) * 1e3)) + return VI + + +BVHTree_body = [] +BVH_BODY_V, BVH_BODY_F = [], [] + + +def occlusion_share(P, Nn, faces, E, body_V, body_F, rays=24, dist=0.06): + """per vertex the share of `rays` cosine-weighted rays over the half sphere of its normal Nn that hit the item (faces over P) or the body (body_F over body_V) + within `dist` m: high in folds, between layers, under a sleeve; smoothed twice over the edges E""" + from mathutils import Vector + from mathutils.bvhtree import BVHTree as _B + P = np.asarray(P, float); Nn = np.asarray(Nn, float) + both = _B.FromPolygons([tuple(p) for p in P] + [tuple(v) for v in body_V], [tuple(f) for f in faces] + [tuple(i + len(P) for i in f_) for f_ in body_F]) + rng = np.random.default_rng(0); u = rng.random((rays, 2)) + loc = np.c_[np.sqrt(u[:, 0]) * np.cos(2 * np.pi * u[:, 1]), np.sqrt(u[:, 0]) * np.sin(2 * np.pi * u[:, 1]), np.sqrt(1 - u[:, 0])] # cosine-weighted + occ = np.zeros(len(P)) + for i, (p, n) in enumerate(zip(P, Nn)): + a = np.array([1.0, 0, 0]) if abs(n[0]) < 0.9 else np.array([0, 1.0, 0]) + t1 = np.cross(n, a); t1 /= np.linalg.norm(t1); t2 = np.cross(n, t1) + D = loc[:, :1] * t1 + loc[:, 1:2] * t2 + loc[:, 2:] * n + o = Vector(p + 0.002 * n) + occ[i] = sum(both.ray_cast(o, Vector(d), dist)[0] is not None for d in D) / rays + E = np.asarray(E); cnt = np.maximum(np.bincount(E.ravel(), minlength=len(P)), 1) + for _ in range(2): + occ = 0.5 * occ + 0.5 * (np.bincount(E[:, 0], occ[E[:, 1]], len(P)) + np.bincount(E[:, 1], occ[E[:, 0]], len(P))) / cnt + return occ + + +def apply_shade(me, f): + """attribute uo_shade (per vertex, 0..1) on the mesh `me` and the albedo of its UO_Look materials multiplied by it""" + a = me.attributes.get("uo_shade") or me.attributes.new("uo_shade", "FLOAT", "POINT") + a.data.foreach_set("value", np.asarray(f, np.float32)) + for m in me.materials: + nt = m.node_tree if m else None + grp = next((n for n in nt.nodes if n.type == "GROUP" and n.node_tree and n.node_tree.name.startswith("UO_Look")), None) if nt else None + if grp is None or any(n.name == "uo_shade" for n in nt.nodes): + continue + at = nt.nodes.new("ShaderNodeAttribute"); at.name = "uo_shade"; at.attribute_type = "GEOMETRY"; at.attribute_name = "uo_shade" + sc_ = nt.nodes.new("ShaderNodeVectorMath"); sc_.operation = "MULTIPLY" + at.location = (grp.location.x - 420, grp.location.y - 300); sc_.location = (grp.location.x - 200, grp.location.y - 300) + if grp.inputs["Albedo"].links: + nt.links.new(grp.inputs["Albedo"].links[0].from_socket, sc_.inputs[0]) + else: + sc_.inputs[0].default_value = grp.inputs["Albedo"].default_value[:3] + nt.links.new(at.outputs["Fac"], sc_.inputs[1]); nt.links.new(sc_.outputs[0], grp.inputs["Albedo"]) + + +def inner_shade(it, c, n_panel): + """uo_shade per vertex (1 outside, INNER_SHADE inside the hood and on the neck panel) and the albedo of the hood's materials scaled by it""" + me = it.data; Mw = it.matrix_world; R = Mw.to_3x3() + P = np.array([Mw @ v.co for v in me.vertices]); Nn = np.array([(R @ v.normal).normalized() for v in me.vertices]) + from mathutils import Vector + bh = BVHTree_body[0] + hit = np.array([bh.ray_cast(Vector(p + 0.002 * n), Vector(n), INNER_DIST)[0] is not None for p, n in zip(P, Nn)], float) # cloth that faces the head / neck (the inside) + E = np.array([e.vertices[:] for e in me.edges]); cnt = np.maximum(np.bincount(E.ravel(), minlength=len(P)), 1) + x = hit + for _ in range(INNER_SMOOTH): # a soft edge round the opening + x = 0.5 * x + 0.5 * (np.bincount(E[:, 0], x[E[:, 1]], len(P)) + np.bincount(E[:, 1], x[E[:, 0]], len(P))) / cnt + f = 1 - (1 - INNER_SHADE) * x + if OCCLUSION > 0: + occ = occlusion_share(P, Nn, [tuple(p.vertices) for p in me.polygons], E, BVH_BODY_V[0], BVH_BODY_F[0], OCC_RAYS, OCC_DIST) + f = f * (1 - OCCLUSION * occ) + print("uo_hood_cover: %s: occlusion x%.2f: share of rays hit p50 %.2f p90 %.2f" % (it.name, OCCLUSION, np.percentile(occ, 50), np.percentile(occ, 90))) + f[n_panel:] = np.minimum(f[n_panel:], INNER_SHADE) + f = np.maximum(f, SHADE_MIN) + apply_shade(me, f) + print("uo_hood_cover: %s: inside shaded x%.2f (%d of %d vertices darker than 0.9, %d panel vertices)" % (it.name, INNER_SHADE, int((f < 0.9).sum()), len(f), len(f) - n_panel)) + + +def ray_up(V, F, c): + from mathutils import Vector + from mathutils.bvhtree import BVHTree + h = BVHTree.FromPolygons([tuple(v) for v in V], F).ray_cast(Vector(c), Vector((0, 0, 1)), 2.0) + return h[3] if h[0] is not None else None + + +def body_push(VI, it, bvh): + """the hood out of the whole body to BODY_GAP along the skin normal; the moves of the vertices that are too close make a smooth field in space (FIELD_SIGMA), + mirrored (symmetric), applied to every vertex: both layers of the cloth move together. A per-vertex push smoothed over the mesh crumpled the cowl.""" + from mathutils import Vector + n = len(VI) + for rnd in range(BODY_ROUNDS): + need = np.zeros(n); N = np.zeros((n, 3)) + for i, p in enumerate(VI): + loc, nor, _, d = bvh.find_nearest(Vector(p), 0.2) + if loc is None: + continue + s = float(np.dot(p - np.array(loc), np.array(nor))) + if s < BODY_GAP: + need[i] = BODY_GAP - s; N[i] = nor + k = np.nonzero(need > 1e-4)[0] + if not len(k): + break + P = np.vstack([VI[k], VI[k] * [-1, 1, 1]]); Dk = need[k, None] * N[k]; Dk = np.vstack([Dk, Dk * [-1, 1, 1]]) + tp = cKDTree(P); D = np.zeros((n, 3)); W = np.zeros(n) + for i, nb in enumerate(tp.query_ball_point(VI, 3 * FIELD_SIGMA)): + if nb: + wgt = np.exp(-0.5 * (np.linalg.norm(P[nb] - VI[i], axis=1) / FIELD_SIGMA) ** 2) + W[i] = wgt.sum(); D[i] = (wgt[:, None] * Dk[nb]).sum(0) + D = D / np.maximum(W, 1.0)[:, None] * 1.2 # far from the push it fades (weight < 1); a little over, the rounds converge faster + VI = VI + D + print("uo_hood_cover: %s: body push round %d: %d vertices closer than %.0f mm to the body (at most %.1f mm in), moved at most %.1f mm" + % (it.name, rnd + 1, len(k), BODY_GAP * 1e3, need.max() * 1e3, np.linalg.norm(D, axis=1).max() * 1e3)) + return VI + + +def fill_neck(it, body, m, VB, whead, c): + """an inner panel over the skin of the neck / upper chest that is seen through the front of the hood (the V of the cowl)""" + import bmesh + from mathutils import Vector + from mathutils.bvhtree import BVHTree + dg = bpy.context.evaluated_depsgraph_get() + mi = it.evaluated_get(dg).to_mesh(); Mi = it.matrix_world + bh = BVHTree.FromPolygons([Mi @ v.co for v in mi.vertices], [tuple(p.vertices) for p in mi.polygons]) + NB = np.array([(body.matrix_world.to_3x3() @ v.normal).normalized() for v in m.vertices]) + VH = np.array([Mi @ v.co for v in mi.vertices]) + front = VH[(np.abs(VH[:, 0]) < 0.03) & (VH[:, 1] < c[1] - 0.05) & (VH[:, 2] < c[2])] + zmin = max(FILL_ZMIN, float(front[:, 2].min())) if len(front) else FILL_ZMIN # no lower than where the cowl's fronts meet (a short cowl: the bare chest below is not a hole) + cand = (whead < 0.5) & (VB[:, 1] < c[1] + 0.02) & (VB[:, 2] > zmin) & (VB[:, 2] < c[2]) & (np.abs(VB[:, 0]) < 0.15) & (NB[:, 1] < -0.2) + seen = np.zeros(len(VB), bool) + for i in np.nonzero(cand)[0]: + for d in ((0, -1, 0), (0, -0.866, 0.5)): + if bh.ray_cast(Vector(VB[i] + NB[i] * 0.004), Vector(d), 1.0)[0] is None: + seen[i] = True; break + if not seen.any(): + print("uo_hood_cover: %s: no skin seen through the front of the hood" % it.name); return + m.calc_loop_triangles() + tri = np.array([t.vertices[:] for t in m.loop_triangles]) + E = np.vstack([tri[:, [0, 1]], tri[:, [1, 2]], tri[:, [2, 0]]]) + sel = seen.copy() + for _ in range(FILL_RINGS): # widen over the skin (not onto the head) + grow = np.zeros(len(VB), bool); grow[E[sel[E[:, 0]], 1]] = True; grow[E[sel[E[:, 1]], 0]] = True + sel |= grow & (whead < 0.5) & (VB[:, 2] > zmin - 0.03) + T = tri[sel[tri].all(1)] + used = np.unique(T); idx = {int(v): k for k, v in enumerate(used)} + P = VB[used] + NB[used] * FILL_NECK + bm = bmesh.new(); bm.from_mesh(it.data) + Minv = np.array(Mi.inverted()) + nv = [bm.verts.new(tuple((Minv @ np.append(p, 1.0))[:3])) for p in P] + bm.verts.ensure_lookup_table() + for a, b, cc in T: + try: + f = bm.faces.new((nv[idx[int(a)]], nv[idx[int(b)]], nv[idx[int(cc)]])); f.material_index = 0; f.smooth = True + except ValueError: + pass + bm.to_mesh(it.data); bm.free(); it.data.update() + print("uo_hood_cover: %s: neck panel from z %.3f m, %d skin vertices seen through the hood, %d faces, %.0f mm off the skin" % (it.name, zmin, int(seen.sum()), len(T), FILL_NECK * 1e3)) + + +def forehead(VI, F, c, SF): + """the front skin over the brows that the hood covers: a ray from C through it hits the hood (inside or outside the skin)""" + from mathutils import Vector + from mathutils.bvhtree import BVHTree + if BROW_Z <= 0 or not len(SF): + return SF[:0] + bh = BVHTree.FromPolygons([tuple(v) for v in VI], F) + hit = np.array([bh.ray_cast(Vector(c), Vector(p - c), 0.4)[0] is not None for p in SF]) + out = SF[hit] + return np.vstack([out, out * [-1, 1, 1]]) + + +def cover(VI, it, c, ts, rs, cone, sig, bvh, F): + """the hood out of the head skin behind the face (radially from c, symmetric, smooth), then out of the whole body (body_push)""" + if len(SF := forehead(VI, F, c, SFRONT)): + S2 = np.vstack([SBACK, SF]); ds = S2 - c; rs = np.linalg.norm(ds, axis=1); ts = cKDTree(ds / rs[:, None]) + print("uo_hood_cover: %s: %d forehead skin points under the hood pushed out too" % (it.name, len(SF) // 2)) + di = VI - c; ri = np.linalg.norm(di, axis=1); ui = di / np.maximum(ri, 1e-9)[:, None] + need = np.zeros(len(VI)) + for i, nb in enumerate(ts.query_ball_point(ui, 2 * np.sin(cone / 2))): + if nb: + need[i] = max(0.0, rs[nb].max() + GAP - ri[i]) + src_i = np.nonzero(need > 0)[0] + push = np.zeros(len(VI)) + if len(src_i): + tn = cKDTree(ui[src_i]) + for i, nb in enumerate(tn.query_ball_point(ui, 2 * np.sin(2.5 * sig / 2))): + if nb: + ang = 2 * np.arcsin(np.clip(np.linalg.norm(ui[src_i[nb]] - ui[i], axis=1) / 2, 0, 1)) + push[i] = (need[src_i[nb]] * np.exp(-0.5 * (ang / sig) ** 2)).max() + E = np.array([e.vertices[:] for e in it.data.edges]) + cnt = np.bincount(E.ravel(), minlength=len(VI)).astype(float) + for _ in range(SMOOTH): + acc = np.bincount(E[:, 0], push[E[:, 1]], len(VI)) + np.bincount(E[:, 1], push[E[:, 0]], len(VI)) + push = np.maximum(0.5 * push + 0.5 * acc / np.maximum(cnt, 1), need) + _, j = cKDTree(VI * [-1, 1, 1]).query(VI) + push = np.maximum(push, push[j]) + VI = VI + ui * push[:, None] + print("uo_hood_cover: %s: %d vertices closer than %.0f mm to the head skin (at most %.1f mm in), %d moved (at most %.1f mm)" + % (it.name, len(src_i), GAP * 1e3, need.max() * 1e3, int((push > 1e-4).sum()), push.max() * 1e3)) + if BODY_GAP > 0: + VI = body_push(VI, it, bvh) + return VI + + +def main(src, dst, sets): + g = globals() + for k, v in sets: + g[k] = type(g[k])(v) + bpy.ops.wm.open_mainfile(filepath=src) + rig = bpy.data.objects["UO_Rig"]; rig.data.pose_position = "REST" + body = bpy.data.objects["UO_Body"] + items = [o for o in bpy.data.collections["Clothing"].all_objects if o.type == "MESH"] + dg = bpy.context.evaluated_depsgraph_get() + m = body.evaluated_get(dg).to_mesh(); M = np.array(body.matrix_world) + VB = np.array([v.co for v in m.vertices]) @ M[:3, :3].T + M[:3, 3] + hg = body.vertex_groups["head"].index + w = np.zeros(len(VB)) + for v in body.data.vertices: + for gr in v.groups: + if gr.group == hg: + w[v.index] = gr.weight + c = np.array(rig.matrix_world @ rig.data.bones["head"].head_local) + np.array([0, 0, CENTRE_UP]) + S = VB[(w > GROUP_MIN) & (VB[:, 1] > c[1] - FACE_Y)] # the front of the body is -y + S = np.vstack([S, S * [-1, 1, 1]]) + global SBACK, SFRONT + SBACK = S; SFRONT = VB[(w > GROUP_MIN) & (VB[:, 1] <= c[1] - FACE_Y) & (VB[:, 2] > c[2] + BROW_Z)] + from mathutils.bvhtree import BVHTree + bvh_body = BVHTree.FromPolygons([tuple(v) for v in VB], [tuple(p.vertices) for p in m.polygons]); BVHTree_body[:] = [bvh_body]; BVH_BODY_V[:] = [VB]; BVH_BODY_F[:] = [[tuple(p.vertices) for p in m.polygons]] + skin_top = ray_up(VB, [tuple(p.vertices) for p in m.polygons], c) + ds = S - c; rs = np.linalg.norm(ds, axis=1); ts = cKDTree(ds / rs[:, None]) + cone, sig = np.radians(CONE), np.radians(SIGMA) + for it in items: + Mi = np.array(it.matrix_world); Vl = np.array([v.co for v in it.data.vertices]); VI = Vl @ Mi[:3, :3].T + Mi[:3, 3] + F = [tuple(p.vertices) for p in it.data.polygons] + if SYMMETRIZE: + VI = symmetrize(VI, F, c, it.name) + top0 = ray_up(VI, F, c) + if TILT_BACK: + zh = (rig.matrix_world @ rig.data.bones["head"].head_local).z + x = np.clip((VI[:, 2] - (zh - TILT_FADE)) / TILT_FADE, 0, 1) if TILT_FADE > 0 else np.ones(len(VI)) + a = np.radians(TILT_BACK) * x * x * (3 - 2 * x) + d = VI - c; ca, sa = np.cos(a), np.sin(a) + VI = c + np.c_[d[:, 0], ca * d[:, 1] + sa * d[:, 2], -sa * d[:, 1] + ca * d[:, 2]] + print("uo_hood_cover: %s: turned back %.0f deg about C" % (it.name, TILT_BACK)) + V0 = VI; _, twin = cKDTree(V0 * [-1, 1, 1]).query(V0) + dz = top0 - (skin_top + TOP_GAP) if (TOP_GAP > 0 and top0 is not None and skin_top is not None) else 0.0 + for attempt in range(3 if dz > 0 else 1): + VI = V0 - [0, 0, dz] + if SHORTEN < 1: # shorter cowl: what hangs below SHORTEN_Z is squeezed up towards it (heights x SHORTEN), the hem keeps its shape + zmin0 = VI[:, 2].min(); below = VI[:, 2] < SHORTEN_Z + VI = VI.copy(); VI[below, 2] = SHORTEN_Z - (SHORTEN_Z - VI[below, 2]) * SHORTEN + print("uo_hood_cover: %s: cowl shortened x%.2f below z %.2f m: lowest point z %.3f -> %.3f m" % (it.name, SHORTEN, SHORTEN_Z, zmin0, VI[:, 2].min())) + VI = cover(VI, it, c, ts, rs, cone, sig, bvh_body, F) + top = ray_up(VI, F, c) + print("uo_hood_cover: %s: inside of the top %.0f mm above the skull (was %.0f)" % (it.name, (top - skin_top) * 1e3, (top0 - skin_top) * 1e3)) + if dz <= 0 or abs(top - skin_top - TOP_GAP) < 0.003: + break + dz += top - skin_top - TOP_GAP + loc = (VI - Mi[:3, 3]) @ np.linalg.inv(Mi[:3, :3]).T + delta = loc - Vl + it.data.vertices.foreach_set("co", loc.astype(np.float32).ravel()) + if it.data.shape_keys: + for k in it.data.shape_keys.key_blocks: + co = np.zeros(len(Vl) * 3, np.float32); k.data.foreach_get("co", co) + k.data.foreach_set("co", (co.reshape(-1, 3) + delta).astype(np.float32).ravel()) + it.data.update() + n_before = len(items[0].data.vertices) + if FILL_NECK > 0: + fill_neck(items[0], body, m, VB, w, c) + if REBIND: + import os, re + here = os.path.dirname(os.path.abspath(__file__)) + os.environ.setdefault("UO_SCRIPTS", here) + text = open(os.path.join(here, "uo_bind_item.py"), encoding="utf-8").read() + text = re.sub(r"^PART\s*=.*$", "PART = 'hood'", text, count=1, flags=re.M) + text = re.sub(r"^SMOOTH\s*=.*$", "SMOOTH = %d" % BIND_SMOOTH, text, count=1, flags=re.M) + if BIND_ARM >= 0: + text = text.replace('{"upper_arm": 0.5}), # hood', '{"upper_arm": %r}), # hood' % BIND_ARM) + for o in bpy.context.view_layer.objects: + o.select_set(o in items) + bpy.context.view_layer.objects.active = items[0] + exec(compile(text, "uo_bind_item.py", "exec"), {"__name__": "__main__"}) + if HEAD_BLEND >= 0: + zh = (rig.matrix_world @ rig.data.bones["head"].head_local).z + HEAD_RIGID + for it in items: + hg = it.vertex_groups.get("head") or it.vertex_groups.new(name="head") + others = [g for g in it.vertex_groups if g.name != "head"] + n = len(it.data.vertices); W = np.zeros((n, len(it.vertex_groups))) + for v in it.data.vertices: + for gr in v.groups: + W[v.index, gr.group] = gr.weight + s = W.sum(1, keepdims=True); W = np.where(s > 0, W / np.maximum(s, 1e-9), 0) + z = np.array([(it.matrix_world @ v.co).z for v in it.data.vertices]) + x = np.clip((z - (zh - HEAD_BLEND)) / max(HEAD_BLEND, 1e-6), 0, 1); tt = x * x * (3 - 2 * x) + if HEAD_NEAR > 0: + from mathutils import Vector as _V + Wb = {g.name: np.zeros(len(VB)) for g in body.vertex_groups} + for v in body.data.vertices: + for gr in v.groups: + Wb[body.vertex_groups[gr.group].name][v.index] = gr.weight + torso = sum(Wb[k] for k in Wb if k.split(".")[0] in ("chest", "spine", "clavicle", "upper_arm")) + th = cKDTree(VB[Wb["head"] > 0.5]); tt_ = cKDTree(VB[torso > 0.5]) + P = np.array([it.matrix_world @ v.co for v in it.data.vertices]) + dh, dt = th.query(P)[0], tt_.query(P)[0] + y = np.clip((dt - dh) / HEAD_NEAR + 0.5, 0, 1); tt = tt * y * y * (3 - 2 * y) + W = W * (1 - tt)[:, None]; W[:, hg.index] += tt + for g in it.vertex_groups: + col = W[:, g.index] + for val in np.unique(np.round(col, 4)): + idx = np.nonzero(np.round(col, 4) == val)[0].tolist() + if val > 0: + g.add(idx, float(val), "REPLACE") + else: + g.remove(idx) + print("uo_hood_cover: %s: rigid on the head above z %.3f m (%d vertices), blended over %.0f mm below" % (it.name, zh, int((tt >= 1).sum()), HEAD_BLEND * 1e3)) + if INNER_SHADE > 0: + inner_shade(items[0], c, n_before) + bpy.context.scene["uo_head_hides"] = 1 # the face hides the inside of the hood (render_uo_layer.py HEAD_HIDES) + bpy.ops.wm.save_as_mainfile(filepath=dst) + print("uo_hood_cover: saved", dst) + + +if __name__ == "__main__": + args = sys.argv[1:]; sets = []; pos = [] + i = 0 + while i < len(args): + if args[i] == "--set": + k, v = args[i + 1].split("=", 1); sets.append((k, v)); i += 2 + else: + pos.append(args[i]); i += 1 + main(pos[0], pos[1] if len(pos) > 1 else pos[0], sets) + sys.stdout.flush() diff --git a/third_party/UO_Model3D_v13/pipeline/uo_hue_preview.py b/third_party/UO_Model3D_v13/pipeline/uo_hue_preview.py new file mode 100644 index 0000000..90cf964 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_hue_preview.py @@ -0,0 +1,82 @@ +"""How images (item art, paperdoll gumps) look dyed with hues of the client's hues.mul, as the client draws them. + + python uo_hue_preview.py HUES.mul OUT.png [--hues 2419,2213,...] [--partial 0|1|both] [--zoom 3] [--crop x0,y0,x1,y1] LABEL=IMG.png ... + +hues.mul: blocks of a 4-byte header + 8 hues; a hue = 32 colours (15-bit, dark to light), table start, table end (uint16), name (20 bytes). Hue number N (as on the +server, 1-based) = block (N - 1) // 8, entry (N - 1) % 8. +The client dyes a pixel by its red channel: colour = hue[red >> 3] (5-bit red = index of the hue's 32 colours). Without the tiledata flag PartialHue (0x00040000) every +pixel is dyed (a brown haft takes the hue's light end, its red is high); with it only the grey pixels (R = G = B). +Rows: the images; columns: undyed, then each hue (with --partial both: every hue twice, full / partial). +""" +import struct, sys +import numpy as np +from PIL import Image, ImageDraw + +ORE = (2419, 2406, 2413, 2418, 2213, 2425, 2207, 2219) # RunUO ore hues: dull copper, shadow iron, copper, bronze, gold, agapite, verite, valorite + + +def read_hues(path): + d = open(path, "rb").read(); out = [] + for b in range(len(d) // 708): + for e in range(8): + o = b * 708 + 4 + e * 88 + cols = struct.unpack_from("<32H", d, o) + name = d[o + 68:o + 88].split(b"\0")[0].decode("latin-1") + out.append((np.array(cols, np.int32), name)) + return out + + +def c555(v): + v = np.asarray(v, np.int32) + return np.stack([((v >> 10) & 31) * 255 // 31, ((v >> 5) & 31) * 255 // 31, (v & 31) * 255 // 31], -1).astype(np.uint8) + + +def dye(im, hue_cols, partial): + """RGBA uint8 -> dyed RGBA: index = 5-bit red; partial: only R = G = B pixels (compared in 15 bits)""" + out = im.copy(); m = im[..., 3] > 0 + r5, g5, b5 = (im[..., 0].astype(np.int32) >> 3), (im[..., 1].astype(np.int32) >> 3), (im[..., 2].astype(np.int32) >> 3) + sel = m & ((r5 == g5) & (g5 == b5) if partial else True) + out[sel, :3] = c555(hue_cols[r5[sel]]) + return out + + +def main(a): + hues_path, out = a[0], a[1]; hues = list(ORE); partial = "0"; zoom = 3; crop = None; imgs = [] + i = 2 + while i < len(a): + if a[i] == "--hues": + hues = [int(x) for x in a[i + 1].split(",")]; i += 2 + elif a[i] == "--partial": + partial = a[i + 1]; i += 2 + elif a[i] == "--zoom": + zoom = int(a[i + 1]); i += 2 + elif a[i] == "--crop": + crop = [int(x) for x in a[i + 1].split(",")]; i += 2 + else: + lab, p = a[i].split("=", 1); imgs.append((lab, np.array(Image.open(p).convert("RGBA")))); i += 1 + table = read_hues(hues_path) + modes = {"0": [False], "1": [True], "both": [False, True]}[partial] + cols = [("undyed", None, False)] + [(("%d%s" % (h, " P" if pm else "")), h, pm) for h in hues for pm in modes] + cells = [] + for lab, im in imgs: + if crop: + im = im[crop[1]:crop[3], crop[0]:crop[2]] + row = [] + for cl, h, pm in cols: + d = im if h is None else dye(im, table[h - 1][0], pm) + bg = np.zeros(d.shape[:2] + (3,), np.uint8); bg[:] = (52, 52, 52); m = d[..., 3] > 0; bg[m] = d[m][:, :3] + row.append(Image.fromarray(bg).resize((d.shape[1] * zoom, d.shape[0] * zoom), Image.NEAREST)) + cells.append((lab, row)) + cw = max(t.size[0] for _, r in cells for t in r) + 4; chh = max(t.size[1] for _, r in cells for t in r) + 4 + S = Image.new("RGB", (90 + len(cols) * cw, 14 + len(cells) * chh), (20, 20, 20)); dr = ImageDraw.Draw(S) + for j, (cl, h, pm) in enumerate(cols): + dr.text((90 + j * cw + 2, 2), cl + ("" if h is None else " " + table[h - 1][1][:10]), fill=(230, 230, 230)) + for i_, (lab, row) in enumerate(cells): + dr.text((2, 14 + i_ * chh + 2), lab, fill=(255, 255, 0)) + for j, t in enumerate(row): + S.paste(t, (90 + j * cw, 14 + i_ * chh)) + S.save(out); print("uo_hue_preview:", out, S.size) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_import_item.py b/third_party/UO_Model3D_v13/pipeline/uo_import_item.py index f2218b5..9f7d819 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_import_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_import_item.py @@ -21,7 +21,7 @@ KIND = "shirt" # shirt, plate, arms, pants, legs, boots, gloves, helm, neck, hair, beard, hat ("" = do not scale or move; only clean + join) SCALE = 0.0 # > 0: uniform scale you give (the item is then only moved), 0 = from the height of KIND PLACE = "wrap" # "wrap": the item is only cleaned, joined and reduced here, its units, size and place are found from the skin by uo_autofit_item.py (called by uo_prepare_item.py); - # "height": the old way, one height per KIND (EXTENTS below) - right only for items shaped like the typical original (docs/qa/autofit.md) + # "height": the old way, one height per KIND (EXTENTS below) - right only for items shaped like the typical original STRETCH = (1.0, 1.0, 1.0) # (width, depth, height) factors about the centre of the model, before the fit: a model made on a mannequin with other proportions than the UO body (a vest much wider and shallower than the # torso: (0.8, 1.25, 1.0)). Recipe of uo_make_item.py: "stretch": [0.8, 1.25, 1.0] TURN = 0 # deg around the vertical axis (180 when the item came in back to front) @@ -33,7 +33,27 @@ # item around the shoulders follows them over ARM_BLEND m of the arm (uo_fit_item.py MATCH_ARMS only finds up to MAX_TURN deg, and a T-pose is beyond it) ARM_MATERIALS = ("sleeve",) # names (parts of names, any case) of the materials of the sleeves (a trim of the sleeves too: "sleeve" matches TrimSleeve) ARM_BLEND = 0.12 # m: the width of the shoulder over which the turn fades from the sleeve into the body +OUTER_SHELL = False # a model that is a CLOSED SOLID cloth (a cloth simulator exports garments ~1.5 cm thick: outer layer + inner layer): only the outer layer is kept, before the reduction. Reducing the + # solid pastes the two layers into each other (cracks, torn patches, the inner lining showing through); a single sheet reduces cleanly and fits to the skin. Recipe: "outer_shell": true +OUTER_REMESH = 0.0 # m: > 0 (0.006) = before OUTER_SHELL the solid is rebuilt by Blender's voxel remesh: a garment exported as separate panels (sewing patterns that only touch, 284 of them + # a robe made by a cloth simulator) becomes ONE continuous sheet, so the seams cannot open when the sleeves are turned onto the arms or the item is wrapped. The UVs are lost: + # the texture is baked from the original (BAKE_TEXTURE, 2048 when not given). Recipe: "outer_shell": 0.006 +HOLES = None # {"close": 0.06, "grow": 0.025, "max": 1.5, "close_above": 0.78}: with OUTER_SHELL, the holes of the sheet (rips of a torn robe): a rim shorter than `close` m is closed + # (a pinhole: 1-px black noise in the sprites), one up to `max` m is widened by `grow` m all round (the rips shrank with the reduction and the shortened hem and read as + # dark specks; the hem, cuffs and neck are longer than `max`); every hole whose rim lies above `close_above` of the sheet's height is closed (the shoulders and the + # roots of the sleeves stay whole: widened rips there read as the sleeve torn off), and every one longer than `slit` times its width (a slit along a hanging + # belt end opened over the belly when the body bent forward), and every one on a sleeve (`sleeve`: the line shoulder -> cuff of the model, |x| and z in its own + # coordinates, and a radius; the elbows read as poking through). Closed holes get the cloth around them in the baked texture (their faces take the + # UVs of the nearest rim). Recipe: "holes": {...} +BAKE_NORMAL = 0.0 # with BAKE_TEXTURE: > 0 = a tangent normal map of the ORIGINAL'S GEOMETRY is baked too (its own normal map, the weave, is left out: noise at UO size), blurred over this + # many m, and lights the item (uo_materials.py NORMAL_MAP): the sculpted folds of the model shade the reduced shell. Recipe: "bake_normal": 0.01 +BAKE_AO = 0.0 # with BAKE_TEXTURE: 0..1 = the ambient occlusion of the original (folds, the hollows between them, AO_DIST m) darkens the baked colour by up to this share. Recipe: "bake_ao": 0.5 +AO_DIST = 0.08 NAME = "" # name of the result ("" = file name) +BAKE_TEXTURE = 0 # > 0 (px, 2048 is plenty): the reduced model gets a NEW UV map and a texture BAKED from the original: a reduction drags the UVs of a model with a sculpted detail / an embroidery into + # streaks (the cloth turns into smeared noise). Needs the Cycles engine of Blender. Recipe: "bake_texture": 2048 +BAKE_RAY = 0.04 # m: how far from the reduced surface the original is looked for in the bake (layers closer to each other than this, e.g. a robe over a tunic, are not mixed up if they are further apart) +WELD = 1e-6 # m: vertices closer than this are merged before the reduction (seams of the glTF import; 0 = off) DECIMATE_TO = 30000 # a model with more vertices than this is reduced (collapse, UVs and materials kept) - a 144k-vertex scan costs minutes in every later step; uo_densify_item.py adds # vertices back where the item has to bend. 0 = never SMOOTH_SHADE = True # smooth shading on every face (UO art is shaded smoothly; a decimated or scanned model with flat faces turns into noise at 36 px/m) @@ -43,10 +63,11 @@ # (the ground is z = 0), and the PART of uo_bind_item.py that belongs to the KIND EXTENTS = {"shirt": (0.999, 1.656, "chest"), "plate": (0.651, 1.643, "chest"), "arms": (0.929, 1.694, "arms"), "pants": (0.057, 1.170, "legs"), "legs": (-0.107, 1.138, "legs"), "boots": (-0.069, 0.563, "boots"), "gloves": (0.797, 1.245, "gloves"), "helm": (1.504, 1.903, "helm"), - # medians over the original animations of the layer (docs/qa/layer_analysis.md, pipeline/layer_analysis.py). + # medians over the original animations of the layer. # hair / beard / hat: the TOP is reliable (hair 1.88-1.93 m for 80 % of the 37 hairstyles), the bottom depends on the style (hair 1.34-1.64): # for a long hairstyle give SCALE yourself instead of letting the height decide - "hair": (1.52, 1.89, "hair"), "beard": (1.52, 1.72, "beard"), "hat": (1.52, 1.92, "hat"), "neck": (1.48, 1.64, "neck")} + "hair": (1.52, 1.89, "hair"), "beard": (1.52, 1.72, "beard"), "hat": (1.52, 1.92, "hat"), "neck": (1.48, 1.64, "neck"), + "hood": (1.40, 1.97, "hood")} # a hood (with a cowl round the neck): from the base of the neck to a little above the head, set from the body, not measured body = bpy.data.objects["UO_Body"] UO_NAMES = {"UO_Body", "UO_Rig", "UO_Camera", "UO_Sun"} @@ -151,6 +172,223 @@ def arms_down(n): me.vertices.foreach_set("co", co.ravel()); me.update() +def outer_shell(n, rays=8, reach=1.5, partner_max=0.06, smooth=8, min_island=0.002): + """keep only the outer layer of a thick closed garment. Every face looks at the sky through a hemisphere of `rays` rays (an inner face sees mostly cloth: the other side of the robe, the sleeve); + its partner is the face hit under it (-normal, up to partner_max m = the other layer); the face that sees more sky is the outer one. The votes are smoothed over the neighbouring faces + (the two layers meet only at the hem and the cuffs) and the inner layer is deleted.""" + import time + import bmesh + from mathutils import Vector + from mathutils.bvhtree import BVHTree + t0 = time.time(); me = n.data; me.calc_loop_triangles() + tri = [t.vertices[:] for t in me.loop_triangles] + if len(tri) != len(me.polygons): + bm = bmesh.new(); bm.from_mesh(me); bmesh.ops.triangulate(bm, faces=bm.faces[:]); bm.to_mesh(me); bm.free(); me.update(); me.calc_loop_triangles(); tri = [t.vertices[:] for t in me.loop_triangles] + bvh = BVHTree.FromPolygons([v.co.copy() for v in me.vertices], tri) + i = np.arange(rays) + 0.5; z = 1 - i / rays; r = np.sqrt(1 - z * z); ph = i * 2.399963 + D = np.c_[r * np.cos(ph), r * np.sin(ph), z] + npoly = len(me.polygons); sky = np.zeros(npoly); partner = -np.ones(npoly, int) + for k, p in enumerate(me.polygons): + nr = p.normal; c = p.center + a = Vector((1, 0, 0)) if abs(nr.x) < 0.9 else Vector((0, 1, 0)) + u = nr.cross(a).normalized(); w = nr.cross(u); o = c + nr * 1e-3; esc = 0 + for d in D: + if bvh.ray_cast(o, u * d[0] + w * d[1] + nr * d[2], reach)[0] is None: + esc += 1 + sky[k] = esc / rays + h = bvh.ray_cast(c - nr * 1e-4, -nr, partner_max) + if h[0] is not None: + partner[k] = h[2] + vote = np.zeros(npoly); has = np.nonzero(partner >= 0)[0]; vote[has] = sky[has] - sky[partner[has]] + bm = bmesh.new(); bm.from_mesh(me); bm.faces.ensure_lookup_table() + A, B = [], [] + for e in bm.edges: + f = e.link_faces + if len(f) == 2: + A.append(f[0].index); B.append(f[1].index) + A = np.array(A); B = np.array(B); deg = np.maximum(np.bincount(np.r_[A, B], minlength=npoly), 1) + for _ in range(smooth): + acc = np.zeros(npoly); np.add.at(acc, A, vote[B]); np.add.at(acc, B, vote[A]); vote = 0.5 * vote + 0.5 * acc / deg + drop = np.nonzero(vote < 0)[0] + bmesh.ops.delete(bm, geom=[bm.faces[k] for k in drop], context="FACES") + bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context="VERTS") + bm.faces.ensure_lookup_table(); seen = np.zeros(len(bm.faces), bool); bits = [] # bits of the inner layer left alone by the vote (a few faces): removed + for f in bm.faces: + if seen[f.index]: + continue + st = [f]; seen[f.index] = True; isl = [f] + while st: + g = st.pop() + for e in g.edges: + for h in e.link_faces: + if not seen[h.index]: + seen[h.index] = True; st.append(h); isl.append(h) + if len(isl) < min_island * len(bm.faces): + bits += isl + if bits: + bmesh.ops.delete(bm, geom=bits, context="FACES") + bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context="VERTS") + nv0 = len(me.vertices); bm.to_mesh(me); bm.free(); me.update() + print("uo_import_item: OUTER_SHELL %s: %d of %d faces kept (outer layer; %d faces of small pieces removed), %d -> %d vertices, %.0f s" % (n.name, npoly - len(drop) - len(bits), npoly, len(bits), nv0, len(me.vertices), time.time() - t0)) + + +def holes(n, cfg): + """HOLES: close the pinholes of the sheet, widen the rips""" + import bmesh + from mathutils.kdtree import KDTree + cfg = dict(dict(close=0.06, grow=0.025, max=1.5), **cfg) + me = n.data; bm = bmesh.new(); bm.from_mesh(me) + seen, loops = set(), [] + for e0 in bm.edges: + if not e0.is_boundary or e0 in seen: + continue + st, loop = [e0], []; seen.add(e0) + while st: + e = st.pop(); loop.append(e) + for v in e.verts: + for f in v.link_edges: + if f.is_boundary and f not in seen: + seen.add(f); st.append(f) + loops.append((sum(e.calc_length() for e in loop), loop)) + zs = [v.co.z for v in bm.verts]; z_cut = min(zs) + cfg.get("close_above", 2.0) * (max(zs) - min(zs)) + high = lambda l: float(np.mean([e.verts[0].co.z for e in l])) > z_cut + def aspect(l): # length / width of a rim (PCA of its vertices): a long narrow slit, e.g. along a hanging belt end + P = np.array([e.verts[0].co for e in l]); ev = np.sort(np.linalg.eigvalsh(np.cov((P - P.mean(0)).T)))[::-1] + return float(np.sqrt(ev[0] / max(ev[1], 1e-12))) + slit = lambda l: aspect(l) > cfg.get("slit", 1e9) + + def sleeve(l): # "sleeve": [[|x|, z] of the shoulder, [|x|, z] of the cuff, r] in the model's own coordinates: a hole within r m of + if "sleeve" not in cfg: # that line lies on a sleeve (elbows and upper arms showed through widened rips like the arm poking out) + return False + (x0, z0), (x1, z1), r = cfg["sleeve"] + c = np.mean([e.verts[0].co[:] for e in l], 0); p = np.array([abs(c[0]), c[2]]); a = np.array([x0, z0]); d = np.array([x1, z1]) - a + t = np.clip((p - a) @ d / (d @ d), 0, 1) + return float(np.linalg.norm(p - a - t * d)) < r + shut = lambda l: high(l) or slit(l) or sleeve(l) + small = [l for L, l in loops if L < cfg["close"] or (L < cfg["max"] and shut(l))] + rips = [l for L, l in loops if cfg["close"] <= L < cfg["max"] and not shut(l)] + print("uo_import_item: HOLES rims (m, aspect): " + ", ".join("%.2f/%.1f" % (L, aspect(l)) for L, l in sorted(loops, key=lambda t: -t[0])[:30] if L >= cfg["close"])) + if small: # the patches: a material slot of their own (kept by the reduction) -> bake_texture gives them the cloth of their rim + me.materials.append(me.materials[0] if len(me.materials) else None); slot = len(me.materials) - 1 + n["uo_patch_slot"] = slot + for l in small: + for f in bmesh.ops.holes_fill(bm, edges=l, sides=0)["faces"]: + f.material_index = slot + bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 4], quad_method="BEAUTY", ngon_method="BEAUTY") + rim = list(set(v for l in rips for e in l for v in e.verts)) + kd = KDTree(len(rim)) + for k, v in enumerate(rim): + kd.insert(v.co, k) + kd.balance() + drop = [f for f in bm.faces if any(kd.find(v.co)[2] < cfg["grow"] for v in f.verts)] if rim else [] + bmesh.ops.delete(bm, geom=drop, context="FACES") + bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context="VERTS") + for _ in range(3): # the widened rims smoothed along themselves (no 1-face steps) + bv = [v for v in bm.verts if v.is_boundary] + new = {v: sum((e.other_vert(v).co for e in v.link_edges if e.is_boundary), v.co * 0) for v in bv} + for v, c in new.items(): + k = sum(1 for e in v.link_edges if e.is_boundary) + if k == 2: + v.co = 0.5 * v.co + 0.25 * c + bm.to_mesh(me); bm.free(); me.update() + print("uo_import_item: HOLES %s: %d holes closed (rim < %.2f m, or above %.2f m), %d rips widened by %.3f m (%d faces removed), %d rims left as they are, %d vertices" % ( + n.name, len(small), cfg["close"], z_cut, len(rips), cfg["grow"], len(drop), len(loops) - len(small) - len(rips), len(me.vertices))) + + +def blur_image(img, sigma_px, normal=False): + """Gaussian blur of an image in place (a normal map is renormalised)""" + from scipy.ndimage import gaussian_filter + w, h = img.size; a = np.array(img.pixels[:], np.float32).reshape(h, w, 4) + for c in range(3): + a[..., c] = gaussian_filter(a[..., c], sigma_px, mode="wrap") + if normal: + v = a[..., :3] * 2 - 1; v /= np.maximum(np.linalg.norm(v, axis=2, keepdims=True), 1e-6); a[..., :3] = v * 0.5 + 0.5 + img.pixels[:] = a.ravel() + return a + + +def bake_texture(lo, hi, px): + """texture of the original `hi` baked onto the reduced `lo` (new smart-projected UVs, one image `px` x `px`, a Principled material with it)""" + import math, tempfile, time + t0 = time.time(); sc = bpy.context.scene + for u in list(lo.data.uv_layers): + lo.data.uv_layers.remove(u) + lo.data.uv_layers.new(name="UVMap") + for o in bpy.context.view_layer.objects: + o.select_set(False) + lo.select_set(True); bpy.context.view_layer.objects.active = lo + bpy.ops.object.mode_set(mode="EDIT"); bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.003, area_weight=0.0, correct_aspect=True, scale_to_bounds=False) + bpy.ops.object.mode_set(mode="OBJECT") + img = bpy.data.images.new("uo_baked_" + lo.name, px, px, alpha=False) + mat = bpy.data.materials.new("uo_baked_" + lo.name); mat.use_nodes = True; nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"]; tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img + nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]); nt.nodes.active = tex + slot = lo.get("uo_patch_slot") + patch = np.array([p.material_index == slot for p in lo.data.polygons]) if slot is not None else np.zeros(len(lo.data.polygons), bool) + lo.data.materials.clear(); lo.data.materials.append(mat) + for i in range(len(lo.data.polygons)): + lo.data.polygons[i].material_index = 0 + sc.render.engine = "CYCLES"; sc.cycles.device = "CPU"; sc.cycles.samples = 1 + b = sc.render.bake + b.use_selected_to_active = True; b.max_ray_distance = BAKE_RAY; b.cage_extrusion = BAKE_RAY; b.margin = 8; b.margin_type = "EXTEND" + b.use_pass_direct = False; b.use_pass_indirect = False; b.use_pass_color = True + hi.select_set(True); lo.select_set(True); bpy.context.view_layer.objects.active = lo + bpy.ops.object.bake(type="DIFFUSE") + texels_per_m = px / max(math.sqrt(sum(p.area for p in lo.data.polygons) / 0.6), 1e-6) # smart project fills ~60 % of the image + if BAKE_NORMAL > 0 or BAKE_AO > 0: + for m in hi.data.materials: # the original's own normal map (the weave) left out: only its geometry + if m and m.use_nodes: + for l in list(m.node_tree.links): + if l.to_socket.name == "Normal" and l.to_node.type == "BSDF_PRINCIPLED": + m.node_tree.links.remove(l) + if BAKE_AO > 0: + ao = bpy.data.images.new("uo_ao_" + lo.name, px, px, alpha=False); atex = nt.nodes.new("ShaderNodeTexImage"); atex.image = ao; nt.nodes.active = atex + sc.world = sc.world or bpy.data.worlds.new("uo_bake_world"); sc.world.light_settings.distance = AO_DIST; sc.cycles.samples = 16 + hi.select_set(True); lo.select_set(True); bpy.context.view_layer.objects.active = lo + bpy.ops.object.bake(type="AO") + A = blur_image(ao, max(1.0, 0.01 * texels_per_m))[..., 0] + w_, h_ = img.size; C = np.array(img.pixels[:], np.float32).reshape(h_, w_, 4) + C[..., :3] *= (1.0 - BAKE_AO * (1.0 - np.clip(A, 0, 1)))[..., None]; img.pixels[:] = C.ravel() + nt.nodes.remove(atex); bpy.data.images.remove(ao); sc.cycles.samples = 1 + print("uo_import_item: BAKE_AO %s: occlusion of the original (%.2f m) darkens the colour by up to %.0f %%, mean AO %.2f" % (lo.name, AO_DIST, 100 * BAKE_AO, float(A.mean()))) + if BAKE_NORMAL > 0: + nim = bpy.data.images.new("uo_normal_" + lo.name, px, px, alpha=False); nim.colorspace_settings.name = "Non-Color" + ntex = nt.nodes.new("ShaderNodeTexImage"); ntex.image = nim; nt.nodes.active = ntex + b.normal_space = "TANGENT" + hi.select_set(True); lo.select_set(True); bpy.context.view_layer.objects.active = lo + bpy.ops.object.bake(type="NORMAL") + blur_image(nim, max(1.0, BAKE_NORMAL * texels_per_m), normal=True) + nm = nt.nodes.new("ShaderNodeNormalMap"); nt.links.new(ntex.outputs["Color"], nm.inputs["Color"]); nt.links.new(nm.outputs["Normal"], bsdf.inputs["Normal"]) + fn = os.path.join(tempfile.mkdtemp(prefix="uo_bake_"), "normal.png"); nim.filepath_raw = fn; nim.file_format = "PNG"; nim.save(); nim.pack() + nt.nodes.active = tex + print("uo_import_item: BAKE_NORMAL %s: normal map of the original's geometry, blurred over %.3f m (%.0f texels/m)" % (lo.name, BAKE_NORMAL, texels_per_m)) + if patch.any(): # closed holes: the UVs of the nearest rim vertex (the cloth around, not the dark inside of the rip) + from mathutils.kdtree import KDTree + me = lo.data; uv = me.uv_layers.active.data + rimuv = {} + for p in me.polygons: + if not patch[p.index]: + for li in p.loop_indices: + vi = me.loops[li].vertex_index + if vi not in rimuv: + rimuv[vi] = uv[li].uv.copy() + kd = KDTree(len(rimuv)); keys = list(rimuv) + for k, vi in enumerate(keys): + kd.insert(me.vertices[vi].co, k) + kd.balance() + for p in me.polygons: # one rim texel per face (UVs of one face from different islands of the atlas would smear across it) + if patch[p.index]: + t = rimuv[keys[kd.find(p.center)[1]]] + for li in p.loop_indices: + uv[li].uv = t + print("uo_import_item: HOLES %s: %d faces of closed holes take the cloth of their rim" % (lo.name, int(patch.sum()))) + if "uo_patch_slot" in lo: + del lo["uo_patch_slot"] + f = os.path.join(tempfile.mkdtemp(prefix="uo_bake_"), "baked.png"); img.filepath_raw = f; img.file_format = "PNG"; img.save(); img.pack() + print("uo_import_item: BAKE_TEXTURE %s: %d x %d px from %s, %d vertices, %.0f s" % (lo.name, px, px, hi.name, len(lo.data.vertices), time.time() - t0)) + + def run(): if FILE: everything = import_file(FILE) @@ -203,9 +441,33 @@ def run(): for k, n in enumerate(news): n.name = (NAME or os.path.splitext(os.path.basename(FILE))[0] or "Item") + ("" if len(news) == 1 else "_%d" % k) n.data.name = n.name + bake_px = BAKE_TEXTURE or (2048 if OUTER_SHELL and OUTER_REMESH > 0 else 0) + originals = {} + if bake_px > 0: # the original (with its texture) stays hidden until the reduced / rebuilt model has its baked texture + for n in news: + hi = n.copy(); hi.data = n.data.copy(); hi.name = n.name + "_original"; clo.objects.link(hi); originals[n] = hi + if OUTER_SHELL: + for n in news: + if OUTER_REMESH > 0: + import time + t0 = time.time(); k0 = len(n.data.vertices) + md = n.modifiers.new("uo_remesh", "REMESH"); md.mode = "VOXEL"; md.voxel_size = float(OUTER_REMESH); md.adaptivity = 0.0 + bpy.context.view_layer.update(); dg = bpy.context.evaluated_depsgraph_get() + me = bpy.data.meshes.new_from_object(n.evaluated_get(dg)); n.modifiers.clear(); old = n.data; n.data = me; bpy.data.meshes.remove(old) + print("uo_import_item: OUTER_REMESH %s: voxels %.1f mm, %d -> %d vertices (one continuous solid), %.0f s" % (n.name, 1000 * OUTER_REMESH, k0, len(me.vertices), time.time() - t0)) + outer_shell(n) + if HOLES: + holes(n, HOLES) if DECIMATE_TO > 0: total = sum(len(n.data.vertices) for n in news) if total > DECIMATE_TO: + if WELD > 0: # a glTF import splits the vertices on every UV / normal seam: the halves of a seam move apart in the reduction (cracks); the UVs stay with the faces + import bmesh + for n in news: + bm = bmesh.new(); bm.from_mesh(n.data); k = len(bm.verts) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD); bm.to_mesh(n.data); bm.free(); n.data.update() + print("uo_import_item: WELD %s: %d -> %d vertices" % (n.name, k, len(n.data.vertices))) + total = sum(len(n.data.vertices) for n in news) ratio = DECIMATE_TO / total for n in news: md = n.modifiers.new("uo_decimate", "DECIMATE"); md.ratio = ratio @@ -215,6 +477,9 @@ def run(): me = bpy.data.meshes.new_from_object(n.evaluated_get(dg)) n.modifiers.clear(); old = n.data; n.data = me; bpy.data.meshes.remove(old) print("uo_import_item: %d vertices -> %d (DECIMATE_TO %d)" % (total, sum(len(n.data.vertices) for n in news), DECIMATE_TO)) + for n, hi in originals.items(): + bake_texture(n, hi, int(bake_px)) + bpy.data.objects.remove(hi, do_unlink=True) if SMOOTH_SHADE: for n in news: n.data.polygons.foreach_set("use_smooth", [True] * len(n.data.polygons)) @@ -270,7 +535,7 @@ def run(): if PLACE == "wrap" and KIND: PLACE = "height" print("uo_import_item: WARNING - scipy is not installed in this Python, so the size and place come from ONE HEIGHT per KIND (PLACE = height), which only suits items shaped like the " - "typical original (docs/qa/autofit.md). For the fit to the skin install scipy into Blender's Python or run python pipeline/uo_make_item.py recipe.json outside the window.") + "typical original. For the fit to the skin install scipy into Blender's Python or run python pipeline/uo_make_item.py recipe.json outside the window.") FILE = os.environ.get("UO_IMPORT_FILE", FILE) KIND = os.environ.get("UO_IMPORT_KIND", KIND) SKIP = [k for k in os.environ.get("UO_IMPORT_SKIP", "").split(",") if k] or SKIP diff --git a/third_party/UO_Model3D_v13/pipeline/uo_make_item.py b/third_party/UO_Model3D_v13/pipeline/uo_make_item.py index 676b630..6bb0997 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_make_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_make_item.py @@ -8,6 +8,12 @@ "skip": ["guy"], # meshes of the file to leave out (parts of names) "keep": ["Blade"] only these "drop_materials": ["Belt", "Buckle"], # materials whose faces are cut out (a belt that is one mesh with the robe) "arms_down": 0, # deg; 80-90 for a model in a T-pose (arms out): the sleeves are turned down about the shoulders ("arm_materials": ["sleeve"] names them, "arm_blend": 0.12 m is the width over which the turn fades into the body) + "outer_shell": false, # true for a garment that is a closed solid ~1.5 cm thick (cloth simulator export): only the outer layer is kept before the reduction (uo_import_item.py OUTER_SHELL); + # a number (0.006 m) = first rebuilt by a voxel remesh into one continuous solid (separate panels whose seams open when the item is fitted), the texture baked (OUTER_REMESH) + "holes": {"close": 0.06, "grow": 0.025}, # with outer_shell: pinholes closed, rips widened (a torn robe; uo_import_item.py HOLES) + "bake_normal": 0.0, "bake_ao": 0.0, # with a baked texture: normal map of the original's geometry blurred over m / its occlusion darkening the colour (uo_import_item.py BAKE_NORMAL, BAKE_AO) + "bake_texture": 0, # px (2048): the reduced model gets new UVs and a texture baked from the original (a reduction smears the UVs of a detailed texture; uo_import_item.py BAKE_TEXTURE) + "scene": {"uo_outline": 0.6, "uo_hide_erode": 1, "uo_legs_under": 1}, # per-item settings of render_uo_layer.py (outline strength, hands / head hide a pixel less, the legs never hide a long robe; "uo_contrast": 1.5 = a contrast curve on the item (darker creases, brighter lights: sleeves and hands read thinner, README section 4; kind robe / skirt get uo_contrast 1.5, uo_shade_sleeve 0.4, uo_shade_width 4 unless the recipe says otherwise); "uo_samples": 1024 = more Cycles samples; "uo_edge_floor": 24 = the 1-px outline is never darker than 24/255 per channel, no pure black rim) "turn": 0, # 180 when the model came in back to front (the fit also tries it) "stretch": [0.8, 1.25, 1.0], # width, depth, height factors about the centre (a model made on a mannequin with other proportions than the UO body); "tune": {"uo_autofit_item.py": {"UNIT": 0.3}} = the file is not in metres "decimate": 30000, # vertices above which the model is reduced @@ -16,13 +22,15 @@ "tune": {"uo_fit_item.py": {"MIN_GAP": 0.02, "LIMIT": 0.05}, "uo_autofit_item.py": {"GAP": 0.02}}, # settings of the steps it runs (autofit, densify, fit, bind) "conform": {"MOVE": [0, 0, -0.07]}, # shirt / tunic / coat made on another mannequin: rebuilt as a shell of the body (uo_conform_item.py: sleeves onto the arms, wrapped GAP off the skin, # weights of the skin under it; the render keeps it out of the skin of its region in every frame); its settings, true = the defaults + "flail": {"BALL_SCALE": 0.7, "CHAIN_SCALE": 0.65}, # a weapon that is a flail: the chain and the ball swing (uo_flail.py, its settings) + "hood_cover": true, # kind hood: brought down onto the skull, cowl onto the shoulders, out of the head skin over the ears, symmetric (uo_hood_cover.py; on by default for a hood, false = off, {"GAP": 0.015} = its settings) "sim": true, # loose garments (kind robe / skirt): cloth simulation on top of the leg push (uo_cloth_sim.py; on by default, false = off, {"goal": 0.3, ...} = its settings) "weapon": {"class": "sword", "part": "weapon1h", "length": null, "tip": "auto"}} # a weapon instead of "kind": class (sword, dagger, mace, axe, polearm, staff, spear, - # bow, crossbow, gun), part (weapon1h, polearm, axe2h, bow: which hand bone and motion), length m (null = of the class), tip ("auto", "heavy", "+y" ...) + # bow, crossbow, gun), part (weapon1h, polearm, axe2h, bow: which hand bone and motion; weapon.L: 1H in the left hand, the mirrored grip of weapon1h, rigid on hand.L), length m (null = of the class), tip ("auto", "heavy", "+y" ...), butt (m along the hand line where the butt goes, the hand at 0: -0.06 = held near the butt end; default of the class) A model of several items (straps + a sword on the back; the file has the model's mannequin): "reference": {"keep": ["mannequin mesh"], "kind": "shirt", "turn_back": false} (false = do not try the half turn: for a model whose front is known, e.g. a belt with a sword) is fitted to the body and its transform goes to every part of "parts": [{"name": "straps", "keep": [...], "kind": "harness"}, {"name": "sword", "keep": [...], "rigid": "quiver"}] (rigid = stiff on a bone; its -uo_behind_torso / uo_no_body_gap properties are set: the chest hides what hangs behind it, the item is not bent away from limbs; "move": [x, y, z] m nudges a part; "skin_shell": {"GAP": 0.03, "VNECK": [1.3, 1.56, 0.1], "ZMAX": 1.56} = rebuild a vest as a shell of the body skin (uo_skin_shell.py); "smooth_mesh": {"iterations": 3, "factor": 0.5} = smooth the finished shape (borders stay); "min_piece": 24 = the renderer drops detached bits smaller than 24 px (default 8); "no_body_gap": true = not bent around the limbs by the renderer; "behind_torso": true = the torso hides what is behind it (garments that wrap the trunk); "cut_above" / "cut_below": z (m) at which a part is cut (a collar above the shoulders, a hem over the thighs); "thicken": {"width": 0.05} = widen a thin long part so that every cross-section direction is at least that wide (a sword: 0.05 m = 2 px), {"factor": 1.3, "own_axis": false} for its hilt on the same axis; "pelvis_share": 0.4 = that share of the weight on the pelvis, the rest on the bone: a sword that follows the leg only partly). +uo_behind_torso / uo_no_body_gap properties are set: the chest hides what hangs behind it, the item is not bent away from limbs; "move": [x, y, z] m nudges a part; "skin_shell": {"GAP": 0.03, "VNECK": [1.3, 1.56, 0.1], "ZMAX": 1.56} = rebuild a vest as a shell of the body skin (uo_skin_shell.py); "smooth_mesh": {"iterations": 3, "factor": 0.5} = smooth the finished shape (borders stay); "min_piece": 24 = the renderer drops detached bits smaller than 24 px (default 8); "no_body_gap": true = not bent around the limbs by the renderer; "behind_torso": true = the torso hides what is behind it (garments that wrap the trunk); "cut_above" / "cut_below": z (m) at which a part is cut (a collar above the shoulders, a hem over the thighs); "skin_wrap": {"GAP": 0.008} = lay straps on the skin: touching it (GAP m), keeping their thickness, none standing off it or poking through (the fit pushes a harness 1.5 cm off the skin and a mannequin's shirt adds more: 3 cm median); "widen": {"width": 0.07} = make straps (ribbons) wider in their own plane, thickness and curve stay (a 4 cm strap is 1.5 px at 36 px/m: invisible); "thicken": {"width": 0.05} = widen a thin long part so that every cross-section direction is at least that wide (a sword: 0.05 m = 2 px), {"factor": 1.3, "own_axis": false} for its hilt on the same axis; "pelvis_share": 0.4 = that share of the weight on the pelvis, the rest on the bone: a sword that follows the leg only partly). Steps (each is one of the scripts that are also in the .blend, see README): uo_import_item.py (clean, join, reduce) -> uo_materials.py (UO look) -> uo_prepare_item.py (uo_autofit_item.py size / place from the skin, uo_densify_item.py, uo_fit_item.py push out of the skin, uo_bind_item.py skin weights). @@ -33,10 +41,21 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) +ROBE_SCENE = {"uo_shade_sleeve": 0.4, "uo_shade_width": 4.0, "uo_contrast": 1.5} # base look of a robe / skirt (2026-10, README section 4): smooth contact shadow at the sleeve-torso junction (needs "conform") and a contrast curve + + +def scene_of(recipe): + """the scene properties of the renderer for a recipe: the base look of a robe / skirt, then the recipe's own "scene" (which wins; e.g. {"uo_contrast": 1} turns the curve off)""" + base = dict(ROBE_SCENE) if recipe.get("kind") in ("robe", "skirt") else {} + base.update(recipe.get("scene", {})) + return base + + PREVIEW_ACTIONS = "04_stand,00_walk_unarmed,02_run_unarmed,09_attack_1h_slash,16_spell_directed,21_die_forward" WEAPON_ACTIONS = {"weapon1h": "07_combat_idle_1h,09_attack_1h_slash,10_attack_1h_pierce,11_attack_1h_bash,03_run_armed,21_die_forward", "polearm": "08_combat_idle_2h,13_attack_2h_slash,14_attack_2h_pierce,12_attack_2h_bash,03_run_armed,21_die_forward", "axe2h": "08_combat_idle_2h,13_attack_2h_slash,12_attack_2h_bash,03_run_armed,21_die_forward,20_get_hit", + "weapon.L": "07_combat_idle_1h,09_attack_1h_slash,08_combat_idle_2h,03_run_armed,16_spell_directed,21_die_forward", "bow": "04_stand,19_attack_crossbow,18_attack_bow,03_run_armed,20_get_hit,21_die_forward"} @@ -95,6 +114,77 @@ def thicken(obs, th): n = len(Vo); new = c + np.outer(t[k:k + n], u) + f * perp[k:k + n]; k += n loc = (np.linalg.inv(Mw) @ np.c_[new, np.ones(n)].T).T[:, :3] o.data.vertices.foreach_set("co", loc.astype(np.float32).ravel()); o.data.update() + +def widen(obs, th): + """make ribbons (straps) wider in their own plane: "width": m (every strap at least this wide, e.g. 0.07 m = 2.5 px at 36 px/m; a strap is 4-5 cm = 1.5 px and vanishes in the render), + "rings": mesh rings around a vertex that give the local direction of the ribbon (3), "max": largest factor (3); the thickness and the length stay, the curve of the strap is followed""" + import bmesh + for o in obs: + Mw = np.array(o.matrix_world); R = Mw[:3, :3] + bm = bmesh.new(); bm.from_mesh(o.data); bm.verts.ensure_lookup_table(); bm.normal_update() + W = np.array([(Mw @ np.append(np.array(v.co), 1.0))[:3] for v in bm.verts]) + Nw = np.array([R @ np.array(v.normal) for v in bm.verts]); Nw /= np.maximum(np.linalg.norm(Nw, axis=1, keepdims=True), 1e-9) + new = W.copy(); fs = [] + for i, v in enumerate(bm.verts): + seen = {v.index}; front = [v] + for _ in range(int(th.get("rings", 3))): + nxt = [] + for a in front: + for e in a.link_edges: + b = e.other_vert(a) + if b.index not in seen: + seen.add(b.index); nxt.append(b) + front = nxt + idx = sorted(seen) + if len(idx) < 4: + continue + P = W[idx]; n = Nw[i]; c = P.mean(0) + Q = (P - c) - np.outer((P - c) @ n, n) + t = np.linalg.eigh(Q.T @ Q)[1][:, -1] + sd = np.cross(n, t); sd /= max(np.linalg.norm(sd), 1e-9) + w = float(np.ptp(P @ sd)) + f = min(float(th.get("max", 3.0)), max(1.0, float(th["width"]) / max(w, 1e-6))) + fs.append(f) + new[i] = W[i] + (f - 1.0) * float((W[i] - c) @ sd) * sd + loc = (np.linalg.inv(Mw) @ np.c_[new, np.ones(len(new))].T).T[:, :3] + o.data.vertices.foreach_set("co", loc.astype(np.float32).ravel()); o.data.update(); bm.free() + print("uo_make_item: widen %%s to %%.1f cm (factor mean %%.2f, max %%.2f)" %% (o.name, 100 * float(th["width"]), float(np.mean(fs)) if fs else 1.0, max(fs) if fs else 1.0)) + + +def skin_wrap(obs, th): + """lay a ribbon (strap) on the skin of the rest pose: every vertex goes to the nearest skin point + the normal * (GAP + its height over the lowest vertex of its 1-ring, at most THICK), so the strap + keeps its thickness, touches the skin (GAP m, 0.008) and does not stand off it or poke through it; a vertex is moved at most MAX m (0.05). The weights are set again afterwards (the skin under each vertex)""" + import bmesh + from mathutils.bvhtree import BVHTree + bpy.context.view_layer.update() + body = bpy.data.objects["UO_Body"]; dg = bpy.context.evaluated_depsgraph_get(); eb = body.evaluated_get(dg); mb = eb.to_mesh(); mb.calc_loop_triangles() + bv = BVHTree.FromPolygons([eb.matrix_world @ v.co for v in mb.vertices], [t.vertices[:] for t in mb.loop_triangles]); eb.to_mesh_clear() + gap, thick, mx = float(th.get("GAP", 0.008)), float(th.get("THICK", 0.006)), float(th.get("MAX", 0.05)) + for o in obs: + Mw = o.matrix_world; bm = bmesh.new(); bm.from_mesh(o.data); bm.verts.ensure_lookup_table() + n = len(bm.verts); P = [Mw @ v.co for v in bm.verts]; L = [None] * n; N = [None] * n; d = np.full(n, 9.0) + for i, p in enumerate(P): + loc, nrm, fi, dist = bv.find_nearest(p, 0.5) + if loc is not None: + L[i], N[i] = loc, nrm; d[i] = dist if (p - loc).dot(nrm) >= 0 else -dist + new = list(P); moved = [] + for i, v in enumerate(bm.verts): + if L[i] is None: + continue + dmin = min([d[i]] + [d[e.other_vert(v).index] for e in v.link_edges]) + q = L[i] + N[i] * (gap + min(thick, max(0.0, d[i] - dmin))) + sh = q - P[i] + if sh.length > mx: + q = P[i] + sh * (mx / sh.length) + new[i] = q; moved.append((q - P[i]).length) + Mi = Mw.inverted() + for v, q in zip(bm.verts, new): + v.co = Mi @ q + bm.to_mesh(o.data); bm.free(); o.data.update() + ok = d < 9 + print("uo_make_item: skin_wrap %%s: median height over the skin %%.1f -> %%.1f cm, inside %%.0f -> 0 %%%% of the vertices, moved %%.1f cm on average (max %%.1f)" %% ( + o.name, 100 * float(np.median(d[ok])), 100 * gap, 100 * float((d[ok] < 0).mean()), 100 * float(np.mean(moved)) if moved else 0.0, 100 * max(moved) if moved else 0.0)) + rig = bpy.data.objects["UO_Rig"]; rig.data.pose_position = "REST" for o in list(bpy.data.collections["Clothing"].all_objects): bpy.data.objects.remove(o, do_unlink=True) @@ -127,6 +217,8 @@ def select(obs): bpy.context.view_layer.update() if part.get("thicken"): thicken(obs, part["thicken"]) + if part.get("widen"): + widen(obs, part["widen"]) run("uo_materials.py", **part.get("materials", {})) if part.get("scale") or part.get("on_bone"): # a part that is the wrong size / place for the body (a pauldron of a stylised armour): scaled about its own centre, then its centre put on a bone (+ offset, m) import numpy as np @@ -239,6 +331,12 @@ def inside(k): select(obs) run("uo_skin_shell.py", **ss) run("uo_bind_item.py", PART=part.get("prepare", {}).get("PART") or "torso", **bind_over) + if part.get("skin_wrap"): # straps and hardware laid on the skin (skin_wrap above), then bound again to the skin under each vertex + sw = dict(part["skin_wrap"]) if isinstance(part["skin_wrap"], dict) else {} + bind_over = sw.pop("bind", {"SMOOTH": 0}) # the weights of the skin under each vertex, not smoothed over the mesh: the strap moves with the skin and keeps its gap in motion + skin_wrap(obs, sw) + select(obs) + run("uo_bind_item.py", PART=part.get("prepare", {}).get("PART") or "torso", **bind_over) if part.get("smooth_mesh"): # Laplacian smoothing of the finished rest shape (bumps and waves left by the push-out of the fit); border vertices (armholes, neckline, hem) stay, the skin weights stay sm = part["smooth_mesh"] if isinstance(part["smooth_mesh"], dict) else {} import bmesh @@ -272,9 +370,12 @@ def inside(k): for k in [k for k in o.data.shape_keys.key_blocks if k.name.startswith("uo_")]: o.shape_key_remove(k) print("uo_make_item: %%s -> rigid on %%s" %% (o.name, bone)) +for k, v in %(scene)r.items(): # "scene": properties of the scene the renderer reads per item (uo_samples, uo_edge_floor, uo_outline, ...) + bpy.context.scene[k] = v + print("uo_make_item: scene property %%s = %%r" %% (k, v)) bpy.ops.wm.save_as_mainfile(filepath=%(out)r) print("uo_make_item: saved", %(out)r) -''' % dict(here=HERE, base=base, ref=ref, parts=parts, out=out_blend) +''' % dict(here=HERE, base=base, ref=ref, parts=parts, scene=scene_of(recipe), out=out_blend) def build_stage(recipe, out_blend): @@ -282,7 +383,7 @@ def build_stage(recipe, out_blend): if "parts" in recipe: return build_parts_stage(recipe, out_blend) kind = recipe.get("kind", "") - imp = dict(FILE=os.path.abspath(recipe["file"]), KIND="" if recipe.get("weapon") else kind, SKIP=list(recipe.get("skip", [])), KEEP=list(recipe.get("keep", [])), DROP_MATERIALS=list(recipe.get("drop_materials", [])), ARMS_DOWN=float(recipe.get("arms_down", 0.0)), ARM_BLEND=float(recipe.get("arm_blend", 0.12)), TURN=int(recipe.get("turn", 0)), + imp = dict(FILE=os.path.abspath(recipe["file"]), KIND="" if recipe.get("weapon") else kind, SKIP=list(recipe.get("skip", [])), KEEP=list(recipe.get("keep", [])), DROP_MATERIALS=list(recipe.get("drop_materials", [])), ARMS_DOWN=float(recipe.get("arms_down", 0.0)), ARM_BLEND=float(recipe.get("arm_blend", 0.12)), TURN=int(recipe.get("turn", 0)), OUTER_SHELL=bool(recipe.get("outer_shell", False)), OUTER_REMESH=float(recipe["outer_shell"]) if not isinstance(recipe.get("outer_shell", False), bool) else 0.0, BAKE_TEXTURE=int(recipe.get("bake_texture", 0)), HOLES=recipe.get("holes"), BAKE_NORMAL=float(recipe.get("bake_normal", 0.0)), BAKE_AO=float(recipe.get("bake_ao", 0.0)), DECIMATE_TO=int(recipe.get("decimate", 30000)), NAME=recipe.get("name", "")) if "scale" in recipe: imp["SCALE"] = float(recipe["scale"]) @@ -305,17 +406,20 @@ def run(script, **over): run("uo_import_item.py", **%(imp)r) run("uo_materials.py", **%(mat)r) prep = %(prep)r -flat = {k: v for k, v in prep.items() if not isinstance(v, dict)} +flat = {k: v for k, v in prep.items() if not isinstance(v, dict) or k.isupper()} # settings of uo_prepare_item.py (a dict setting too: HEM_BAND) wp = %(weapon)r if wp: # a weapon: upright, class length, onto the line of its hand bone, rigid on the weapon bone run("uo_orient_weapon.py", CLASS=wp["class"], LENGTH=float(wp.get("length") or 0.0), TIP=wp.get("tip", "auto"), FLAT=bool(wp.get("flat", True))) - run("uo_place_weapon.py", PART=wp["part"], **({"ROLL_DEG": float(wp["roll"])} if "roll" in wp else {})) + run("uo_place_weapon.py", PART=wp["part"], **({"ROLL_DEG": float(wp["roll"])} if "roll" in wp else {}), **({"BUTT": float(wp["butt"])} if "butt" in wp else {})) run("uo_bind_item.py", PART=wp["part"]) else: run("uo_prepare_item.py", KIND=%(kind)r, **flat) +for k, v in %(scene)r.items(): # "scene": properties of the scene the renderer reads per item (uo_outline, uo_hide_erode, uo_legs_under, ...) + bpy.context.scene[k] = v + print("uo_make_item: scene property %%s = %%r" %% (k, v)) bpy.ops.wm.save_as_mainfile(filepath=%(out)r) print("uo_make_item: saved", %(out)r) -''' % dict(here=HERE, imp=imp, mat=recipe.get("materials", {}), prep=recipe.get("prepare", {}), kind=kind, out=out_blend, weapon=recipe.get("weapon"), tune=json.dumps(recipe.get("tune", {}))) +''' % dict(here=HERE, scene=scene_of(recipe), imp=imp, mat=recipe.get("materials", {}), prep=recipe.get("prepare", {}), kind=kind, out=out_blend, weapon=recipe.get("weapon"), tune=json.dumps(recipe.get("tune", {}))) def run_blend(base, stage_text, log): @@ -395,6 +499,27 @@ def list_file(path): q = subprocess.run([sys.executable, os.path.join(HERE, "item_clearance.py"), blend, "--json", os.path.join(out, "clearance.json")], capture_output=True, text=True) line = next((l for l in q.stdout.splitlines() if l.startswith("CLEARANCE")), "item_clearance failed: " + (q.stderr or q.stdout)[-300:]) print(line); open(os.path.join(out, "report.txt"), "a").write(line + "\n") + if recipe.get("hood_cover", recipe.get("kind") == "hood"): # a hood: pushed out of the head skin it does not cover (ears), symmetric, smooth (uo_hood_cover.py) + cfg = recipe["hood_cover"] if isinstance(recipe.get("hood_cover"), dict) else {} + sets = sum([["--set", "%s=%s" % kv] for kv in cfg.items()], []) + r = subprocess.run([sys.executable, os.path.join(HERE, "uo_hood_cover.py"), blend, blend] + sets, capture_output=True, text=True) + lines = [l for l in r.stdout.splitlines() if l.startswith("uo_hood_cover")] + open(os.path.join(out, "report.txt"), "a").write("\n".join(lines) + "\n") + if r.returncode or not any("saved" in l for l in lines): + sys.exit("uo_hood_cover failed:\n%s\n%s" % (r.stdout[-3000:], r.stderr[-2000:])) + for l in lines: + print(l[:260]) + if recipe.get("flail"): # a flail: haft rigid in the hand, the chain and the ball simulated per action (uo_flail.py -> flail_sim.npz next to item.blend) + t = time.time(); cfg = recipe["flail"] if isinstance(recipe["flail"], dict) else {} + sets = sum([["--set", "%s=%s" % kv] for kv in cfg.items()], []) + r = subprocess.run([sys.executable, os.path.join(HERE, "uo_flail.py"), blend, blend, "--out", os.path.join(out, "flail_sim.npz")] + sets, capture_output=True, text=True) + lines = [l for l in r.stdout.splitlines() if l.startswith("uo_flail")] + open(os.path.join(out, "report.txt"), "a").write("\n".join(lines) + "\n") + if r.returncode or not any("wrote" in l for l in lines): + sys.exit("uo_flail failed:\n%s\n%s" % (r.stdout[-3000:], r.stderr[-2000:])) + for l in lines: + print(l[:260]) + print("flail simulated in %.0f s" % (time.time() - t)) sim = recipe.get("sim", recipe.get("kind") in ("robe", "skirt")) and not recipe.get("weapon") # loose garments: cloth simulation on top of the leg push (uo_cloth_sim.py), on by default if sim and a.no_sim is False: t = time.time(); cfg = ["%s=%s" % kv for kv in (recipe["sim"].items() if isinstance(recipe.get("sim"), dict) else [])] diff --git a/third_party/UO_Model3D_v13/pipeline/uo_materials.py b/third_party/UO_Model3D_v13/pipeline/uo_materials.py index 3d04765..1ddc958 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_materials.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_materials.py @@ -27,17 +27,23 @@ SPEC_STRENGTH = 1.0 # highlight = SPEC_STRENGTH * albedo * max(N.L, 0) ^ SPEC_POWER, added to the UO light (linear light; the metal tints its own highlight). SPEC_POWER = 16 # Measured on the metal-named wearable animations of the client (light_items.py, 41 animations, body normals as the proxy): mail / ring / chain # SPEC_STRENGTH 0.7, power 11; the brightest quartile (plate, helms) 2.0, power 19; cloth and leather: Lambert only (0.3, no better than none). - # 1.0 / 16 is in between; use 2.0 / 19 for shiny plate, 0.7 / 11 for mail. Docs: docs/qa/light_shadow.md + # 1.0 / 16 is in between; use 2.0 / 19 for shiny plate, 0.7 / 11 for mail. TEXTURE_PX = 0 # > 0: a texture larger than this (px, the longer side) is scaled down to it: a fine weave or noise texture rendered at 36 px/m aliases into white speckles, which UO cloth does not have # (the colour of the cloth is its average); 0 = leave the textures. A recipe of uo_make_item.py: "materials": {"TEXTURE_PX": 64}. Do not use for cut-out textures (hair cards, lace). +GREY_CLOTH = None # {"value": 0.55, "keep_sat": 0.25, "hue": (0.04, 0.2), "contrast": 1.0, "fill": 0.0, "flatten": 0.0}: ("fill" m: gaps in the gold up to this size closed with gold; "flatten" m: the grey relative to the cloth around, panels of another cloth do not stand out) the cloth of the texture (pixels that are not saturated gold / yellow) becomes grey, + # its median at "value" (0-1 of the stored texture), the texture's own light and dark kept (ratio to the median ** contrast); gold pixels (saturation + # >= keep_sat, hue in the range, 0-1 = 0-360 deg) stay as they are, blended softly at their edge. A black robe with gold embroidery made grey like + # the hue-able UO robes. Recipe: "materials": {"GREY_CLOTH": {...}} TEXTURE_FILTER = True # box-average every colour / normal texture that is finer than the sprite (more than FILTER_TEXELS_PER_M texels per metre of the item): at one sample per pixel # a 1 m coat on a 1024 px texture is sampled every ~25th texel and comes out as noise, and the thin dark details (laces, buttons, stitches) appear and disappear # from frame to frame. Averaged, they stay as one soft dark pixel line. Skipped for cut-out textures (Alpha used) and for small textures. FILTER_TEXELS_PER_M = 54.0 # = 1.5 texels per UO pixel (36 px/m) +TEXTURE_BLUR = 0.0 # m: > 0 = after TEXTURE_FILTER the colour textures are blurred (Gauss, sigma this many m of the item at FILTER_TEXELS_PER_M): only the large stains and the baked shading + # of the folds stay, the mottle of a scanned texture goes (it read as pixel noise). Normal maps are left. Recipe: "materials": {"TEXTURE_BLUR": 0.045} NORMAL_MAP = True # a normal map of the model lights the item (UO_Look_N: the UO light on the mapped normal): weave, folds and raised straps give the shading the flat shell lacks. # False = the old behaviour (the map is dropped). The map is filtered like the colour (renormalised) NORMAL_STRENGTH = 1.0 -SHADOW = 0.4 # own shadow (docs/qa/light_shadow.md: the UO sprites are lit S = ambient + (1 - ambient) * c * s in the shade of the body / of the item itself, s = 0.4 measured): +SHADOW = 0.4 # own shadow (the UO sprites are lit S = ambient + (1 - ambient) * c * s in the shade of the body / of the item itself, s = 0.4 measured): # the item gets s of the light as emission (no shadow) and 1 - s as a diffuse BSDF lit by a Sun with the UO light direction that casts shadows (render_uo_layer.py makes # the Sun). Folds, a sleeve over the chest, an arm over the coat get their shade. 0 = off (UO_Look: Lambert, no shadows) FLAT_GREY = None # 0..1: one plain grey instead of the model's colours and textures (an item the game tints with a hue: a sword, a blade; 0.55 = mid grey, the hue then shows). None = the model's colour @@ -304,6 +310,52 @@ def convert(mat, ng): return "%s: %s%s" % (mat.name, how, "" if col is not None else " (plain colour %s)" % ", ".join("%.2f" % c for c in grp.inputs["Albedo"].default_value[:3])) +def grey_cloth(obs, lines): + """GREY_CLOTH on the item's own colour textures (see the setting)""" + cfg = dict(dict(value=0.55, keep_sat=0.25, hue=(0.04, 0.2), contrast=1.0, fill=0.0, flatten=0.0), **GREY_CLOTH) + used = {n.image for o in obs for sl in o.material_slots if sl.material and sl.material.use_nodes for n in sl.material.node_tree.nodes + if n.type == "TEX_IMAGE" and n.image and n.image.type == "IMAGE" and n.image.colorspace_settings.name != "Non-Color"} + for im in used: + w, h = im.size + px = np.empty(w * h * 4, np.float32); im.pixels.foreach_get(px); px = px.reshape(-1, 4) + rgb = px[:, :3].astype(np.float64); mx = rgb.max(1); mn = rgb.min(1); d = np.maximum(mx - mn, 1e-9) + sat = np.where(mx > 1e-6, (mx - mn) / np.maximum(mx, 1e-6), 0.0) + r, g, b = rgb.T + hue = np.where(mx == r, ((g - b) / d) % 6, np.where(mx == g, (b - r) / d + 2, (r - g) / d + 4)) / 6.0 + h0, h1 = cfg["hue"] + ws = np.clip((sat - (cfg["keep_sat"] - 0.08)) / 0.16, 0, 1) + wh = np.clip(np.minimum(hue - (h0 - 0.03), (h1 + 0.03) - hue) / 0.03, 0, 1) + wv = np.clip((mx - 0.08) / 0.08, 0, 1) + wgold = ws * wh * wv; wgold = wgold * wgold * (3 - 2 * wgold) + filled = 0 + dens = max([x for x in (texel_density(o, im) for o in obs) if x], default=0.0) or max(w, h) # texels per metre + if cfg["fill"] > 0: # gaps in the gold embroidery up to `fill` m closed with gold (a floral band on dark cloth: grey in its gaps breaks it up at 36 px/m) + from scipy.ndimage import binary_closing, uniform_filter + rad = max(1, int(round(cfg["fill"] * dens))) + gm = (wgold >= 0.5).reshape(h, w) + closed = binary_closing(gm, iterations=rad, border_value=0) + add = (closed & ~gm).ravel() + if add.any(): + k = 2 * rad + 1; g3 = gm.astype(float) + num = np.stack([uniform_filter(g3 * rgb[:, c].reshape(h, w), k) for c in range(3)], -1).reshape(-1, 3); den = uniform_filter(g3, k).ravel() + rgb[add] = num[add] / np.maximum(den[add, None], 1e-6); wgold[add] = 1.0; filled = int(add.sum()) + lum = rgb @ np.array([0.299, 0.587, 0.114]) + cloth = wgold < 0.5 + baked = cloth & (lum > 0.005) # texels outside the UV islands (black background of a baked texture) do not count + med = float(np.median(lum[baked])) if baked.any() else 0.5 + ref = np.full(len(lum), max(med, 1e-4)) + if cfg["flatten"] > 0: # the grey relative to the cloth AROUND the texel (Gaussian, sigma `flatten` m): panels of another cloth (a darker damask strip down + from scipy.ndimage import gaussian_filter # the back) do not stand out as lighter / darker grey; folds and the small pattern stay + sig = max(1.0, cfg["flatten"] * dens); vm = baked.reshape(h, w).astype(float) + num = gaussian_filter((lum * baked).reshape(h, w), sig); den = gaussian_filter(vm, sig) + ref = np.where(den.ravel() > 0.05, num.ravel() / np.maximum(den.ravel(), 1e-6), ref) + ref = np.maximum(ref, 1e-4) + grey = np.clip(cfg["value"] * (np.maximum(lum, 1e-4) / ref) ** cfg["contrast"], 0, 1) + new = wgold[:, None] * rgb + (1 - wgold[:, None]) * grey[:, None] + px[:, :3] = new; im.pixels.foreach_set(px.ravel()); im.update(); im.pack() + lines.append("GREY_CLOTH %s: %.0f%% gold kept (%d texels of gaps filled), cloth median %.3f -> %.3f, contrast %.2f" % (im.name, 100 * (wgold >= 0.5).mean(), filled, med, cfg["value"], cfg["contrast"])) + + def run(): ng = bpy.data.node_groups.get("UO_Look") if ng is None: @@ -312,8 +364,23 @@ def run(): if not obs and "Clothing" in bpy.data.collections: obs = [o for o in bpy.data.collections["Clothing"].all_objects if o.type == "MESH"] done = set(); lines = [] + if GREY_CLOTH: + grey_cloth(obs, lines) if TEXTURE_FILTER: filter_textures(obs, lines) + if TEXTURE_BLUR > 0: + from scipy.ndimage import gaussian_filter + cols = {n.image for o in obs for sl in o.material_slots if sl.material and sl.material.use_nodes for n in sl.material.node_tree.nodes + if n.type == "TEX_IMAGE" and n.image and not any(l.to_node.type == "NORMAL_MAP" for l in n.outputs["Color"].links)} + sig = TEXTURE_BLUR * FILTER_TEXELS_PER_M + for im in cols: + w, h = im.size; px = np.empty(w * h * 4, np.float32); im.pixels.foreach_get(px); px = px.reshape(h, w, 4) + msk = (px[..., :3].sum(2) > 0.02).astype(np.float32) # the empty background of a baked atlas is not mixed in (dark seams at the UV islands) + wm = np.maximum(gaussian_filter(msk, sig, mode="nearest"), 1e-4) + for c in range(3): + px[..., c] = np.where(msk > 0, gaussian_filter(px[..., c] * msk, sig, mode="nearest") / wm, px[..., c]) + im.pixels.foreach_set(px.ravel()); im.update(); im.pack() + lines.append("texture %s blurred (sigma %.3f m = %.1f texels)" % (im.name, TEXTURE_BLUR, sig)) if TEXTURE_PX > 0: used = {n.image for o in obs for sl in o.material_slots if sl.material and sl.material.use_nodes for n in sl.material.node_tree.nodes if n.type == "TEX_IMAGE" and n.image} # only the item's own textures for im in used: diff --git a/third_party/UO_Model3D_v13/pipeline/uo_orient_weapon.py b/third_party/UO_Model3D_v13/pipeline/uo_orient_weapon.py index 7d699ee..d9bc50d 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_orient_weapon.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_orient_weapon.py @@ -6,7 +6,7 @@ # (TIP = "auto"; give "+axis" / "-axis" like "-y" when the model has a narrow butt and a wide head, e.g. a mace or a hammer: there the tip is the heavy end, say TIP = "heavy"); # 3. the widest side perpendicular to the shaft goes to +X (the blade's flat, the side of the stock); a round shaft keeps whatever it had; # 4. scaled so that the length along the shaft is LENGTH m (by CLASS, LENGTHS below: the typical original of the class, measured) unless LENGTH > 0 is given; the butt end at the origin. -# Then: uo_place_weapon.py (PART weapon1h / polearm / axe2h / bow) and uo_bind_item.py with the same PART. See docs/qa/weapons_free_models.md. +# Then: uo_place_weapon.py (PART weapon1h / polearm / axe2h / bow) and uo_bind_item.py with the same PART. import bpy import numpy as np from mathutils import Matrix @@ -16,7 +16,7 @@ TIP = "auto" # "auto" (narrower end), "heavy" (the end with more vertices / mass is the head: mace, hammer, axe), or "+x", "-x", "+y", "-y", "+z", "-z" (model axis the tip points to) FLAT = True # turn the widest side perpendicular to the shaft to +X LENGTHS = {"sword": 1.1, "dagger": 0.5, "mace": 1.2, "axe": 1.35, "axe2h": 1.4, "polearm": 2.5, "staff": 2.4, "spear": 2.7, "bow": 1.34, "crossbow": 1.1, "gun": 1.2} -# measured on the original weapons of the client (docs/qa/weapons_free_models.md): the longest bounding-box diagonal over all frames of the animation, m. 1H blades 0.83 katana / +# measured on the original weapons of the client: the longest bounding-box diagonal over all frames of the animation, m. 1H blades 0.83 katana / # 0.97 cutlass / 1.12 scimitar / 1.21 broadsword / 1.31 viking sword; maces, hammers, clubs 1.06-1.29; 1H axes 1.34-1.39; 2H axes 1.39-1.65; bardiche 2.37, halberd 2.51, long spear # 2.70; quarter staff 2.27, black staff 2.78; bow 1.34; crossbow 1.09, heavy crossbow 1.44. The dagger (0.5) is not measured. diff --git a/third_party/UO_Model3D_v13/pipeline/uo_place_weapon.py b/third_party/UO_Model3D_v13/pipeline/uo_place_weapon.py index a72b195..6719764 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_place_weapon.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_place_weapon.py @@ -8,34 +8,43 @@ # bit / cutting edge of an axe towards +X), thin along Y. The script turns the weapon about its shaft so that +X lies in the plane the original weapon REF_ANIM # has at roll 0 of the class (weapon_motion.json: roll.offsets; default the reference weapon of the class); the bone then turns it with the roll per pose # (uo_weapon_bones.py). A class without "roll" (bows) keeps the turn of the shortest rotation +Z -> DIR. ROLL_DEG overrides the offset (degrees). +# PART = "weapon.L": a one-handed thing in the LEFT hand (a horn, a lantern: no original to calibrate on). It takes the grip of weapon1h.R mirrored across the sagittal plane +# (X of hand.R -> -X of hand.L, the same grip line, the same roll turned the mirror way), so the left version is the mirror image of the right one; uo_bind_item.py with PART = +# "weapon.L" then makes it rigid on hand.L (the hand bone itself, as the original left-hand items follow it). # Running it again places the weapon again. import bpy, json, math import numpy as np from mathutils import Vector, Matrix -PART = "polearm" # "polearm", "axe2h", "bow" or "weapon1h" +PART = "polearm" # "polearm", "axe2h", "bow", "weapon1h" or "weapon.L" (1H in the left hand, mirror of weapon1h) REF_ANIM = None # original weapon whose head orientation to copy (anim id, e.g. 624 halberd, 613 executioner's axe, 623 cutlass); None = the reference of the class ROLL_DEG = None # or: offset of the head about the shaft in degrees (overrides REF_ANIM) -BONE = {"polearm": "polearm.L", "axe2h": "axe2h.L", "bow": "bow.L", "weapon1h": "weapon1h.R"}[PART] +BUTT = None # m along the class line where the butt goes (the hand pivot is at 0): None = the class's usual `butt`; e.g. -0.06 = held close to the butt end (a flail's short haft) +BONE = {"polearm": "polearm.L", "axe2h": "axe2h.L", "bow": "bow.L", "weapon1h": "weapon1h.R", "weapon.L": "weapon1h.R"}[PART] +MIRROR = PART == "weapon.L" # the grip of the right hand mirrored onto hand.L WM = json.loads(bpy.data.texts["weapon_motion.json"].as_string())[BONE] rig = bpy.data.objects["UO_Rig"] -H = rig.matrix_world @ rig.data.bones[WM["parent"]].matrix_local # the hand bone (hand.L, hand.R) at rest -> world -piv = H @ Vector(WM["pivot"]); d = (H.to_3x3() @ Vector(WM["dir"])).normalized() +mx = (lambda v: Vector((-v[0], v[1], v[2]))) if MIRROR else (lambda v: Vector(v)) # mirror of a vector in the hand's own axes +HAND = "hand.L" if MIRROR else WM["parent"] +H = rig.matrix_world @ rig.data.bones[HAND].matrix_local # the hand bone (hand.L, hand.R) at rest -> world +dloc = mx(WM["dir"]) +piv = H @ mx(WM["pivot"]); d = (H.to_3x3() @ dloc).normalized() RO = WM.get("roll") ru = None if RO: # where +X of the weapon must point (perpendicular to the shaft) delta = math.radians(ROLL_DEG) if ROLL_DEG is not None else RO["offsets"].get(str(REF_ANIM if REF_ANIM is not None else RO["ref"]), 0.0) - ru = (H.to_3x3() @ (Matrix.Rotation(delta, 3, Vector(WM["dir"]).normalized()) @ Vector(RO["e1"]))).normalized() + ruloc = mx(Matrix.Rotation(delta, 3, Vector(WM["dir"]).normalized()) @ Vector(RO["e1"])) + ru = (H.to_3x3() @ ruloc).normalized() for ob in [o for o in bpy.context.selected_objects if o.type == "MESH"]: co = np.array([ob.matrix_world @ v.co for v in ob.data.vertices]) z0, ctr = co[:, 2].min(), co[:, :2].mean(0) # butt height, shaft axis (middle of the bounds in XY) R = Vector((0, 0, 1)).rotation_difference(d).to_matrix().to_4x4() - start = piv + d * WM["butt"] # where the butt goes + start = piv + d * (WM["butt"] if BUTT is None else BUTT) # where the butt goes Rr = Matrix.Identity(4) if ru is not None: x1 = R.to_3x3() @ Vector((1, 0, 0)) Rr = Matrix.Rotation(math.atan2(d.dot(x1.cross(ru)), x1.dot(ru)), 4, d) T = Matrix.Translation(start) @ Rr @ R @ Matrix.Translation(-Vector((ctr[0], ctr[1], z0))) ob.matrix_world = T @ ob.matrix_world - print("uo_place_weapon: %s on the line of %s, butt at %.2f m along it" % (ob.name, BONE, WM["butt"])) + print("uo_place_weapon: %s on the line of %s, butt at %.2f m along it" % (ob.name, BONE, WM["butt"] if BUTT is None else BUTT)) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_prepare_item.py b/third_party/UO_Model3D_v13/pipeline/uo_prepare_item.py index e9fe428..fc1ba33 100644 --- a/third_party/UO_Model3D_v13/pipeline/uo_prepare_item.py +++ b/third_party/UO_Model3D_v13/pipeline/uo_prepare_item.py @@ -5,17 +5,28 @@ # they do not bend. # distance uo_fit_item.py GAP_BY_KIND: how far from the skin (tight cloth 1.5 cm, armour / helmets / sleeves 3 cm). # skinning uo_bind_item.py PART: which bones the item follows (and RIGID presets: hair on the head). -# The settings are measured where noted in the scripts (docs/qa/slot_geometry.md); weights stay "as the skin under the item" (SMOOTH 4, STIFF 1): the originals -# do not tell another policy from it (docs/qa/bind_sweep.json: SMOOTH 0..12 and STIFF 0.5..4 change the IoU with the sprites by +-0.002 at most). +# The settings are measured where noted in the scripts; weights stay "as the skin under the item" (SMOOTH 4, STIFF 1): the originals +# do not tell another policy from it. import json import os import re import bpy -KIND = "shirt" # slot: shirt, plate, arms, pants, legs, boots, gloves, helm, neck, hair, beard, hat, robe, skirt, harness, waist, vest, quiver +KIND = "shirt" # slot: shirt, plate, arms, pants, legs, boots, gloves, helm, neck, hair, beard, hat, robe, skirt, harness, waist, vest, hood, quiver AUTOFIT = True # first uo_autofit_item.py: units, size and place from the skin the slot covers (False: the item already stands where it should) DENSIFY = True # False: keep the mesh as it is -FIT = True # False: skip uo_fit_item.py (the item already sits right) +FIT = None # False: skip uo_fit_item.py (the item already sits right); None = by slot: not for a hood (a stiff shell round the head: the push out of the head bent + # its sides unevenly, mirror p90 2.6 -> 19 mm, the hood looked crooked from the front) +HEM = None # m: a robe / skirt that is longer than the body (made on a taller mannequin) has its part below HEM_FROM shortened (uniformly, folds and embroidery squeezed) so that the hem lands this high above the ground + # (the original robes end at 0.0-0.2 m; a hem below the ground would hang under the feet of the sprite). None = leave. Recipe: "prepare": {"HEM": 0.02} +HEM_FROM = 0.70 # m: the shortening starts here (the sleeves of the model end above it) +HEM_FLARE = None # {"factor": 0.7, "from": 0.95, "r_min": 0.12}: a skirt flared wider than the UO robes (a model that trails on the floor, then shortened by HEM: the hem stands ~1.1 m wide from the + # front, the original robe 469 ~0.6 m) is narrowed about its axis: the distance from the axis beyond r_min m is scaled from 1 at `from` m down to `factor` at the hem + # (linear in height; the axis = centre of the cloth at HEM_FROM). Recipe: "prepare": {"HEM_FLARE": {...}} +HEM_BAND = None # {"height": 0.09, "fade": 0.02, "skip_front": 20, "edge": 5}: a gold band along the whole hem (an embroidered band that the model has only on some panels breaks off + # between the legs in the sprites): up to `height` m above the LOCAL hem (lowest point per angle around the skirt), fading over `fade` m, none within `skip_front` + # degrees of the front (-Y; the opening of a robe, soft over `edge` degrees). Point attribute uo_hem_band + the item's material mixes in the gold of its own texture + # (mottled like embroidery; texels already gold are kept). Recipe: "prepare": {"HEM_BAND": {...}} PART = None # skin weights of uo_bind_item.py: None = those of the slot (SLOTS below); e.g. "torso" for a cuirass whose pauldrons are a separate part (stays on pelvis / spine / chest / neck, does not stretch with the arms) # KIND -> (PART of uo_bind_item.py, densify?, class). class: "tight" (cloth / leather close to the skin), "hard" (armour, thick: keeps its distance, 3 cm), @@ -25,7 +36,7 @@ "plate": ("chest", True, "hard"), "arms": ("arms", True, "hard"), "legs": ("legs", True, "hard"), "helm": ("helm", True, "hard"), "neck": ("neck", True, "hard"), "robe": ("robe", True, "loose"), "skirt": ("skirt", True, "loose"), "cloak": ("cloak", True, "loose"), - "waist": ("belt", True, "tight"), "vest": ("chest", True, "tight"), # belt / sash (Waist), waistcoat / doublet (MiddleTorso): measured ranges, settings by analogy (docs/qa/autofit.md) + "waist": ("belt", True, "tight"), "vest": ("chest", True, "tight"), "hood": ("hood", True, "hard"), # hood: a hood with a cowl round the neck (keeps its shape off the head); belt / sash (Waist), waistcoat / doublet (MiddleTorso): measured ranges, settings by analogy "harness": ("torso", True, "tight"), # straps over the trunk (follow pelvis / spine / chest / neck only) "quiver": ("quiver", False, "rigid"), # a quiver, a sword on the back: rigid on the chest (custom properties uo_no_body_gap, uo_behind_torso: render_uo_layer.py) "hair": ("hair", False, "rigid"), "beard": ("beard", False, "rigid"), "hat": ("hat", False, "rigid"), @@ -70,11 +81,139 @@ def run(name, **over): print("uo_prepare_item: scipy is not installed in this Python: no fit to the skin (uo_autofit_item.py); the item stays where uo_import_item.py put it") elif AUTOFIT: run("uo_autofit_item.py", KIND=KIND) +if HEM is not None: # after the autofit the mesh holds world coordinates (the object's own transform is the identity) + import numpy as np + _obs = [o for o in bpy.context.selected_objects if o.type == "MESH" and o.name != "UO_Body"] + _co = {o: np.empty(len(o.data.vertices) * 3, np.float32) for o in _obs} + for o in _obs: + o.data.vertices.foreach_get("co", _co[o]) + _zmin = float(min(c[2::3].min() for c in _co.values())) + if _zmin < HEM - 1e-3: + _k = (HEM_FROM - HEM) / (HEM_FROM - _zmin) + for o, c in _co.items(): + z = c[2::3]; low = z < HEM_FROM + z[low] = HEM_FROM - (HEM_FROM - z[low]) * _k + o.data.vertices.foreach_set("co", c); o.data.update() + print("uo_prepare_item: HEM: the part below %.2f m shortened x%.2f, the hem was at %.3f m, now %.3f m" % (HEM_FROM, _k, _zmin, HEM)) +if HEM_FLARE: + import numpy as np + _cfg = dict(dict(factor=0.7, r_min=0.12), **HEM_FLARE); _cfg.setdefault("from", 0.95) + _obs = [o for o in bpy.context.selected_objects if o.type == "MESH" and o.name != "UO_Body"] + _P = {o: np.array([v.co for v in o.data.vertices]) for o in _obs} + _all = np.concatenate(list(_P.values())); _zlo = float(_all[:, 2].min()) + _band = _all[np.abs(_all[:, 2] - HEM_FROM) < 0.05] + _c = 0.5 * (_band[:, :2].min(0) + _band[:, :2].max(0)) + _w0 = np.ptp(_all[_all[:, 2] < _zlo + 0.05][:, 0]) + for o, P in _P.items(): + t = np.clip((_cfg["from"] - P[:, 2]) / max(_cfg["from"] - _zlo, 1e-6), 0.0, 1.0) + f = 1.0 + (_cfg["factor"] - 1.0) * t + d = P[:, :2] - _c; r = np.linalg.norm(d, axis=1) + rn = np.where(r > _cfg["r_min"], _cfg["r_min"] + (r - _cfg["r_min"]) * f, r) + P[:, :2] = _c + d * (rn / np.maximum(r, 1e-9))[:, None] + o.data.vertices.foreach_set("co", P.astype(np.float32).ravel()); o.data.update() + _all = np.concatenate(list(_P.values())) + print("uo_prepare_item: HEM_FLARE x%.2f at the hem (from %.2f m, axis %.3f %.3f): hem width %.2f -> %.2f m" % (_cfg["factor"], _cfg["from"], _c[0], _c[1], _w0, np.ptp(_all[_all[:, 2] < _zlo + 0.05][:, 0]))) if DENSIFY and _dens: run("uo_densify_item.py", KIND=KIND) +if FIT is None: + FIT = KIND not in ("hood",) if FIT: run("uo_fit_item.py", KIND=KIND) run("uo_bind_item.py", PART=_part) + + +def hem_band(obs, cfg): + """HEM_BAND: the weight uo_hem_band per vertex and the gold mixed in by it in the materials of `obs`""" + import numpy as np + cfg = dict(dict(height=0.09, fade=0.02, skip_front=0.0, edge=5.0, bins=36, noise=30.0, color=None), **cfg) + co = {} + for o in obs: + c = np.empty(len(o.data.vertices) * 3, np.float32); o.data.vertices.foreach_get("co", c) + M = np.array(o.matrix_world); co[o] = c.reshape(-1, 3) @ M[:3, :3].T + M[:3, 3] + allc = np.concatenate(list(co.values())) + z0 = allc[:, 2].min(); sk = allc[allc[:, 2] < z0 + 0.3] + cx, cy = sk[:, 0].mean(), sk[:, 1].mean() + B = int(cfg["bins"]); step = 360.0 / B + ang = lambda p: np.degrees(np.arctan2(p[:, 0] - cx, -(p[:, 1] - cy))) # 0 = the front (-Y) + a = ang(allc); b = ((a + 180) / step).astype(int) % B + zb = np.full(B, np.nan) + for i in range(B): + m = (b == i) & (allc[:, 2] < HEM_FROM) + if m.any(): + zb[i] = allc[m, 2].min() + ok = ~np.isnan(zb) # empty bins: from their neighbours (circular) + zb = np.interp(np.arange(B), np.nonzero(ok)[0], zb[ok], period=B) + zb = (np.roll(zb, 1) + 2 * zb + np.roll(zb, -1)) / 4 + cen = -180 + step * (np.arange(B) + 0.5) + lines = [] + for o, p in co.items(): + ap = ang(p) + h = p[:, 2] - np.interp(ap, cen, zb, period=360) + w = np.clip(1 - (h - cfg["height"]) / max(cfg["fade"], 1e-4), 0, 1) + if cfg["skip_front"] > 0: + w *= np.clip((np.abs(ap) - cfg["skip_front"]) / max(cfg["edge"], 1e-3), 0, 1) + w[p[:, 2] > HEM_FROM] = 0 + at = o.data.attributes.get("uo_hem_band") or o.data.attributes.new(name="uo_hem_band", type="FLOAT", domain="POINT") + at.data.foreach_set("value", w.astype(np.float32)); o.data.update() + lines.append("%s: %.0f%% of the vertices in the band" % (o.name, 100 * (w > 0.5).mean())) + for m in {sl.material for sl in o.material_slots if sl.material is not None}: + lines.append(band_material(m, cfg)) + print("uo_prepare_item: HEM_BAND %.3f m above the hem (%.3f-%.3f m), front opening %.0f deg kept: %s" % (cfg["height"], zb.min(), zb.max(), cfg["skip_front"], "; ".join(lines))) + + +def band_material(m, cfg): + """mix the gold of the material's own texture into its albedo by the attribute uo_hem_band (texels already gold kept)""" + import numpy as np + nt = m.node_tree + if nt is None or any(n.name == "uo_hem_band" for n in nt.nodes): + return "%s: no change" % m.name + grp = next((n for n in nt.nodes if n.type == "GROUP" and n.node_tree and n.node_tree.name.startswith("UO_Look")), None) + if grp is None or not grp.inputs["Albedo"].links: + return "%s: no UO_Look with a texture" % m.name + src = grp.inputs["Albedo"].links[0].from_socket + col = cfg["color"] + if col is None: # the texture's gold: saturated yellow / orange texels (as GREY_CLOTH of uo_materials.py) + im = next((n.image for n in nt.nodes if n.type == "TEX_IMAGE" and n.image is not None), None) + if im is None: + return "%s: no texture, no gold" % m.name + px = np.empty(im.size[0] * im.size[1] * 4, np.float32); im.pixels.foreach_get(px); rgb = px.reshape(-1, 4)[:, :3].astype(np.float64) + mx = rgb.max(1); mn = rgb.min(1); d = np.maximum(mx - mn, 1e-9); r, g, bb = rgb.T + sat = np.where(mx > 1e-6, d / np.maximum(mx, 1e-6), 0) + hue = np.where(mx == r, ((g - bb) / d) % 6, np.where(mx == g, (bb - r) / d + 2, (r - g) / d + 4)) / 6.0 + gold = (sat > 0.3) & (hue > 0.04) & (hue < 0.2) & (mx > 0.15) + if gold.sum() < 50: + return "%s: no gold in the texture" % m.name + lum = rgb[gold] @ np.array([0.299, 0.587, 0.114]); mean = rgb[gold].mean(0); ml = lum.mean() + dark, light = mean * np.percentile(lum, 20) / ml, mean * np.percentile(lum, 80) / ml + if im.colorspace_settings.name == "sRGB": # Image.pixels of an sRGB image are sRGB-encoded, the node colours are linear + lin = lambda c: np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + dark, light = lin(dark), lin(light) + else: + dark = light = np.array(col[:3], float) + N = nt.nodes; L = nt.links; x, y = grp.location.x - 260, grp.location.y - 360 + at = N.new("ShaderNodeAttribute"); at.name = "uo_hem_band"; at.attribute_type = "GEOMETRY"; at.attribute_name = "uo_hem_band"; at.location = (x - 600, y) + sep = N.new("ShaderNodeSeparateColor"); sep.mode = "HSV"; sep.location = (x - 600, y - 180); L.new(src, sep.inputs[0]) + keep = N.new("ShaderNodeMapRange"); keep.location = (x - 420, y - 180); keep.clamp = True + keep.inputs["From Min"].default_value, keep.inputs["From Max"].default_value = 0.17, 0.33 # saturation of the texel: gold already -> the band leaves it + keep.inputs["To Min"].default_value, keep.inputs["To Max"].default_value = 1.0, 0.0 + L.new(sep.outputs[1], keep.inputs["Value"]) + fac = N.new("ShaderNodeMath"); fac.operation = "MULTIPLY"; fac.location = (x - 240, y); L.new(at.outputs["Fac"], fac.inputs[0]); L.new(keep.outputs["Result"], fac.inputs[1]) + tc = N.new("ShaderNodeTexCoord"); tc.location = (x - 780, y - 380) + noi = N.new("ShaderNodeTexNoise"); noi.location = (x - 600, y - 380); noi.inputs["Scale"].default_value = cfg["noise"]; noi.inputs["Detail"].default_value = 2.0 + L.new(tc.outputs["Object"], noi.inputs["Vector"]) # mottled like the embroidery (light and dark gold of the texture) + nr = N.new("ShaderNodeMapRange"); nr.location = (x - 420, y - 380); nr.clamp = True + nr.inputs["From Min"].default_value, nr.inputs["From Max"].default_value = 0.35, 0.65 + L.new(noi.outputs["Fac"], nr.inputs["Value"]) + gc = N.new("ShaderNodeMix"); gc.data_type = "RGBA"; gc.location = (x - 240, y - 380) + gc.inputs[6].default_value = (*dark, 1.0); gc.inputs[7].default_value = (*light, 1.0); L.new(nr.outputs["Result"], gc.inputs[0]) + mix = N.new("ShaderNodeMix"); mix.data_type = "RGBA"; mix.location = (x, y) + L.new(fac.outputs["Value"], mix.inputs[0]); L.new(src, mix.inputs[6]); L.new(gc.outputs[2], mix.inputs[7]) + L.new(mix.outputs[2], grp.inputs["Albedo"]) + return "%s: gold %s-%s" % (m.name, "(%.2f %.2f %.2f)" % tuple(dark), "(%.2f %.2f %.2f)" % tuple(light)) + + +if HEM_BAND: + hem_band([o for o in bpy.context.selected_objects if o.type == "MESH" and o.name != "UO_Body"], HEM_BAND) if KIND == "waist": # a belt is a ring: the half behind the torso is hidden by it in the clothing layer (render_uo_layer.py TORSO_HIDE_MARGIN), otherwise the client draws it over the chest for _o in bpy.context.selected_objects: if _o.type == "MESH" and _o.name != "UO_Body": diff --git a/third_party/UO_Model3D_v13/pipeline/uo_render_gump_fit.py b/third_party/UO_Model3D_v13/pipeline/uo_render_gump_fit.py new file mode 100644 index 0000000..5f78bb5 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_render_gump_fit.py @@ -0,0 +1,946 @@ +"""Paperdoll gumps (male / female) and the item icon (art) of a garment that covers the body (robe, dress), fitted to an ORIGINAL item of the same kind. + + PD_OUT=DIR PD_REF_MALE=50469.png PD_REF_FEMALE=60469.png PD_ART_REF=7939.png python run_script_in_blend.py ITEM.blend uo_render_gump_fit.py + +uo_render_paperdoll.py fits a belt to the waist; a robe covers the whole body and the arms of the drawn paperdoll bodies (gumps 12 / 13: arms out, elbows bent) stand otherwise than the +rest pose of the 3D body (arms straight, 40 deg from the vertical). So per gender the arms are posed (the same angle out at both shoulders and bend at both elbows, in the front plane) and the +render is scaled / moved (x and y apart) so that the silhouette of the item matches the original gump of the same kind of item (plain robe 50469 / 60469 for a robe). +The search uses the item's triangles projected flat (fast), the final image is rendered like uo_render_paperdoll.py (UO_Look, the Sun of the own shadow, the body a holdout for the back of +the item, 4 x 4 supersampling) twice: textured and with the light alone (compose(): flat grey cloth times the light, the texture's gold as a cleaned mask). The grey of the item is mapped to +the levels of the original gump (p5 / p50 / p95 of its luminance), coloured parts (gold) keep their colour. PD_SAVE_RAW=1 also writes the raw renders (raw_*.npy, shade_*.npy). +Art: the item laid flat (arms out at a right angle, front view) turned PD_ART_TURN degrees and stretched into the box of the original icon (PD_ART_REF; the mirrored second icon +is written too). Writes gump_male.png, gump_female.png, art.png, art_mirror.png, fit.json in PD_OUT. +""" +import json, os +import bpy +import numpy as np +from mathutils import Matrix, Vector +from PIL import Image, ImageDraw +from scipy import ndimage, optimize + +OUT = os.environ["PD_OUT"] +REFS = {g: os.environ.get("PD_REF_" + g.upper()) for g in ("male", "female")} +BODIES = {g: os.environ.get("PD_BODY_" + g.upper()) for g in ("male", "female")} # the body gumps (12 / 13): where their fists are, the sleeves must end +HAND_W = float(os.environ.get("PD_HAND_W", "0.5")) # weight of the hands' distance to the fists (per (10 px)^2) against 1 - IoU +ARM_W = float(os.environ.get("PD_ARM_W", "2.0")) # weight of 1 - IoU in the sleeves' region alone (the skirt is most of the item: with the whole IoU only, a sleeve could leave + # the upper arm of the body gump bare) +ARM_ROWS, ARM_DX = (70, 175), 24 # the sleeves' region: these rows, farther than ARM_DX px from the body axis +BARE_W = float(os.environ.get("PD_BARE_W", "3.0")) # weight of the share of the body gump's arm skin (under the original's sleeves) left bare +SCALE_W = 200.0 # penalty on the scales beyond 15 % (shoulders), 25 % (hem), 8 % (height) from 1: without it the search ran off (height x1.36) +ART_REF = os.environ.get("PD_ART_REF") +ART_TURN = float(os.environ.get("PD_ART_TURN", "-45")) # deg, counter-clockwise on the screen +ART_ARMS = float(os.environ.get("PD_ART_ARMS", "15")) # deg the arms are raised from the rest pose (40 deg from the vertical) for the laid item +GOLD_K = float(os.environ.get("PD_GOLD_K", "2.1")) # coloured parts: the grey's mean factor times this (embroidery stays brighter than the cloth) +DESPECKLE = float(os.environ.get("PD_DESPECKLE", "0.6")) # a pixel darker than this share of its neighbours' mean is a speck (0 = off) +DARK_FILL = float(os.environ.get("PD_DARK_FILL", "0.45")) # share of the item's median luminance below which a pixel is a slit / deep shadow to fill (0 = off) +FLATTEN_SIGMA, FLATTEN_K, SOFT_SIGMA, OUTLINE_K, SHARPEN = 10.0, 0.0, 0.0, 0.55, 0.0 # smooth_cloth(): px, share, px, outline, unsharp amount (off since compose(): the cloth is the light alone) +GOLD_SAT = 0.12 # compose(): saturation of the texture's gold +GOLD_AREA = 125000 # hi-res px of the item the GOLD_* sizes are for (the robe's gump at SS 4); scaled with the item's size (the icon is smaller) +GOLD_BIG, GOLD_SMALL, GOLD_EDGE = 2000, 200, 12 # hi-res px: gold pieces kept (bigger than BIG, or than SMALL touching the item's edge within EDGE: collar, cuffs; stray specks dropped) +GOLD_CLOSE, GOLD_OPEN, GOLD_MAJ = 6, 5, 2 # hi-res px radii of the closing / opening of the gold mask, passes of a 3 x 3 majority on the gump pixels +GOLD_SIGMA, GOLD_MOTTLE = 2.0, 1.0 # the gold's colour: its median mixed with the texture's colour smoothed by GOLD_SIGMA hi-res px (the embroidery's pattern) +TEX_FOLDS, TEX_GAMMA, TEX_GRAIN, TEX_MOTTLE, TEX_GOLD, TEX_BORDER = 0.6, 1.2, 0.04, 0.04, 0.0, 1.0 # gold: no noise and no darker edge (sym_trims draws its embroidery) # texture_cloth(): see there +LINE_K = 0.7 +OUTLINE_GOLD = 0.75 # the outline on gold edge pixels (OUTLINE_K made the gold bands' rims black) +COVER_PX = 4 # px: the body gump's skin this close to the item (not the fists, head, feet) is covered by it (the original covers all of the body + # but the hands, head and feet; a bare shoulder / forearm showed through) # smooth_cloth(): px, share of the evening, px, the outline's share of the colour +DESPECKLE_PASSES = int(os.environ.get("PD_DESPECKLE_PASSES", "3")) # repeated: small clusters of dark pixels shrink from their edges +PXM = 90.5 # gump px per m and the body axis / floor of the gump of body 12 (uo_render_paperdoll.py) +GW, GH = 260, 237 +GX0, GY_FEET = 94.5, 221.0 +SS = 4 +LIGHT_SUN_DEG = float(os.environ.get("PD_SUN_DEG", "20")) # deg: the Sun's angular size in the render of the light (soft own shadows) +LIGHT_SAMPLES = int(os.environ.get("PD_LIGHT_SAMPLES", "32")) +os.makedirs(OUT, exist_ok=True) +sc = bpy.context.scene +rig, body = bpy.data.objects["UO_Rig"], bpy.data.objects["UO_Body"] +items = [o for o in bpy.data.collections["Clothing"].all_objects if o.type == "MESH"] +rig.data.pose_position = "POSE" +rig.animation_data_clear() if rig.animation_data else None +for pb in rig.pose.bones: + pb.matrix_basis = Matrix() +sc.render.engine = "CYCLES"; sc.cycles.device = "CPU"; sc.cycles.samples = 1; sc.cycles.use_denoising = False +sc.cycles.pixel_filter_type = "BOX"; sc.cycles.filter_width = 0.01; sc.render.dither_intensity = 0.0 +sc.render.film_transparent = True; sc.view_settings.view_transform = "Standard" +sc.render.image_settings.file_format = "PNG"; sc.render.image_settings.color_mode = "RGBA" +sc["uo_look"] = 1.0; sc["uo_exact"] = 0.0 +sc.render.resolution_percentage = 100 +g = bpy.data.node_groups.get("UO_Look_NS"); sun = None +if g is not None: # the Sun of the own shadow, as render_uo_layer.shadow_sun / uo_render_paperdoll.py + Lv = Vector(next(n for n in g.nodes if n.type == "COMBXYZ").inputs[i].default_value for i in range(3)) + ld = bpy.data.lights.new("UO_Sun", "SUN"); sun = bpy.data.objects.new("UO_Sun", ld); sc.collection.objects.link(sun) + ld.angle = 0.0; ld.energy = 3.14159265 * (1.0 - g["uo_ambient"]) * (1.0 - g["uo_shadow"]) + sun.rotation_euler = Vector((0, 0, 1)).rotation_difference(Lv.normalized()).to_euler() + sc.cycles.diffuse_bounces = 0 + for m in bpy.data.materials: + m.cycles.emission_sampling = "NONE" +cd = bpy.data.cameras.new("PD_Camera"); cd.type = "ORTHO"; cam = bpy.data.objects.new("PD_Camera", cd); sc.collection.objects.link(cam) +cam.data.sensor_fit = "HORIZONTAL"; sc.camera = cam +for o in items: # no per-frame cloth push here: the item as the skeleton skins it + for k in ("uo_cloth",): + if k in o: + del o[k] + + +TORSO = {} # bone -> scale across the body (fit_torso): pelvis / spine / chest along x, the clavicles along themselves (the shoulders come in with the chest) +FIT_TORSO = {"female": True} # genders whose robe is fitted to the drawn body (user: the woman's robe more fitted; the original 60469 is as loose as the man's) +FIT_MARGIN = float(os.environ.get("PD_FIT_MARGIN", "3")) # px of cloth beyond the drawn body on each side, shoulders to hips +UPPER_W = {"female": float(os.environ.get("PD_UPPER_W", "0.7"))} # the upper sleeve this wide across the arm: the wide sleeves merged with the torso at the waist (the drawn woman and + # the original show a gap between the arm and the waist) +FIT_ROWS = (106, 142) # gump rows of the fit (waist and hips of body 13; above, the drawn upper arms join the torso) + + +def apply_torso(): + """the robe's torso narrowed (TORSO); the children do not inherit the scale (legs, neck, arms keep their size, the shoulders move in)""" + for bn in ("spine", "chest", "neck", "clavicle.L", "clavicle.R", "thigh.L", "thigh.R", "upper_arm.L", "upper_arm.R"): + rig.data.bones[bn].inherit_scale = "NONE" if TORSO else "FULL" + for bn in ("pelvis", "spine", "chest"): + rig.pose.bones[bn].scale = (TORSO.get(bn, 1.0), 1.0, 1.0) + for side in ("L", "R"): + rig.pose.bones["clavicle." + side].scale = (1.0, TORSO.get("chest", 1.0), 1.0) + + +def pose_arms(out_deg, bend_deg, length=1.0): + """both arms `out_deg` further out at the shoulder and `bend_deg` more out at the elbow, in the front plane (about the forward axis); upper arm and forearm `length` times as long + (the drawn paperdoll bodies have shorter arms than the 3D body: with the real length the hand reached the fist only with the upper arm pulled into the torso)""" + for pb in rig.pose.bones: + pb.matrix_basis = Matrix() + apply_torso() + for side, sg in (("L", -1.0), ("R", 1.0)): + for bn, ang in (("upper_arm." + side, out_deg), ("forearm." + side, bend_deg)): + ml = rig.data.bones[bn].matrix_local.to_3x3() + R = Matrix.Rotation(np.radians(sg * ang), 3, "Y") + q = (ml.inverted() @ R @ ml).to_quaternion() + pb = rig.pose.bones[bn]; pb.rotation_mode = "QUATERNION"; pb.rotation_quaternion = q + if bn.startswith("upper_arm"): + pb.scale = (1.0, length, 1.0) # along the bone; the forearm inherits it (one factor for both) + bpy.context.view_layer.update() + + +# The arms of the drawn paperdoll bodies (gump px, read off gumps 12 / 13 of the Nelderim client): shoulder, elbow, wrist of each arm. They are not symmetric: the character's right arm +# (left on the screen) hangs from the shoulder to the elbow and points the forearm out to the side, the left one goes out at the elbow and down to the fist. A symmetric pose searched +# for the silhouette (the first versions) bunched the sleeves into puffs at the shoulders. +JOINTS = {"male": {"R": ((75, 88), (69, 116), (54, 113)), "L": ((114, 88), (122, 111), (129, 121))}, + "female": {"R": ((75, 88), (70, 115), (52, 111)), "L": ((113, 88), (121, 112), (128, 121))}} +ARM_MODE = os.environ.get("PD_ARM_MODE", "joints") # "joints": each arm along the drawn body's (JOINTS); "fit": one symmetric pose searched for the silhouette (the first versions) + + +def from_gump(px, p): + """gump pixel -> world (x, z) for the fit p (inverse of to_gump)""" + kxt, kxb, ky, dx, dy = p + z = (GY_FEET + dy - px[1]) / (PXM * ky) + return np.array([(px[0] - GX0 - dx) / (PXM * kx_at(z, kxt, kxb)), z]) + + +def pose_arms_to(joints, p, wrist=None, upper_w=1.0): + """each upper arm along shoulder -> elbow of the body gump, in the front plane, as long as that (the drawn arms are shorter); each forearm aimed at the wrist (aim_forearm); + the hands shrunk to nothing (the body is a holdout: a 3D hand in front of the sleeve would cut a hole in it, the drawn fist shows instead)""" + for pb in rig.pose.bones: + pb.matrix_basis = Matrix() + apply_torso(); bpy.context.view_layer.update() + out = {} + for side, (S, E, W) in joints.items(): + if wrist and side in wrist: + W = wrist[side] + s, e, w = (from_gump(np.array(v, float), p) for v in (S, E, W)) + if TORSO: # the narrowed chest moved the shoulder: the upper arm from where it is to the elbow + h = rig.matrix_world @ rig.pose.bones["upper_arm." + side].head; s = np.array([h.x, h.z]) + b = rig.data.bones["upper_arm." + side]; d0 = np.array([b.tail_local.x - b.head_local.x, b.tail_local.z - b.head_local.z]); tgt = e - s + ang = -np.arctan2(d0[0] * tgt[1] - d0[1] * tgt[0], d0 @ tgt); k = float(np.linalg.norm(tgt)) / b.length # Matrix.Rotation(t, "Y") turns (x, z) by -t + ml = b.matrix_local.to_3x3() + pb = rig.pose.bones["upper_arm." + side]; pb.rotation_mode = "QUATERNION"; pb.rotation_quaternion = (ml.inverted() @ Matrix.Rotation(ang, 3, "Y") @ ml).to_quaternion(); pb.scale = (upper_w, k, upper_w) + for bn in ("forearm." + side, "hand." + side): + rig.data.bones[bn].inherit_scale = "NONE" + bpy.context.view_layer.update() + fk, lean = aim_forearm(side, w) + hb = rig.pose.bones["hand." + side]; hb.scale = (0.01, 0.01, 0.01) + out[side] = [round(float(np.degrees(ang)), 1), round(k, 3), round(fk, 3), round(lean, 1)] + bpy.context.view_layer.update() + return out + + +def aim_forearm(side, w): + """the forearm from its posed head towards the point w = world (x, z) in the front view: nearer than its length -> it leans towards the viewer (-y) so that its front view is that short + (the drawn forearm is foreshortened; squashing it along the bone crumpled the sleeve), farther -> stretched. Returns (stretch, lean deg)""" + pb = rig.pose.bones["forearm." + side]; Mw = rig.matrix_world + hw = Mw @ pb.head; t = np.array([w[0] - hw.x, w[1] - hw.z]); d2 = float(np.linalg.norm(t)); L = pb.bone.length * Mw.to_scale()[0] + lean = min(np.arccos(min(1.0, d2 / L)), np.radians(LEAN_MAX)) + dy, k = -L * np.sin(lean), d2 / (L * np.cos(lean)) + dw = Vector((t[0], dy, t[1])).normalized(); da = (Mw.to_3x3().inverted() @ dw).normalized() + rest = pb.bone.matrix_local.to_3x3() + M = (rest.col[1].rotation_difference(da).to_matrix() @ rest).to_4x4(); M.translation = pb.head + pb.rotation_mode = "QUATERNION"; pb.matrix = M; bpy.context.view_layer.update(); pb.scale = (SLEEVE_W, k, SLEEVE_W) + return k, float(np.degrees(lean)) + + +def fist_blobs(body_png, ref): + """the fists of a body gump as masks with their centres: its pixels the original item does not cover, at the height of the hands, the two biggest pieces""" + b = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0 + free = b & ~ndimage.binary_dilation(ref, iterations=1); free[:85] = False; free[170:] = False + lab, n = ndimage.label(free) + sizes = ndimage.sum(free, lab, range(1, n + 1)); big = np.argsort(sizes)[::-1][:2] + 1 + return [(lab == k, ndimage.center_of_mass(free, lab, k)) for k in big] + + +CUFF_RIM = 0.015 # m: the sleeve's mouth = per 15 deg around the forearm the vertices this close to the farthest one along it (the whole slanted rim of the bell) +CUFF_INTO = float(os.environ.get("PD_CUFF_INTO", "0.5")) # px: the mouth's far edge this far past the drawn wrist into the hand (the centre of the mouth on the forearm's line) +LEAN_MAX = float(os.environ.get("PD_LEAN_MAX", "30")) # deg: the forearm leans towards the viewer at most this much, the rest of the foreshortening is a squash along the bone +SLEEVE_W = float(os.environ.get("PD_SLEEVE_W", "0.75")) # the forearm (the sleeve's bell) this wide across the bone: leaning, the bell's mouth seen nearly face-on covered the hand + + +def cuff_rims(objs): + """per arm side: [(object index, vertex indices)] of the sleeve's mouth, in the rest pose: vertices weighted >= 0.9 to the forearm, per 15 deg sector around its axis the farthest + CUFF_RIM along it (the farthest 3 cm overall were only the bell's lower lip, 5 cm below the axis)""" + for pb in rig.pose.bones: + pb.matrix_basis = Matrix() + bpy.context.view_layer.update() + rest = posed_verts(objs); out = {} + for side in ("R", "L"): + b = rig.data.bones["forearm." + side]; M = np.array(rig.matrix_world) + h = M[:3, :3] @ np.array(b.head_local) + M[:3, 3]; t = M[:3, :3] @ np.array(b.tail_local) + M[:3, 3]; d = (t - h) / np.linalg.norm(t - h) + u = np.cross(d, [0.0, 1.0, 0.0]); u /= np.linalg.norm(u); v = np.cross(d, u) + rim = [] + for k, o in enumerate(objs): + gi = o.vertex_groups.get("forearm." + side) + if gi is None: + continue + w = np.zeros(len(o.data.vertices), np.float32) + for vx in o.data.vertices: + for g in vx.groups: + if g.group == gi.index: + w[vx.index] = g.weight + r = rest[k] - h; along = r @ d; sec = ((np.degrees(np.arctan2(r @ v, r @ u)) + 180) // 15).astype(int) + sel = (w >= 0.9) & (along > 0.5 * np.linalg.norm(t - h)); ix = [] + for sc_ in np.unique(sec[sel]): + s2 = sel & (sec == sc_) + ix.append(np.nonzero(s2 & (along >= along[s2].max() - CUFF_RIM))[0]) + if ix: + rim.append((k, np.concatenate(ix))) + out[side] = rim + return out + + +def posed_verts(objs): + dg = bpy.context.evaluated_depsgraph_get(); out = [] + for o in objs: + ev = o.evaluated_get(dg); me = ev.to_mesh() + co = np.empty(len(me.vertices) * 3, np.float32); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3) + M = np.array(ev.matrix_world); out.append(co @ M[:3, :3].T + M[:3, 3]); ev.to_mesh_clear() + return out + + +def hand_mask(body_png, ref, E, W): + """the drawn hand: the body gump's pixels the original item leaves free (fist_blobs) beyond the drawn wrist W (across the line through W square to elbow -> wrist)""" + d = np.array(W, float) - np.array(E, float); d /= np.linalg.norm(d) + fm, _ = min(fist_blobs(body_png, ref), key=lambda bc: np.hypot(bc[1][1] - W[0], bc[1][0] - W[1])) + Y, X = np.mgrid[0:GH, 0:GW] + return fm & ((X - W[0]) * d[0] + (Y - W[1]) * d[1] > 0) + + +def fit_cuffs(joints, p, body_png, ref, upper_w=1.0): + """per arm: the forearm aimed (aim_forearm) until the centre of the sleeve's mouth (cuff_rims) lands CUFF_INTO px past the drawn wrist (JOINTS) in the gump: fixed-point, the forearm's + target moved by the remaining error. Measured: the drawn forearm left bare (elbow -> wrist, outside the hand) and the hand covered by the sleeve, px""" + bb = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0 + rims = cuff_rims(items); tgt, err, bare, over = {}, {}, {}, {} + Y, X = np.mgrid[0:GH, 0:GW] + for side, (S, E, W) in joints.items(): + d = np.array(W, float) - np.array(E, float); L = np.linalg.norm(d); d /= L + wv = np.array(W, float) + for it in range(15): + pose_arms_to({side: (S, E, tuple(wv))}, p, upper_w=upper_w) + pv = posed_verts(items) + rp = to_gump(np.concatenate([pv[k][ix][:, [0, 2]] for k, ix in rims[side]]), p) - np.array(W, float) + nv = np.array([-d[1], d[0]]) + e = -(np.max(rp @ d) - CUFF_INTO) * d - np.mean(rp @ nv) * nv # the far edge to the wrist along the forearm, the centre onto its line + if np.hypot(*e) < 0.2: + break + wv = wv + e + tgt[side] = (round(float(wv[0]), 2), round(float(wv[1]), 2)); err[side] = round(float(np.hypot(*e)), 2) + hm = hand_mask(body_png, ref, E, W) + t = (X - E[0]) * d[0] + (Y - E[1]) * d[1]; nrm = np.abs(-(X - E[0]) * d[1] + (Y - E[1]) * d[0]) + lane = bb & ~hm & (t > 0) & (t <= L) & (nrm <= 6) + sil = raster(to_gump(flat_tris(items), p), GW, GH, 2) + bare[side] = int((lane & ~sil).sum()); over[side] = int((hm & sil).sum()) + return tgt, err, bare, over + + +def cover_body(a, body_png, ref, joints=None, neck=False): + """the body gump's skin within COVER_PX of the item (not the hands beyond the drawn wrists, the head and neck, the feet) taken into the item, coloured like the nearest cloth""" + bb = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0 + m = a[..., 3] > 0 + keep = np.zeros_like(bb) + for fm in ([hand_mask(body_png, ref, E, W) for S, E, W in joints.values()] if joints else [f for f, _ in fist_blobs(body_png, ref)]): + keep |= ndimage.binary_dilation(fm, iterations=1) + keep[:84] = True; keep[205:] = True # head with the neck, feet + if neck: # and the neckline's opening (the skin of the neck would fill it) + keep |= ndimage.binary_dilation(neck_opening(neckline(body_png, neck if neck is not True else None), keep.shape), iterations=1) + d, (iy, ix) = ndimage.distance_transform_edt(~m, return_indices=True) + add = bb & ~m & ~keep & (d <= COVER_PX) + out = a.copy() + out[add, :3] = a[iy[add], ix[add], :3]; out[add, 3] = 1.0 + return out, int(add.sum()), int((bb & ~(out[..., 3] > 0) & ~keep).sum()) + + +def arm_free_tris(objs): + """per object the loop triangles whose vertices are all weighted < 0.5 to the arms (the robe's torso and skirt)""" + out = [] + for o in objs: + w = np.zeros(len(o.data.vertices), np.float32); gi = {o.vertex_groups[n].index for n in ("upper_arm.L", "upper_arm.R", "forearm.L", "forearm.R") if n in o.vertex_groups} + for v in o.data.vertices: + for g in v.groups: + if g.group in gi: + w[v.index] += g.weight + me = o.data; me.calc_loop_triangles() + t = np.empty(len(me.loop_triangles) * 3, np.int32); me.loop_triangles.foreach_get("vertices", t); t = t.reshape(-1, 3) + out.append(np.nonzero((w[t] < 0.5).all(1))[0]) + return out + + +def run_at(m, y, x0): + """(left, right) of the run of row y of mask m through column x0, None if x0 is not in it""" + row = m[y] + if not row[x0]: + return None + l, r = x0, x0 + while l > 0 and row[l - 1]: + l -= 1 + while r < len(row) - 1 and row[r + 1]: + r += 1 + return l, r + + +def fit_torso(body_png, p): + """TORSO scales so that the robe's torso (arm_free_tris, arms at rest) is FIT_MARGIN px wider than the drawn body on each side in FIT_ROWS (least squares of both edges)""" + bb = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0; x0 = int(GX0) + rows = [y for y in range(*FIT_ROWS) if run_at(bb, y, x0)] + tgt = np.array([run_at(bb, y, x0) for y in rows], float) + np.array([-FIT_MARGIN, FIT_MARGIN]) + sel = arm_free_tris(items) + + def widths(): + pose_arms(0, 0); tr = flat_tris(items, sel) + m = raster(to_gump(tr, p), GW, GH, 2) + return np.array([run_at(m, y, x0) or (x0, x0) for y in rows], float) + + def cost(x): + TORSO.clear(); TORSO.update(pelvis=x[0], spine=x[1], chest=x[2]) + pen = 100.0 * sum(max(0.0, 0.6 - v) ** 2 + max(0.0, v - 1.1) ** 2 for v in x) + return float(((widths() - tgt) ** 2).mean()) + pen + TORSO.clear(); before = widths() + x0v = np.ones(3); r = optimize.minimize(cost, x0v, method="Nelder-Mead", options=dict(initial_simplex=np.vstack([x0v, x0v - 0.1 * np.eye(3)]), maxiter=150, xatol=0.005, fatol=0.01)) + cost(r.x); after = widths() + return dict(scale={k: round(float(v), 3) for k, v in TORSO.items()}, rows=list(FIT_ROWS), rms_px_before=round(float(np.sqrt(((before - tgt) ** 2).mean())), 2), + rms_px_after=round(float(np.sqrt(((after - tgt) ** 2).mean())), 2), waist_px=[int(before[len(rows) // 3, 1] - before[len(rows) // 3, 0]), int(after[len(rows) // 3, 1] - after[len(rows) // 3, 0]), + int(tgt[len(rows) // 3, 1] - tgt[len(rows) // 3, 0])]) + + +def fit_body(ref, kxt_max=None): + """kx_top, kx_hem, ky, dx, dy that match the item without its sleeves (arms at rest) to the original gump without the sleeves' region""" + zone = np.zeros((GH, GW), bool); zone[ARM_ROWS[0]:ARM_ROWS[1]] = True; zone[:, int(GX0 - ARM_DX):int(GX0 + ARM_DX)] = False + pose_arms(0, 0); tr = flat_tris(items) + + def cost(x): + kxt, kxb, ky, dx, dy = x + sil = raster(to_gump(tr, x), GW, GH, 1) + return 1.0 - iou(sil & ~zone, ref & ~zone) + SCALE_W * sum(max(0.0, abs(v - 1.0) - lim) ** 2 for v, lim in ((kxt, 0.15), (kxb, 0.25), (ky, 0.08))) \ + + (SCALE_W * max(0.0, kxt - kxt_max) ** 2 if kxt_max is not None else 0.0) + x0 = np.array([1.0, 1.0, 1.0, 0.0, 0.0]); step = np.array([0.06, 0.06, 0.06, 4, 4]) + return optimize.minimize(cost, x0, method="Nelder-Mead", options=dict(initial_simplex=np.vstack([x0, x0 + np.diag(step)]), maxiter=400, xatol=0.01, fatol=1e-5)) + + +def flat_tris(objs, sel=None): + """posed triangles of `objs` in world (x, z): (n, 3, 2); sel: per object the loop triangles to keep""" + dg = bpy.context.evaluated_depsgraph_get(); out = [] + for i, o in enumerate(objs): + ev = o.evaluated_get(dg); me = ev.to_mesh(); me.calc_loop_triangles() + co = np.empty(len(me.vertices) * 3, np.float32); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3) + M = np.array(ev.matrix_world); co = co @ M[:3, :3].T + M[:3, 3] + t = np.empty(len(me.loop_triangles) * 3, np.int32); me.loop_triangles.foreach_get("vertices", t) + t = t.reshape(-1, 3) + out.append(co[t if sel is None else t[sel[i]]][:, :, [0, 2]]); ev.to_mesh_clear() + return np.concatenate(out) + + +Z_TOP, Z_HEM = 1.45, 0.15 # m: the horizontal scale goes from kx_top at the shoulders to kx_hem at the hem (a skirt wider than the original's is narrowed without pulling the + # shoulders in: with one kx the upper arm of the body gump came out bare beside the shoulder) + + +def kx_at(z, kxt, kxb): + return kxb + (kxt - kxb) * np.clip((z - Z_HEM) / (Z_TOP - Z_HEM), 0, 1) + + +def to_gump(xz, p): + """world (x, z) -> gump pixel with the fit p = (kx_top, kx_hem, ky, dx, dy) about the body axis / the floor""" + kxt, kxb, ky, dx, dy = p + return np.stack([GX0 + xz[..., 0] * PXM * kx_at(xz[..., 1], kxt, kxb) + dx, GY_FEET - xz[..., 1] * PXM * ky + dy], -1) + + +def raster(tris_px, w, h, k=2): + im = Image.new("L", (w * k, h * k), 0); dr = ImageDraw.Draw(im) + for t in tris_px * k: + dr.polygon([tuple(v) for v in t], fill=255) + return np.asarray(im.resize((w, h), Image.BOX)) >= 128 + + +def iou(a, b): + return (a & b).sum() / max((a | b).sum(), 1) + + +def fists(body_png, ref): + """centres of the two fists of a body gump: its pixels that the original item does not cover, at the height of the hands (the two biggest pieces between the shoulders and the knees)""" + b = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0 + free = b & ~ndimage.binary_dilation(ref, iterations=1); free[:85] = False; free[170:] = False + lab, n = ndimage.label(free) + sizes = ndimage.sum(free, lab, range(1, n + 1)); big = np.argsort(sizes)[::-1][:2] + 1 + c = [ndimage.center_of_mass(free, lab, k) for k in big] + return sorted([(x, y) for y, x in c]) # left on the screen first + + +def hand_points(): + """the hands (middle of the hand bone) in world (x, z), left on the screen first""" + pts = [] + for side in ("R", "L"): # the character's right hand is on the left of the screen (front view) + pb = rig.pose.bones["hand." + side]; M = rig.matrix_world + h, t = M @ pb.head, M @ pb.tail + pts.append(((h.x + t.x) / 2, (h.z + t.z) / 2)) + return np.array(pts) + + +def fit_gender(ref, fist=None, bare=None): + """arms (out, bend) and kx, ky, dx, dy that make the item's silhouette best match the original gump `ref` (alpha) and put the hands on the fists of the body gump""" + cache = {} + arm_zone = np.zeros((GH, GW), bool); arm_zone[ARM_ROWS[0]:ARM_ROWS[1]] = True; arm_zone[:, int(GX0 - ARM_DX):int(GX0 + ARM_DX)] = False + + def cost(x): + out, bend, alen, kxt, kxb, ky, dx, dy = x + key = (round(out, 1), round(bend, 1), round(alen, 3)) + if key not in cache: + pose_arms(*key); cache[key] = (flat_tris(items), hand_points()) + tr, hp = cache[key] + sil = raster(to_gump(tr, (kxt, kxb, ky, dx, dy)), GW, GH, 1) + c = 1.0 - iou(sil, ref) + ARM_W * (1.0 - iou(sil & arm_zone, ref & arm_zone)) + if bare is not None: # skin of the body gump's arms the original covers and ours does not + c += BARE_W * (bare & ~sil).sum() / max(bare.sum(), 1) + c += SCALE_W * max(0.0, abs(alen - 0.85) - 0.2) ** 2 + c += SCALE_W * sum(max(0.0, abs(v - 1.0) - lim) ** 2 for v, lim in ((kxt, 0.15), (kxb, 0.25), (ky, 0.08))) # the scales stay near the body gump's (90.5 px/m) + if fist is not None: + d = to_gump(hp, (kxt, kxb, ky, dx, dy)) - np.array(fist) + c += HAND_W * float((d ** 2).sum(1).mean()) / 100.0 + return c + best = None + for out0, bend0 in ((0, 0), (10, 25), (20, 40), (10, 60), (0, 80)): # a few starts (the cost is flat in some directions) + x0 = np.array([out0, bend0, 0.85, 1.0, 1.0, 1.0, 0.0, 0.0]); step = np.array([8, 12, 0.08, 0.06, 0.06, 0.06, 4, 4]) + r = optimize.minimize(cost, x0, method="Nelder-Mead", options=dict(initial_simplex=np.vstack([x0, x0 + np.diag(step)]), maxiter=320, xatol=0.05, fatol=1e-4)) + if best is None or r.fun < best.fun: + best = r + return best + + +def render(path, w, h, px_per_m, cx, cz, show, holdout_body): + for o in bpy.data.objects: + if o.type == "MESH": + o.hide_render = o not in show and not (o is body and holdout_body) + body.is_holdout = bool(holdout_body) + cam.data.ortho_scale = w / px_per_m + cam.location = (cx, -6.0, cz); cam.rotation_euler = (np.pi / 2, 0, 0) + sc.render.resolution_x, sc.render.resolution_y = w, h + sc.render.filepath = path + bpy.ops.render.render(write_still=True) + return np.asarray(Image.open(path).convert("RGBA"), np.float32) / 255.0 + + +def render_light(*args): + """render() of the light alone (white_albedo) with soft own shadows: the sleeves' hard shadow on the waist was a flat dark patch at the gump scale""" + ang, spp = (sun.data.angle, sc.cycles.samples) if sun is not None else (None, None) + if sun is not None: + sun.data.angle = np.radians(LIGHT_SUN_DEG); sc.cycles.samples = LIGHT_SAMPLES + try: + return render(*args) + finally: + if sun is not None: + sun.data.angle, sc.cycles.samples = ang, spp + + +def disk(r): + y, x = np.mgrid[-r:r + 1, -r:r + 1] + return x * x + y * y <= r * r + + +def compose(tex, sh, k): + """the item from two renders on the gump grid times k: tex (textured) and sh (white_albedo: the light alone). The texture's small pattern, its dark seams and stray specks of gold were + noise, black dashes and a dark patch at the gump scale; so the cloth is a flat grey times the light, and the gold a cleaned mask of the texture's gold (whole gump + pixels: a mixed edge pixel got the grey's or the gold's levels by chance, a dotted edge) times its colour times the light""" + m = tex[..., 3] > 0.5 + f = np.sqrt(m.sum() / GOLD_AREA); r = lambda v: max(1, int(round(v * f))) + sat = tex[..., :3].max(-1) - tex[..., :3].min(-1); g = m & (sat > GOLD_SAT) + lab, n = ndimage.label(g); sz = np.bincount(lab.ravel()); sz[0] = 0 + touch = np.zeros(n + 1, bool); touch[np.unique(lab[m & ~ndimage.binary_erosion(m, disk(r(GOLD_EDGE)))])] = True + keep = (sz >= GOLD_BIG * f * f) | ((sz >= GOLD_SMALL * f * f) & touch); keep[0] = False + gk = ndimage.binary_closing(keep[lab], disk(r(GOLD_CLOSE))) & m + gk = ndimage.binary_fill_holes(ndimage.binary_opening(gk, disk(r(GOLD_OPEN)))) & m + if k > 1: + h, w = gk.shape[0] // k, gk.shape[1] // k + blk = gk[:h * k, :w * k].reshape(h, k, w, k).mean((1, 3)) >= 0.5 + for _ in range(GOLD_MAJ): + blk = ndimage.median_filter(blk.astype(np.uint8), size=3).astype(bool) + gk = np.zeros_like(gk); gk[:h * k, :w * k] = np.repeat(np.repeat(blk, k, 0), k, 1) + light = sh[..., :3] @ np.array([0.299, 0.587, 0.114]) # 0.5 = a white-lit grey 0.5 albedo + alb = tex[..., :3] / np.maximum(light, 0.05)[..., None] * 0.5 + w = g.astype(np.float32); sg = GOLD_SIGMA * f; den = ndimage.gaussian_filter(w, sg) + gm = np.median(alb[g], axis=0) if g.any() else np.array([0.8, 0.6, 0.3]) + loc = np.stack([ndimage.gaussian_filter(alb[..., c] * w, sg) for c in range(3)], -1) / np.maximum(den, 1e-3)[..., None] + loc = np.where(den[..., None] > 0.05, loc, gm) + out = np.zeros_like(tex) + out[..., :3] = np.where(gk[..., None], gm * (1 - GOLD_MOTTLE) + loc * GOLD_MOTTLE, 0.5) * light[..., None] + out[..., 3] = tex[..., 3] + return np.clip(out, 0, 1) + + +LW = np.array([0.299, 0.587, 0.114]) +TRIM_ZONE, HEM_MAX = 22, 14 # gump px: the front strips within this of the body axis, the hem band at most this high +TRIM = dict(sat=1.15, collar_rows=6, edge=0.85, thread=1.08, dot=1.18, dip=0.93, tex_mix=0.5) # sym_trims(): the gold's saturation, rows of the collar kept, the embroidery's shades + + +NECK_PAD, NECK_DEPTH, COLLAR_W = 2.0, 5.0, 3.0 # neckline(): px beside the drawn neck, depth of the U at the middle (the originals: 82 -> 88), the collar band's width + + +def neckline(body_png, ax=None): + """the drawn body's neck: its narrowest row (rows 65-90) gives the neck's columns nl..nr, the shoulders' row y_sh is the first row below it at least 8 px wider; the neckline is the U + y = y_sh + NECK_DEPTH (1 - ((x - xc) / hw)^2), |x - xc| <= hw = half the neck + NECK_PAD (centred on the neck; with ax centred on ax and wide enough to clear the neck)""" + bb = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0; x0 = int(GX0) - 3 + runs = {y: run_at(bb, y, x0) or run_at(bb, y, x0 - 3) for y in range(65, 100)} + ws = {y: r[1] - r[0] + 1 for y, r in runs.items() if r} + yn = min((y for y in ws if y <= 90), key=lambda y: (ws[y], -y)); nl, nr = runs[yn] + y_sh = next(y for y in range(yn, 100) if y in ws and ws[y] >= ws[yn] + 8) + top = np.full(bb.shape[1], bb.shape[0]); cs = np.nonzero(bb.any(0))[0] + top[cs] = bb[:, cs].argmax(0) # the body's top row per column + xc = (nl + nr) / 2.0 if ax is None else float(ax) + return dict(xc=xc, hw=max(xc - nl, nr - xc) + NECK_PAD, y_sh=int(y_sh), neck=[int(nl), int(nr)], top=top) + + +def neck_opening(nk, shape): + """pixels above the neckline's U (the neck shows there)""" + Y, X = np.mgrid[0:shape[0], 0:shape[1]] + t = (X - nk["xc"]) / nk["hw"] + return (np.abs(t) <= 1) & (Y < nk["y_sh"] + NECK_DEPTH * (1 - t * t)) & (Y >= nk["y_sh"] - 8) + + +def sym_trims(a, light, P=TRIM, body_png=None): + """the gold bands redrawn on the gump grid (before the levels; a = compose() boxed down, light = the light alone boxed down): the texture's front strips were uneven (left and right of + different width, a step at the waist) -> per row the outer half-width of the pair (a straight line fitted, outliers > 1.5 px dropped) and the gap between them (0 above the row where the + front opens, its median below, 3 rows of taper) about the strips' own axis, so both strips are mirror images; the hem band's height per column smoothed (median of 7) and averaged with + its mirror column; the cuffs as the texture has them. With the body gump the collar is redrawn (the model's standing collar was two gold horns beside the neck over a ragged + dark band of its inside): the item cut away above the neckline U (neckline()) and above the body's top beside the neck, a COLLAR_W gold band under the U, the cloth around it with a + smooth light (per column the median of the 3 rows under the zone) and a soft shadow under the collar; the front strips start under it. On all gold an embroidery: edge x0.85, a light thread x1.08 inside it, on the band's middle line a dot every 4 px + (x1.18 / x0.93 between), the gold half flat (the texture's median gold, saturation x1.15) and half the texture's own (its pattern), times the light. Returns (image, measures)""" + m = a[..., 3] > 0; sat = a[..., :3].max(-1) - a[..., :3].min(-1); gold = m & (sat > 0.06) + if gold.sum() < 20: + return a, {} + H, W = m.shape; Y, X = np.mgrid[0:H, 0:W] + gm = np.median(a[gold, :3] / np.maximum(light[gold], 1e-3)[:, None], axis=0) + gl = gm @ LW; gm = np.clip(gl + P.get("sat", 1.15) * (gm - gl), 0, 1) + # hem: per column the lowest item row and the gold above it + cols = np.nonzero(m.any(0))[0]; yb = np.full(W, -1); th = np.zeros(W) + for x in cols: + ys = np.nonzero(m[:, x])[0]; yb[x] = ys.max() + g = gold[max(0, yb[x] - HEM_MAX):yb[x] + 1, x] + if g.any(): + th[x] = yb[x] - (max(0, yb[x] - HEM_MAX) + np.argmax(g)) + 1 + hem_old = m & (Y > (yb - HEM_MAX)[None, :]) & gold + # axis from the front strips: rows above the hem, gold within TRIM_ZONE of the body axis + x0 = GX0; front_old = gold & ~hem_old & (np.abs(X - x0) <= TRIM_ZONE) + rows = [y for y in range(H) if front_old[y].any()] + mids = [(np.nonzero(front_old[y])[0].min() + np.nonzero(front_old[y])[0].max()) / 2 for y in rows] + ax = round(float(np.median(mids)) * 2) / 2 if mids else x0 + # the collar: the top rows of the front gold (above the first row of a narrow strip pair) are kept as drawn; with the body gump the strips start under the redrawn collar + nk = neckline(body_png) if body_png else None + ys_f = np.array(rows); y0 = int(nk["y_sh"] + NECK_DEPTH + COLLAR_W) if nk else ys_f.min() + P.get("collar_rows", 6); y1 = int(np.median(yb[cols][np.abs(cols - ax) <= 3])) + # the strips' axis per row: from the drawn neck's centre under the collar (the head is 2-3 px left of the item's middle: a collar on the strips was lopsided against the head, one on + # the neck lopsided against the strips) to the strips' own axis at the hem, in half pixels (mirror images per row) + a_top = nk["xc"] if nk else ax + axr = lambda y: np.round((a_top + (ax - a_top) * np.clip((np.asarray(y, float) - y0) / max(y1 - y0, 1), 0, 1)) * 2) / 2 + ho, gi, yy = [], [], [] + for y in range(y0, y1 + 1): + xs = np.nonzero(front_old[y])[0] + if not len(xs): + continue + ay = ax # measured about the strips' own axis (the texture's), drawn about axr + ho.append(((ay - xs.min()) + (xs.max() - ay)) / 2) + inner = m[y] & ~gold[y]; c = int(round(ay)) + g_ = 0.0 + if inner[c] or inner[min(W - 1, c + 1)] or inner[c - 1]: + xs2 = [x for x in range(int(ay - ho[-1]), int(ay + ho[-1]) + 1) if inner[x]] + if xs2: + g_ = ((ay - min(xs2)) + (max(xs2) - ay)) / 2 + gi.append(g_); yy.append(y) + yy, ho, gi = np.array(yy), np.array(ho), np.array(gi) + # outer half-width: a straight line (two passes, outliers > 1.5 px dropped) + k = np.ones(len(yy), bool) + for _ in range(2): + c1 = np.polyfit(yy[k], ho[k], 1); k = np.abs(np.polyval(c1, yy) - ho) < 1.5 + gis = ndimage.median_filter(gi, 7) + split = yy[np.argmax(gis > 0.5)] if (gis > 0.5).any() else y1 + 1 + gw = float(np.median(gi[(yy >= split) & (gi > 0.5)])) if (gi[yy >= split] > 0.5).any() else 0.0 + d = np.abs(X - axr(Y)) + HO = np.polyval(c1, Y); GI = np.where(Y >= split, gw * np.clip((Y - split + 1) / 3.0, 0, 1), 0.0) + front = m & (Y >= y0) & (Y <= y1) & (d <= HO) & (d >= GI) + # hem band: thickness smoothed and symmetric about the axis + ths = ndimage.median_filter(th, 7); thm = ths.copy() + for x in cols: + xm = int(round(2 * ax - x)) + if 0 <= xm < W and yb[xm] >= 0 and ths[xm] > 0 and ths[x] > 0: + thm[x] = (ths[x] + ths[xm]) / 2 + hem = m & (Y > (yb - np.round(thm))[None, :]) & (thm[None, :] > 0) + zone = (np.abs(X - axr(Y)) <= TRIM_ZONE) & (Y >= y0) + other = gold & ~hem_old & ~zone + cut = np.zeros_like(m); collar = np.zeros_like(m); czone = np.zeros_like(m); info = {} + if nk: + op = neck_opening(nk, m.shape); side = np.abs(X - nk["xc"]) > nk["hw"] + cut = m & (op | (side & (Y < nk["top"][None, :]) & (Y < nk["y_sh"] + 2))) + m = m & ~cut + zb = int(nk["y_sh"] + NECK_DEPTH + 6); near = np.abs(X - nk["xc"]) <= nk["hw"] + fill = (np.abs(X - nk["xc"]) <= nk["hw"] + 8) & (Y <= zb) & ~op & np.where(near, Y >= nk["y_sh"] - 1, Y >= nk["top"][None, :]) + grow = fill & ~m; m = m | fill # the item fills the zone down from the body's shoulder line (the 3D neck cut a hole beside it) + a = a.copy(); a[grow, 3] = 1.0 + dn = ndimage.distance_transform_edt(~op) + collar = m & (dn <= COLLAR_W) & (np.abs(X - nk["xc"]) <= nk["hw"] + 2) + xmir = np.clip(np.round(2 * nk["xc"] - X).astype(int), 0, W - 1) + collar = (collar | collar[Y, xmir]) & ~op # mirror-symmetric about the strips' axis (the drawn shoulders are not level) + g2 = collar & ~m; m = m | collar; a[g2, 3] = 1.0; grow = grow | g2 + czone = m & (Y >= nk["y_sh"] - 8) & (Y <= zb) & (np.abs(X - nk["xc"]) <= nk["hw"] + 8) + other = other & ~czone + lf = light.copy(); xs_z = np.nonzero(czone.any(0))[0]; lc = np.zeros(W); wc = np.zeros(W) # the light in the zone: per column the median of the 3 rows under it, smoothed along x + for x in xs_z: + col = light[zb + 1:zb + 4, x][m[zb + 1:zb + 4, x]] + if len(col): + lc[x], wc[x] = np.median(col), 1.0 + lc = ndimage.gaussian_filter1d(lc, 3.0) / np.maximum(ndimage.gaussian_filter1d(wc, 3.0), 1e-3) + lf = np.where(czone, lc[None, :], lf) + lf = np.where(collar, max(float(np.median(lc[xs_z])), 1e-3), lf) # the collar evenly lit (the shadow of the rows under it made one half dark) + lf = np.where(czone & ~collar & (dn <= COLLAR_W + 1), lf * 0.95, lf) # a soft shadow under the collar + light = np.where(czone, lf, light) + cm = collar.copy(); cm[:, :] = collar[:, np.clip(np.round(2 * nk["xc"] - X[0]).astype(int), 0, W - 1)] + info = dict(neck=nk["neck"], shoulder_row=nk["y_sh"], centre=nk["xc"], cut_px=int(cut.sum()), collar_px=int(collar.sum()), + collar_sym_iou=round(float((collar & cm).sum() / max((collar | cm).sum(), 1)), 3), grown_px=int(grow.sum())) + new = front | hem | other | collar | (gold & ~hem_old & (Y < y0) & ~czone) + # the embroidery: a darker edge, a light thread inside it, on the band's middle line a dot every 4 px; mixed with the texture's own gold (its pattern) + dist = ndimage.distance_transform_cdt(np.pad(new, 1), metric="taxicab")[1:-1, 1:-1] + u = np.where(hem | collar, X, np.where(front, Y, X + Y)) + ridge = (dist >= 3) & (dist >= ndimage.maximum_filter(dist, 3)) + prof = np.where(dist == 1, P.get("edge", 0.85), np.where(dist == 2, P.get("thread", 1.08), 1.0)) + prof = np.where(ridge & (u % 4 == 0), P.get("dot", 1.18), np.where(ridge & (u % 4 == 2), P.get("dip", 0.93), prof)) + out = a.copy(); out[cut, 3] = 0.0 + out[m, :3] = 0.5 * light[m][:, None] + flat = gm[None, :] * light[new][:, None] + tex = np.where((gold & new & ~czone)[new][:, None], a[new, :3], flat) + mix = P.get("tex_mix", 0.5) + out[new, :3] = np.clip(((1 - mix) * flat + mix * tex) * prof[new][:, None], 0, 1) + sym = new & zone; symm = np.zeros_like(sym) + xs_, ys_ = np.nonzero(sym.T) + xm = np.round(2 * axr(ys_) - xs_).astype(int); ok = (xm >= 0) & (xm < W) + symm[ys_[ok], xm[ok]] = True + return out, dict(collar=info, axis=[float(a_top), ax], split_row=int(split), strip_halfwidth=[round(float(np.polyval(c1, y0)), 2), round(float(np.polyval(c1, y1)), 2)], gap_halfwidth=round(gw, 2), + front_sym_iou=round(float((sym & symm).sum() / max((sym | symm).sum(), 1)), 3)) + + +def sym_art(a, c0, half): + """the laid item's gold mirrored about column c0 (the item's centre) within `half` px of it: the mask's Gaussian (1.5 px) averaged with its mirror, >= 0.5 is gold; a pixel that changes + takes its mirror pixel's colour (gold) or the median of the cloth (grey)""" + m = a[..., 3] > 0.5; sat = a[..., :3].max(-1) - a[..., :3].min(-1); g = m & (sat > GOLD_SAT) + H, W = m.shape; X = np.arange(W)[None, :].repeat(H, 0); xm = np.round(2 * c0 - X).astype(int); ok = (xm >= 0) & (xm < W) + xm = np.clip(xm, 0, W - 1); Yi = np.arange(H)[:, None].repeat(W, 1) + gb = ndimage.gaussian_filter(g.astype(np.float32), 1.5); gs = (gb + gb[Yi, xm]) / 2 >= 0.5 + zone = ok & (np.abs(X - c0) <= half) & m & m[Yi, xm] + out = a.copy(); cloth = np.median(a[m & ~g, :3], axis=0) + add = zone & gs & ~g; rem = zone & ~gs & g + out[add, :3] = a[Yi[add], xm[add], :3]; out[rem, :3] = cloth + return out + + +def white_albedo(objs): + """the items' UO_Look albedo unlinked and set to a flat grey (returned to restore it): a render of the light alone""" + saved = [] + for o in objs: + for slot in o.material_slots: + m = slot.material + if m is None or m.node_tree is None: + continue + for n in m.node_tree.nodes: + if n.type == "GROUP" and n.node_tree is not None and n.node_tree.name.startswith("UO_Look"): + sock = n.inputs["Albedo"] + saved.append((m.node_tree, sock, [l.from_socket for l in sock.links], tuple(sock.default_value))) + for l in list(sock.links): + m.node_tree.links.remove(l) + sock.default_value = (0.5, 0.5, 0.5, 1.0) + return saved + + +def restore_albedo(saved): + for nt, sock, srcs, val in saved: + sock.default_value = val + for fs in srcs: + nt.links.new(fs, sock) + + +def warp(a, A, t, shape): + """premultiplied linear resampling: output pixel (y, x) samples input A @ (y, x) + t""" + pm = a.copy(); pm[..., :3] *= pm[..., 3:] + out = np.zeros(shape + (4,), np.float32) + for c in range(4): + out[..., c] = ndimage.affine_transform(pm[..., c], A, offset=t, output_shape=shape, order=1, mode="constant", cval=0.0) + out[..., :3] /= np.maximum(out[..., 3:], 1e-6) + return out + + +def box_down(a, k): + h, w = a.shape[0] // k, a.shape[1] // k + a = a[:h * k, :w * k].reshape(h, k, w, k, 4) + al = a[..., 3].mean((1, 3)) + rgb = (a[..., :3] * a[..., 3:]).sum((1, 3)) / np.maximum(a[..., 3].sum((1, 3)), 1e-6)[..., None] + out = np.zeros((h, w, 4), np.float32); out[..., :3] = rgb; out[..., 3] = (al >= 0.5) + return out + + +def match_levels(a, ref_rgba): + """grey pixels of `a` mapped (linear in luminance) so that their p50 / p95 equal those of the original's opaque pixels; coloured pixels (gold) keep their colour""" + m = a[..., 3] > 0; lum = a[..., :3] @ np.array([0.299, 0.587, 0.114]) + rm = ref_rgba[..., 3] > 0; rl = (ref_rgba[..., :3].astype(np.float32) / 255.0) @ np.array([0.299, 0.587, 0.114]) + sat = a[..., :3].max(-1) - a[..., :3].min(-1) + grey = m & (sat <= 0.06) + s5, s50, s95 = np.percentile(lum[grey], [5, 50, 95]); r5, r50, r95 = np.percentile(rl[rm], [5, 50, 95]) + new = np.clip(np.interp(lum, [0.0, s5, s50, s95, 1.0], [0.0, r5, r50, r95, min(1.0, r95 + (1 - s95) * (r95 - r50) / max(s95 - s50, 1e-4))]), 0, 1) # p5 / p50 / p95 as the original's + f = np.where(lum > 1e-4, new / np.maximum(lum, 1e-4), 1.0) + f = np.where(sat <= 0.06, f, GOLD_K * r50 / s50) # gold: one factor (its shading stays; the stretch of the grey turned it brown-black, a cap at p95 made it flat) + out = a.copy(); out[..., :3] = np.clip(a[..., :3] * f[..., None], 0, 1) + return out, (s50, s95, r50, r95) + + +def despeckle(a, k=DESPECKLE, passes=None): + """dark pixels: below DARK_FILL of the median of the item -> the colour of the cloth around (normalised Gaussian, sigma 2 px); the slits between the panels of the model and the shadows + in its folds are black lines at the gump scale (the originals are smooth); then single specks against their neighbours, DESPECKLE_PASSES times""" + m = a[..., 3] > 0; lum = a[..., :3] @ np.array([0.299, 0.587, 0.114]); tot = 0 + if DARK_FILL > 0 and m.any(): + dark = m & (lum < DARK_FILL * np.median(lum[m])); ok = (m & ~dark).astype(np.float32) + den = ndimage.gaussian_filter(ok, 2.0) + col = np.stack([ndimage.gaussian_filter(a[..., c] * ok, 2.0) for c in range(3)], -1) / np.maximum(den, 1e-6)[..., None] + fill = dark & (den > 0.05) + a = a.copy(); a[fill, :3] = col[fill]; tot += int(fill.sum()) + for _ in range(DESPECKLE_PASSES if passes is None else passes): + a, n = despeckle1(a, k); tot += n + if not n: + break + return a, tot + + +def despeckle1(a, k): + """single dark pixels (shadows of folds in the texture, much darker than the cloth around) replaced by the mean of their opaque neighbours: the original gumps are smooth""" + m = a[..., 3] > 0; lum = a[..., :3] @ np.array([0.299, 0.587, 0.114]) + pad = lambda x: np.pad(x, ((1, 1), (1, 1)) + ((0, 0),) * (x.ndim - 2)) + lm, mm, rgb = pad(np.where(m, lum, 0)), pad(m.astype(np.float32)), pad(a[..., :3] * m[..., None]) + H, W = m.shape; sh = [(y, x) for y in range(3) for x in range(3) if (y, x) != (1, 1)] + n = sum(mm[y:y + H, x:x + W] for y, x in sh) + mean_l = sum(lm[y:y + H, x:x + W] for y, x in sh) / np.maximum(n, 1) + mean_c = sum(rgb[y:y + H, x:x + W] for y, x in sh) / np.maximum(n, 1)[..., None] + bad = m & (n >= 5) & (lum < k * mean_l) + out = a.copy(); out[bad, :3] = mean_c[bad] + return out, int(bad.sum()) + + +def neck_cover(nk, shape, k=4): + """share of each pixel inside the neckline's U (neck_opening on a k x k finer grid)""" + Y, X = np.mgrid[0:shape[0] * k, 0:shape[1] * k].astype(np.float64) + Y, X = (Y + 0.5) / k - 0.5, (X + 0.5) / k - 0.5 + t = (X - nk["xc"]) / nk["hw"] + op = (np.abs(t) <= 1) & (Y < nk["y_sh"] + NECK_DEPTH * (1 - t * t)) & (Y >= nk["y_sh"] - 8) + return op.reshape(shape[0], k, shape[1], k).mean((1, 3)) + + +def soft_collar(a, body_png): + """the collar's edge against the neck anti-aliased (the black 1 px outline along it looked hard and jagged): a pixel the U covers in part is the collar's colour (its nearest whole + pixel, x0.9) mixed with the body gump's pixel there by the share the U covers; pixels the U covers up to 75 % come back into the item that way""" + nk = neckline(body_png); cov = neck_cover(nk, a.shape[:2]) + body = np.asarray(Image.open(body_png).convert("RGBA")).astype(np.float32) / 255.0 + m = a[..., 3] > 0; whole = m & (cov == 0) + _, (iy, ix) = ndimage.distance_transform_edt(~whole, return_indices=True) + near = ndimage.binary_dilation(m, iterations=1) + part = near & (cov > 0) & (cov <= 0.75) & (body[..., 3] > 0) + out = a.copy() + base = a[iy[part], ix[part], :3] * 0.9; c = cov[part][:, None] + out[part, :3] = (1 - c) * base + c * body[part, :3]; out[part, 3] = 1.0 + return out, int(part.sum()) + + +def smooth_cloth(a, no_outline=None, light_outline=None): + """the look of the original gumps: smooth grey (the texture's small pattern and the darker inner panel were noise and a dark patch at 90 px/m): the grey luminance evened against its + surroundings (FLATTEN_SIGMA px: panels of another shade come to the level of the cloth around) and softened (SOFT_SIGMA px), the item's edge rounded (a ragged cuff) and drawn as a + dark 1 px outline like the originals' (OUTLINE_K of the colour)""" + m = a[..., 3] > 0 + if not m.any(): + return a + m2 = ndimage.binary_opening(m, iterations=1) | (ndimage.binary_erosion(m, iterations=1)) # single-pixel spikes of the edge (a ragged cuff) cut off + m2 = ndimage.binary_closing(m2, iterations=1) & ndimage.binary_dilation(m, iterations=1) + m2 = ndimage.binary_fill_holes(m2) # slits between the panels inside the item: filled, no outline drawn round them + lum = a[..., :3] @ np.array([0.299, 0.587, 0.114]); sat = a[..., :3].max(-1) - a[..., :3].min(-1) + grey = m & (sat <= 0.06); g = grey.astype(np.float32) + nz = lambda x, sg: ndimage.gaussian_filter(x * g, sg) / np.maximum(ndimage.gaussian_filter(g, sg), 1e-6) + med = np.median(lum[grey]) if grey.any() else 0.5 + flat = lum * (med / np.maximum(nz(lum, FLATTEN_SIGMA), 1e-4)) ** FLATTEN_K + soft = nz(flat, SOFT_SIGMA) if SOFT_SIGMA > 0 else flat + if SHARPEN > 0: # the folds as crisp as in the original (unsharp mask, 1 px) + soft = soft + SHARPEN * (soft - nz(soft, 1.0)) + new = np.where(grey, np.clip(soft, 0, 1), lum) + f = np.where(lum > 1e-4, new / np.maximum(lum, 1e-4), 1.0) + out = a.copy(); out[..., :3] = np.clip(a[..., :3] * f[..., None], 0, 1) + grow = m2 & ~m # pixels the closing / the hole filling added: the colour around + if grow.any(): + _, (iy, ix) = ndimage.distance_transform_edt(~m, return_indices=True) + out[grow, :3] = out[iy[grow], ix[grow], :3] + out[..., 3] = m2 + out, _ = despeckle(out) # the sharpening brought the seams of the panels back as dark dashes + lum2 = out[..., :3] @ np.array([0.299, 0.587, 0.114]); inside = ndimage.binary_erosion(m2, iterations=1) + med5 = ndimage.median_filter(np.where(m2, lum2, np.nan_to_num(np.median(lum2[m2]))), size=5) + line = inside & (lum2 < LINE_K * med5) # dark lines (a dark seam of the texture along the gold bands, slits): darker than LINE_K of the 5 x 5 median + if line.any(): + medc = np.stack([ndimage.median_filter(out[..., c], size=5) for c in range(3)], -1) + out[line, :3] = medc[line] + edge = m2 & ~ndimage.binary_erosion(m2, iterations=1) + if no_outline is not None: + edge &= ~no_outline # no outline there (soft_collar blends that edge) + sat2 = out[..., :3].max(-1) - out[..., :3].min(-1) + k_ = np.where(sat2 > 0.06, OUTLINE_GOLD, OUTLINE_K) + if light_outline is not None: + k_ = np.where(light_outline, np.maximum(k_, 0.85), k_) # near the collar a lighter rim (black there framed the collar) + out[edge, :3] *= k_[edge][:, None] + return out + + +def texture_cloth(a, ref, seed=3): + """the cloth of the original gumps is painted, not smooth: local contrast of the folds (TEX_FOLDS x (blur 1 px - blur 5 px)), deeper shadows (luminance about the median to the power + TEX_GAMMA), grain and mottle (noise blurred 0.6 / 2 px, TEX_GRAIN / TEX_MOTTLE of the luminance; on the gold TEX_GOLD of it), the grey's p5 / p50 / p95 back to the original's, the gold + bands' edge TEX_BORDER darker (an embroidered band). Measured on the male gump: fine grain 0.024 -> 0.034 (original 0.020), folds 0.061 -> 0.085 (0.067), p5 0.27 -> 0.17 (0.19)""" + m = a[..., 3] > 0; inner = ndimage.binary_erosion(m, iterations=1) + sat = a[..., :3].max(-1) - a[..., :3].min(-1); gold = m & (sat > 0.06); grey = inner & ~gold + if not grey.any(): + return a + Lw = np.array([0.299, 0.587, 0.114]); lum = a[..., :3] @ Lw; rng = np.random.default_rng(seed) + new = lum + TEX_FOLDS * (ndimage.gaussian_filter(lum, 1.0) - ndimage.gaussian_filter(lum, 5.0)) + f = np.where(lum > 1e-3, new / np.maximum(lum, 1e-3), 1.0) + med = np.median(lum[grey]); f = f * np.where(grey, (np.maximum(lum, 1e-3) / med) ** (TEX_GAMMA - 1.0), 1.0) + n1 = ndimage.gaussian_filter(rng.standard_normal(lum.shape), 0.6); n2 = ndimage.gaussian_filter(rng.standard_normal(lum.shape), 2.0) + nz = TEX_GRAIN * n1 / n1.std() + TEX_MOTTLE * n2 / n2.std() + f = f * np.where(grey, 1 + nz, np.where(gold, 1 + TEX_GOLD * nz, 1.0)) + out = a.copy(); out[..., :3] = np.clip(a[..., :3] * f[..., None], 0, 1) + l2 = out[..., :3] @ Lw; rm = ref[..., 3] > 0; rl = (ref[..., :3].astype(np.float32) / 255.0) @ Lw + s5, s50, s95 = np.percentile(l2[grey], [5, 50, 95]); r5, r50, r95 = np.percentile(rl[rm], [5, 50, 95]) + tl = np.interp(l2, [0, s5, s50, s95, 1], [0, r5, r50, r95, min(1.0, r95 + (1 - s95) * (r95 - r50) / max(s95 - s50, 1e-4))]) + out[grey, :3] = np.clip(out[grey, :3] * (tl / np.maximum(l2, 1e-3))[grey][:, None], 0, 1) + e = gold & ~ndimage.binary_erosion(gold, iterations=1) & inner + out[e, :3] *= TEX_BORDER + return out + + +def to8(a): + return np.clip(a * 255 + 0.5, 0, 255).astype(np.uint8) + + +report = {} +tmp = os.path.join(OUT, "_tmp.png") +for gender, refp in REFS.items(): + if not refp: + continue + ref = np.asarray(Image.open(refp).convert("RGBA")); refm = ref[..., 3] > 0 + TORSO.clear(); pose_arms(0, 0); before = iou(raster(to_gump(flat_tris(items), (1, 1, 1, 0, 0)), GW, GH, 1), refm) + fist = fists(BODIES[gender], refm) if BODIES.get(gender) else None + bare = None + if BODIES.get(gender): + bb = np.asarray(Image.open(BODIES[gender]).convert("RGBA"))[..., 3] > 0 + bare = bb & refm; bare[:ARM_ROWS[0]] = False; bare[ARM_ROWS[1]:] = False; bare[:, int(GX0 - ARM_DX):int(GX0 + ARM_DX)] = False + if ARM_MODE == "joints" and gender in JOINTS: + TORSO.clear(); torso = None + r = fit_body(refm); kxt, kxb, ky, dx, dy = r.x; P = tuple(r.x) + if FIT_TORSO.get(gender) and BODIES.get(gender): # fitted torso, then the scale / shift again (no widening at the shoulders), the torso once more for that + P = (min(kxt, 1.0), kxb, ky, dx, dy); fit_torso(BODIES[gender], P) + r = fit_body(refm, 1.0); kxt, kxb, ky, dx, dy = r.x; P = tuple(r.x); torso = fit_torso(BODIES[gender], P) + uw = UPPER_W.get(gender, 1.0) + wr, ce, gp, ov = fit_cuffs(JOINTS[gender], P, BODIES[gender], refm, uw) if BODIES.get(gender) else (None, None, None, None) + arms = pose_arms_to(JOINTS[gender], P, wr, uw); out_deg = bend = alen = float("nan") + arms = dict(arms, forearm_target=wr, cuff_error_px=ce, forearm_bare_px=gp, hand_covered_px=ov, torso=torso) + else: + r = fit_gender(refm, fist, bare); out_deg, bend, alen, kxt, kxb, ky, dx, dy = r.x; P = (kxt, kxb, ky, dx, dy) + pose_arms(round(out_deg, 1), round(bend, 1), round(alen, 3)); arms = None + # render at the gump scale times SS with one uniform scale k0, then the rest of the fit as a resampling (map_coordinates: the horizontal scale depends on the height) + k0 = min(kxt, kxb, ky); W, H = GW * SS, GH * SS + cxw = (GW / 2 - GX0) / PXM; czw = (GY_FEET - GH / 2) / PXM + a = render(tmp, W, H, PXM * SS * k0, cxw / k0, czw / k0, items, True) + saved = white_albedo(items); sh = render_light(tmp, W, H, PXM * SS * k0, cxw / k0, czw / k0, items, True); restore_albedo(saved) + # the render maps world (x, z) to SS * (GX0 + x PXM k0, GY_FEET - z PXM k0) (camera centred so that the body axis / floor land there); the wanted map uses kx, ky and the shift + Yo, Xo = np.mgrid[0:H, 0:W].astype(np.float64) + 0.5 + z = (SS * (GY_FEET + dy) - Yo) / (SS * PXM * ky); xw = (Xo - SS * (GX0 + dx)) / (SS * PXM * kx_at(z, kxt, kxb)) + Yi, Xi = SS * GY_FEET - z * SS * PXM * k0 - 0.5, SS * GX0 + xw * SS * PXM * k0 - 0.5 + pm = a.copy(); pm[..., :3] *= pm[..., 3:] + wa = np.stack([ndimage.map_coordinates(pm[..., c], [Yi, Xi], order=1, mode="constant", cval=0.0) for c in range(4)], -1).astype(np.float32) + wa[..., :3] /= np.maximum(wa[..., 3:], 1e-6) + psh = sh.copy(); psh[..., :3] *= psh[..., 3:] + wsh = np.stack([ndimage.map_coordinates(psh[..., c], [Yi, Xi], order=1, mode="constant", cval=0.0) for c in range(4)], -1).astype(np.float32) + wsh[..., :3] /= np.maximum(wsh[..., 3:], 1e-6) + if os.environ.get("PD_SAVE_RAW"): + np.save(os.path.join(OUT, "raw_%s.npy" % gender), wa); np.save(os.path.join(OUT, "shade_%s.npy" % gender), wsh) + res = box_down(compose(wa, wsh, SS), SS) + res, trims = sym_trims(res, box_down(wsh, SS)[..., :3] @ LW, body_png=BODIES.get(gender)) + res, lv = match_levels(res, ref) + res, nsp = despeckle(res) if DESPECKLE > 0 else (res, 0) # after the levels: their stretch made the darker folds black again + covered, skin_left = 0, None + if BODIES.get(gender): # before smooth_cloth: the outline goes round the grown edge + res, covered, skin_left = cover_body(res, BODIES[gender], refm, JOINTS.get(gender) if ARM_MODE == "joints" else None, neck=True) + nop = neck_opening(neckline(BODIES[gender]), res.shape[:2]) if BODIES.get(gender) else None + res = texture_cloth(smooth_cloth(res, *((ndimage.binary_dilation(nop, iterations=2), ndimage.binary_dilation(nop, iterations=6)) if nop is not None else ())), ref) + if BODIES.get(gender): + res, trims["collar"]["soft_px"] = soft_collar(res, BODIES[gender]) + Image.fromarray(to8(res)).save(os.path.join(OUT, "gump_%s.png" % gender)) + after = iou(res[..., 3] > 0, refm) + hand_px = to_gump(hand_points(), P) + hand_err = [round(float(np.hypot(*(hp - np.array(f)))), 1) for hp, f in zip(hand_px, fist)] if fist else None + bare_left = int((bare & ~(res[..., 3] > 0)).sum()) if bare is not None else None + report[gender] = dict(arm_mode=ARM_MODE, arms_deg=arms, arms_out=round(out_deg, 1), elbow_bend=round(bend, 1), arm_length=round(alen, 3), bare_arm_px=bare_left, covered_px=covered, skin_left_px=skin_left, kx_top=round(kxt, 3), kx_hem=round(kxb, 3), ky=round(ky, 3), dx=round(dx, 1), dy=round(dy, 1), + trims=trims, iou_rest=round(before, 3), iou_fit_flat=round(1 - r.fun, 3), iou_render=round(after, 3), levels=[round(float(v), 3) for v in lv], specks=nsp, fists=[[round(float(v), 1) for v in f] for f in fist] if fist else None, hand_error_px=hand_err) + print("GUMP %s: %s" % (gender, report[gender]), flush=True) + +if ART_REF: # the item laid flat: arms out at a right angle, front view, turned 45 deg (the collar up right, the hem down left, as the original icon), fitted to its box + ref = np.asarray(Image.open(ART_REF).convert("RGBA")); ys, xs = np.nonzero(ref[..., 3] > 0) + rb = (xs.min(), ys.min(), xs.max(), ys.max()); AW, AH = ref.shape[1], ref.shape[0] + TORSO.clear(); pose_arms(ART_ARMS, 0, 1.0) + tr = flat_tris(items); lo = tr.reshape(-1, 2).min(0); hi = tr.reshape(-1, 2).max(0) + ppm = 400.0 + w, h = int((hi[0] - lo[0]) * ppm) + 8, int((hi[1] - lo[1]) * ppm) + 8 + a = render(tmp, w, h, ppm, (lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, items, False) # no holdout: the head would cut a hole in the collar + saved = white_albedo(items); sh = render_light(tmp, w, h, ppm, (lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, items, False); restore_albedo(saved) + if os.environ.get("PD_SAVE_RAW"): + np.save(os.path.join(OUT, "rawrot_art.npy"), a); np.save(os.path.join(OUT, "shaderot_art.npy"), sh) + def laid(x, box=None): # turned ART_TURN, cropped (to the textured render's box), stretched to the original's box at S4 times its pixels + pm = x.copy(); pm[..., :3] *= pm[..., 3:] + im = Image.fromarray(np.clip(pm * 255 + 0.5, 0, 255).astype(np.uint8), "RGBA").rotate(ART_TURN, resample=Image.BICUBIC, expand=True) + rot = np.asarray(im, np.float32) / 255.0 + if box is None: + yy, xx = np.nonzero(rot[..., 3] > 0.5); box = (yy.min(), yy.max() + 1, xx.min(), xx.max() + 1) + rot = rot[box[0]:box[1], box[2]:box[3]] + rs = np.stack([np.asarray(Image.fromarray(rot[..., c]).resize((bw, bh), Image.BILINEAR)) for c in range(4)], -1) + rs[..., :3] /= np.maximum(rs[..., 3:], 1e-6) + big = np.zeros((AH * S4, AW * S4, 4), np.float32); big[rb[1] * S4:rb[1] * S4 + bh, rb[0] * S4:rb[0] * S4 + bw] = rs + return big, box + S4 = 4; bw, bh = (rb[2] - rb[0] + 1) * S4, (rb[3] - rb[1] + 1) * S4 # stretched to the original's box (its proportions are those of a laid robe) + a = sym_art(a, w / 2 - (lo[0] + hi[0]) / 2 * ppm, 0.12 * ppm) # symmetric before the turn (the front strips about the item's centre line) + big, box = laid(a); bigsh, _ = laid(sh, box) + big = compose(big, bigsh, S4) + if os.environ.get("PD_SAVE_RAW"): + np.save(os.path.join(OUT, "raw_art.npy"), big) + art = box_down(big, S4) + art, lv = match_levels(art, ref) + art, nsp = despeckle(art) if DESPECKLE > 0 else (art, 0) + art = texture_cloth(smooth_cloth(art), ref) + Image.fromarray(to8(art)).save(os.path.join(OUT, "art.png")) + Image.fromarray(to8(art[:, ::-1])).save(os.path.join(OUT, "art_mirror.png")) + ys2, xs2 = np.nonzero(art[..., 3] > 0) + report["art"] = dict(turn=ART_TURN, box_ref=[int(v) for v in rb], box=[int(xs2.min()), int(ys2.min()), int(xs2.max()), int(ys2.max())], iou=round(iou(art[..., 3] > 0, ref[..., 3] > 0), 3)) + print("ART: %s" % report["art"], flush=True) +json.dump(report, open(os.path.join(OUT, "fit.json"), "w"), indent=1) +if os.path.exists(tmp): + os.remove(tmp) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_render_gump_ragged.py b/third_party/UO_Model3D_v13/pipeline/uo_render_gump_ragged.py new file mode 100644 index 0000000..307f4c4 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_render_gump_ragged.py @@ -0,0 +1,173 @@ +"""Paperdoll gumps (male / female) and the item icon (art) of the torn robe: a plain grey robe with holes on purpose, a hood lying on the back, no gold. + + PD_OUT=DIR PD_REF_MALE=50469.png PD_REF_FEMALE=60469.png PD_BODY_MALE=12.png PD_BODY_FEMALE=13.png PD_ART_REF=7939.png \ + python run_script_in_blend.py ITEM.blend uo_render_gump_ragged.py + +The FIT is that of uo_render_gump_fit.py (its definitions are loaded from that file, its own main part is not run and the file is not changed): per gender the scale x (shoulders / hem) and y +to the silhouette of the original robe's gump (50469 / 60469) without the sleeves' region, the woman's torso narrowed to the drawn body 13 (FIT_TORSO, the upper sleeve 0.7 wide), each upper +arm along shoulder -> elbow of the drawn body (JOINTS of gumps 12 / 13) and each forearm aimed until the sleeve's mouth ends on the drawn wrist. +The LOOK is this robe's own (that script's compose / sym_trims / smooth_cloth / texture_cloth are made for a robe with gold bands and a standing collar, and they fill every hole): + - the textured render with the body as a holdout (holes over the body are transparent: the paperdoll body shows through them, as in the animation), box-filtered from SS x SS; + - the grey mapped to the original gump's levels (p5 / p50 / p95 of its luminance: the standard robe grey that takes a hue); + - dark specks and slits darker than DARK of the cloth around (the inside of the robe seen through a hole where no body is behind) kept: they read as the holes; single dark pixels evened; + - the body gump's skin within COVER_PX of the robe taken in (a bare shoulder / forearm next to the sleeve), not inside the robe's holes, not the hands, head, feet; + - a line where a sleeve meets the torso (a render of the uo_region labels, like render_uo_layer.py INNER_LINE), the outline 1 px darker (OUTLINE_K), 1-px spikes of the ragged hem cut. +Art: the robe laid flat (arms out ART_ARMS, front view), turned ART_TURN, stretched into the box of the original icon (PD_ART_REF), the same look; the mirrored icon too. +Writes gump_male.png, gump_female.png, art.png, art_mirror.png (and the same as .bin: a Gumpart.mul / art.mul entry), fit.json in PD_OUT. +""" +import json, os +import numpy as np +from PIL import Image +from scipy import ndimage + +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(globals().get("__file__") or sys.argv[2]))) +import uo_gump_art as ga +HERE = os.path.dirname(os.path.abspath(globals().get("__file__") or sys.argv[2])) # run_script_in_blend.py passes no __file__: the script is its second argument +_src = open(os.path.join(HERE, "uo_render_gump_fit.py"), encoding="utf-8").read() +exec(compile(_src[:_src.index("\nreport = {}")], "uo_render_gump_fit.py", "exec"), globals()) # settings, scene setup and the fit functions only + +LINE_K = float(os.environ.get("PD_LINE_K", "0.75")) # the sleeve / torso line (render_uo_layer.py INNER_LINE of this robe: 0.75) +OUTLINE_K = float(os.environ.get("PD_OUTLINE_K", "0.55")) +DARK = float(os.environ.get("PD_DARK", "0.3")) # pixels darker than this share of the cloth's median are a hole's inside: kept dark (not evened away) +ART_ARMS = float(os.environ.get("PD_ART_ARMS", "0")) # deg the arms are raised for the laid robe: 0 = as at rest (40 deg from the vertical); 15 spread them wide (icon IoU 0.67, at 0: 0.74) +ART_MIRROR = os.environ.get("PD_ART_MIRROR", "0") == "1" # 1 = the icon mirrored (a model laid the other way round) +HOLE_TOP = int(os.environ.get("PD_HOLE_TOP", "120")) # gump row: gaps in the robe above it are filled (shoulders / sleeve roots), below it the rips stay open +SHOULDER_ROWS, SHOULDER_PX = (84, 104), 10 # gump rows of the shoulders and how far from the robe their skin is covered +NECK_X, NECK_HALF = 92, 7 # the drawn neck (bodies 12 / 13: columns 85-97 / 86-94): its skin stays (the V-neck) +SPECK_K = 0.6 # single pixels darker than this share of their neighbours: evened + + +def region_labels(w, h, ppm, cx, cz, holdout): + """per pixel: 1 = a sleeve of the robe (uo_region 1-4), 0 = its torso part, -1 = no robe (a render of the labels, flat emission)""" + obs = [o for o in items if "uo_region" in o.data.attributes] + if not obs: + return None + m = bpy.data.materials.new("pd_region"); m.use_nodes = True; nt = m.node_tree; nt.nodes.clear() + at = nt.nodes.new("ShaderNodeAttribute"); at.attribute_type = "GEOMETRY"; at.attribute_name = "uo_region" + em = nt.nodes.new("ShaderNodeEmission"); out = nt.nodes.new("ShaderNodeOutputMaterial") + nt.links.new(at.outputs["Fac"], em.inputs["Strength"]); em.inputs["Color"].default_value = (0.25, 0.25, 0.25, 1); nt.links.new(em.outputs[0], out.inputs["Surface"]) + keep = {o.name: [sl.material for sl in o.material_slots] for o in obs} + for o in obs: + for sl in o.material_slots: + sl.material = m + try: + a = render(tmp, w, h, ppm, cx, cz, items, holdout) + finally: + for o in obs: + for sl, mt in zip(o.material_slots, keep[o.name]): + sl.material = mt + lab = np.full(a.shape[:2], -1, np.int8); on = a[..., 3] > 0.5 + lab[on] = (a[..., 0][on] > 0.12).astype(np.int8) # 0.25 x label 0..4 in Standard view: the torso 0, a sleeve >= 0.25 + return lab + + +def holes_of(m): + return ndimage.binary_fill_holes(m) & ~m + + +def finish(a, ref, lab=None): + """levels, specks, sleeve line, ragged edge, outline (a: RGBA float on the target grid; lab: region labels on the same grid)""" + m = a[..., 3] > 0 + lv, _ = match_levels(a, ref); out = lv + lum = lv[..., :3] @ LW; med = np.median(lum[m]) + hole_dark = m & (lum < DARK * med) # the inside of the robe seen through a hole: stays + for _ in range(3): # single dark pixels (the texture's grain at the gump scale) + out, n = despeckle1(out, SPECK_K) + if not n: + break + out[hole_dark, :3] = lv[hole_dark, :3] + if lab is not None: # a sleeve over the torso: the torso pixels next to it darker + line = m & (lab == 0) & ndimage.binary_dilation(m & (lab == 1), iterations=1) + out[line, :3] *= LINE_K + m2 = (ndimage.binary_opening(m, iterations=1) | ndimage.binary_erosion(m, iterations=1)) & m # 1-px spikes of the ragged hem cut off + out[..., 3] = m2 + edge = m2 & ~ndimage.binary_erosion(m2, iterations=1, border_value=0) + out[edge, :3] *= OUTLINE_K + return out + + +def cover_skin(a, body_png, ref, joints): + """cover_body() of uo_render_gump_fit.py, but the robe's holes stay open (the body shows through them on purpose)""" + m = a[..., 3] > 0; hl = holes_of(m) + up = hl.copy(); up[HOLE_TOP:] = False # above the waist the robe has no holes (closed on import): a gap there is where the sleeve + if up.any(): # leaves the torso in the gump's arm pose -> the cloth around + _, (iy, ix) = ndimage.distance_transform_edt(~m, return_indices=True) + a = a.copy(); a[up, :3] = a[iy[up], ix[up], :3]; a[up, 3] = 1.0; hl &= ~up + out, n, left = cover_body(a, body_png, ref, joints, neck=False) + back = hl & (out[..., 3] > 0) & ~(a[..., 3] > 0) + out[back, 3] = 0.0 + bb = np.asarray(Image.open(body_png).convert("RGBA"))[..., 3] > 0 # the shoulders: the drawn body (13) is broader there than the robe -> its skin up to SHOULDER_PX + m2 = out[..., 3] > 0; d, (iy, ix) = ndimage.distance_transform_edt(~m2, return_indices=True) # from the robe, outside the neck, taken in + Y, X = np.mgrid[0:GH, 0:GW] + sh = bb & ~m2 & (d <= SHOULDER_PX) & (Y >= SHOULDER_ROWS[0]) & (Y <= SHOULDER_ROWS[1]) & (np.abs(X - NECK_X) > NECK_HALF) + out[sh, :3] = out[iy[sh], ix[sh], :3]; out[sh, 3] = 1.0 + return out, n - int(back.sum()) + int(sh.sum()), left - int(sh.sum()) + + +report = {} +tmp = os.path.join(OUT, "_tmp.png") +for gender, refp in REFS.items(): + if not refp: + continue + ref = np.asarray(Image.open(refp).convert("RGBA")); refm = ref[..., 3] > 0 + TORSO.clear(); pose_arms(0, 0); before = iou(raster(to_gump(flat_tris(items), (1, 1, 1, 0, 0)), GW, GH, 1), refm) + r = fit_body(refm); kxt, kxb, ky, dx, dy = r.x; P = tuple(r.x); torso = None + if FIT_TORSO.get(gender) and BODIES.get(gender): # the woman: the torso fitted to the drawn body 13, the scale / shift again, the torso once more + P = (min(kxt, 1.0), kxb, ky, dx, dy); fit_torso(BODIES[gender], P) + r = fit_body(refm, 1.0); kxt, kxb, ky, dx, dy = r.x; P = tuple(r.x); torso = fit_torso(BODIES[gender], P) + uw = UPPER_W.get(gender, 1.0) + wr, ce, gp, ov = fit_cuffs(JOINTS[gender], P, BODIES[gender], refm, uw) + arms = pose_arms_to(JOINTS[gender], P, wr, uw) + k0 = min(kxt, kxb, ky); W, H = GW * SS, GH * SS + cxw = (GW / 2 - GX0) / PXM; czw = (GY_FEET - GH / 2) / PXM + a = render(tmp, W, H, PXM * SS * k0, cxw / k0, czw / k0, items, True) + lab = region_labels(W, H, PXM * SS * k0, cxw / k0, czw / k0, True) + Yo, Xo = np.mgrid[0:H, 0:W].astype(np.float64) + 0.5 # world (x, z) -> gump with kx(z), ky, the shift (as uo_render_gump_fit.py) + z = (SS * (GY_FEET + dy) - Yo) / (SS * PXM * ky); xw = (Xo - SS * (GX0 + dx)) / (SS * PXM * kx_at(z, kxt, kxb)) + Yi, Xi = SS * GY_FEET - z * SS * PXM * k0 - 0.5, SS * GX0 + xw * SS * PXM * k0 - 0.5 + pm = a.copy(); pm[..., :3] *= pm[..., 3:] + wa = np.stack([ndimage.map_coordinates(pm[..., c], [Yi, Xi], order=1, mode="constant", cval=0.0) for c in range(4)], -1).astype(np.float32) + wa[..., :3] /= np.maximum(wa[..., 3:], 1e-6) + res = box_down(wa, SS) + labg = None + if lab is not None: + lw = ndimage.map_coordinates(lab.astype(np.float32), [Yi, Xi], order=0, mode="constant", cval=-1) + h_, w_ = GH, GW; lb = lw[:h_ * SS, :w_ * SS].reshape(h_, SS, w_, SS) + labg = np.where((lb == 1).mean((1, 3)) >= 0.5, 1, np.where((lb == 0).mean((1, 3)) > 0, 0, -1)).astype(np.int8) + res, covered, skin_left = cover_skin(res, BODIES[gender], refm, JOINTS[gender]) + res = finish(res, ref, labg) + Image.fromarray(to8(res)).save(os.path.join(OUT, "gump_%s.png" % gender)) + data, extra = ga.encode_gump(to8(res)); open(os.path.join(OUT, "gump_%s.bin" % gender), "wb").write(data) # a Gumpart.mul entry (Gumpidx.mul extra = width << 16 | height) + m = res[..., 3] > 0 + report[gender] = dict(kx_top=round(kxt, 3), kx_hem=round(kxb, 3), ky=round(ky, 3), dx=round(dx, 1), dy=round(dy, 1), torso=torso, arms_deg=arms, forearm_target=wr, cuff_error_px=ce, + forearm_bare_px=gp, hand_covered_px=ov, covered_px=covered, skin_left_px=skin_left, holes_px=int(holes_of(m).sum()), + iou_rest=round(before, 3), iou_render=round(iou(m, refm), 3)) + print("GUMP %s: %s" % (gender, report[gender]), flush=True) + +if ART_REF: # the robe laid flat, turned, fitted to the box of the original icon + ref = np.asarray(Image.open(ART_REF).convert("RGBA")); ys, xs = np.nonzero(ref[..., 3] > 0) + rb = (xs.min(), ys.min(), xs.max(), ys.max()); AW, AH = ref.shape[1], ref.shape[0] + TORSO.clear(); pose_arms(ART_ARMS, 0, 1.0) + tr = flat_tris(items); lo = tr.reshape(-1, 2).min(0); hi = tr.reshape(-1, 2).max(0) + ppm = 400.0; w, h = int((hi[0] - lo[0]) * ppm) + 8, int((hi[1] - lo[1]) * ppm) + 8 + a = render(tmp, w, h, ppm, (lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, items, False) + S4 = 4; bw, bh = (rb[2] - rb[0] + 1) * S4, (rb[3] - rb[1] + 1) * S4 + pm = a.copy(); pm[..., :3] *= pm[..., 3:] + im = Image.fromarray(np.clip(pm * 255 + 0.5, 0, 255).astype(np.uint8), "RGBA").rotate(ART_TURN, resample=Image.BICUBIC, expand=True) + rot = np.asarray(im, np.float32) / 255.0; yy, xx = np.nonzero(rot[..., 3] > 0.5); rot = rot[yy.min():yy.max() + 1, xx.min():xx.max() + 1] + rs = np.stack([np.asarray(Image.fromarray(rot[..., c]).resize((bw, bh), Image.BILINEAR)) for c in range(4)], -1) + rs[..., :3] /= np.maximum(rs[..., 3:], 1e-6) + big = np.zeros((AH * S4, AW * S4, 4), np.float32); big[rb[1] * S4:rb[1] * S4 + bh, rb[0] * S4:rb[0] * S4 + bw] = rs + art = finish(box_down(big, S4), ref) + if ART_MIRROR: + art = art[:, ::-1].copy() + Image.fromarray(to8(art)).save(os.path.join(OUT, "art.png")) + Image.fromarray(to8(art[:, ::-1])).save(os.path.join(OUT, "art_mirror.png")) + for nm, im_ in (("art", art), ("art_mirror", art[:, ::-1])): # art.mul entries + open(os.path.join(OUT, nm + ".bin"), "wb").write(ga.encode_art(to8(im_))) + report["art"] = dict(turn=ART_TURN, box_ref=[int(v) for v in rb], iou=round(iou(art[..., 3] > 0, ref[..., 3] > 0), 3)) + print("ART: %s" % report["art"], flush=True) +json.dump(report, open(os.path.join(OUT, "fit.json"), "w"), indent=1, default=str) +if os.path.exists(tmp): + os.remove(tmp) diff --git a/third_party/UO_Model3D_v13/pipeline/uo_render_weapon_gump.py b/third_party/UO_Model3D_v13/pipeline/uo_render_weapon_gump.py new file mode 100644 index 0000000..e1e1244 --- /dev/null +++ b/third_party/UO_Model3D_v13/pipeline/uo_render_weapon_gump.py @@ -0,0 +1,364 @@ +"""Paperdoll gumps (male 50000 + AnimID on body gump 12, female 60000 + AnimID on body gump 13) and item art (art.mul, two IDs: the second one mirrored) +of a one-handed weapon, rendered from the same 3D model as its animation (ITEM.blend from uo_make_item.py; a flail from uo_flail.py has its chain posed here). + + python uo_render_weapon_gump.py ITEM.blend OUT_DIR [--set NAME=VALUE ...] + +Gump: front view, orthographic, 90.5 px/m (body gump 12: head top y 54 .. feet y 221 = 1.855 m), the haft upright in the right hand (on the left of the picture) like the +client's own weapon gumps: the grip on the haft line of mace / war mace / maul (gumps 50631 / 50642 / 50633: x 47.5) at the middle of the fist (male y 114; the fist of +the female body 13 is ~1.5 px higher). The weapon leans TILT out from the body; a flail's chain hangs free (straight down) from the top of the haft, the ball beside the haft above the fist. +The haft is cut out under the body gump's fist except its bottom rows (HOLD_CUT, HOLD_SHOW), so the fist holds it. The weapon gumps of the client have no female version (60000 + AnimID does not exist, the client uses the male one). +Art: the weapon lying on the ground seen from above (UO draws items top-down with the map turned 45 deg), the haft along the picture's diagonal (butt bottom left), the +chain turning ART_CURL from its line, a dark rim around (ART_RIM); the second art is its mirror (the client's weapons come in pairs: mace 0xF5C / 0xF5D). +Colours: the metal (chain, ball, grey fittings) grey only (R = G = B), the wooden haft brown like the client's wooden hafts (WOOD_TINT, WOOD_LEVELS). The client dyes +an item by indexing the hue's 32 colours with each pixel's red channel (all pixels; with the tiledata flag PartialHue 0x00040000 only the grey ones): grey metal takes +the hue by its level, the wood stays wood with PartialHue (uo_hue_preview.py shows both; the animation's metal: uo_grey_metal.py). Gump edge pixels darkened by OUTLINE. +Writes gump_male.png, gump_female.png, art.png, art_mirror.png (RGBA) and the mul entries *.bin (uo_gump_art.py). +Light, materials: UO_Look as render_uo_layer.py, 1 sample per pixel; SS x SS supersampling box-averaged. +""" +import json, os, sys +import numpy as np +import bpy +from mathutils import Vector, Matrix + +HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE) +PXM = 90.5 # gump px per m (body gump 12) +GW, GH = 260, 237 +GRIP = {"male": (48.0, 114.5), "female": (48.0, 113.0)} # gump pixel (continuous, pixel i spans i..i+1) of the grip: the haft line of the client's maces, the middle of the fist +SS = 3 +ROLL = 0.0 # deg: the weapon turned about its haft (which side faces the viewer) +THIN = (0.9, 0.8, 0.9) # gump: the haft's and the chain's cross-section scaled by the first two, the ball by the third (about where the chain holds it): the animation + # needs a thick haft (7.5 cm: 2.7 px at 36 px/m), the gump is 2.5 x larger (6.8 px; the client's maces 3-5 px); 0.5 gave a 2 px line, + # 0.7 a 3.8 px haft ("a weapon for children", user: 1 px thicker) +HAFT_LEN = 1.56 # gump: the haft x this long (the knob, KNOB m at the butt, kept; the shaft above it stretched; the chain and the ball move with its end): user, +30 %, + # then +20 % more (1.3 x 1.2) +KNOB = 0.09 +GRIP_AT = 0.22 # gump: the hand holds the haft this far (m) above the butt (the animation: 0.06); "it need not hold the very end", "somewhere above the end" (user): + # 0.15 showed only the knob under the fist; 0.22 / 0.25 / 0.28 compared: wood between the fist and the knob, from 0.25 the ball reaches the fist +ART_HAFT_LEN = 1.56 # art: as HAFT_LEN (the grip does not matter on the ground); 1.0 / 1.56 shown to the user, the gump's length taken +ART_THIN = (1.0, 0.8, 1.4) # art: as THIN (with the rim the haft is 3-4 px like the client's mace / war hammer; 1.5 / 1.6 / 1.3 was a tube); the chain with LINK_PX: 1.6 thick + # links, "thinner, so the gaps between them show" (user): 0.8 / 1.0 / 1.2 compared with dark gaps +ART_OUTLINE = 1.0 # art: no darkened edge inside, a dark rim outside instead (ART_RIM grey level; the client's arts: 2-8; 16 = no true black) +ART_RIM = 16.0 +TILT = 20.0 # deg: the gump weapon leans out from the body (its top to the left of the picture), so a flail's chain hangs free beside the haft (user); + # 10 / 15 / 20 compared: the ball (0.4 m of chain and ball under a 0.5 m haft) ends at the height of the fist and lay on it at 10 and 15 +DRAPE = () # a flail's chain on the gump: (x, z) direction of the first pieces (x < 0 = outwards), then straight down (() = hanging free; with an upright + # haft it was ((-1, -0.2), (-1, -0.7), (-0.5, -1)): an arch over the outer side) +HOLD_CUT = (8, 9, 6) # the window of the fist around the grip (x within +-8 px, y from 9 above to 6 below): the haft is cut out where the body gump's fist lies in it, +HOLD_SHOW = {"male": 2, "female": 3} # but the haft shows through the bottom HOLD_SHOW rows of the fist (counted up from the fist's lower edge in each column; 0 = hidden + # under the whole fist): "let the stick show through the hand a little" (user). The client's weapon gumps leave out the haft over the top 5 rows + # of the fist (male 109-113) and draw it over the rest; that suited the female fist (8 rows high at the haft, user: ok) but on the male one + # (9 rows) the haft started in the middle of the fist, pasted on ("cut out ugly", user); male 3 / 2 / 1 compared. Body gumps from CLIENT +CLIENT = os.path.join(HERE, "..", "uo_client", "NelderimServUO") +BODY = {"male": 12, "female": 13} +ART_PXM = 34.0 # art px per m (the client's maces: 0.7-1 m long weapons are 28-38 px along the diagonal) +ART_CURL = 125.0 # deg: the chain on the ground turns this much from the haft line (35 / 60 / 90 compared: at 90 the chain and the ball stand apart from the haft; + # "bent like an A, not an L" (user): 110 / 125 / 140 compared with a sharp bend) +ART_APEX = 2 # the turn made over the first ART_APEX pieces of the chain, then straight (0 = spread over the whole chain: a round hook) +ART_CHAIN_LEN = 1.25 # art: the chain x this long (its links longer; "a little longer", user) +LINK_PX = 3.0 # art: draw the chain's links: every LINK_PX px along the chain the last LINK_GAP of it darkened x LINK_DARK (0 = as rendered) +LINK_GAP = 0.34 +LINK_DARK = 0.0 # (<= 0: the gap takes the rim's grey, ART_RIM: the gaps read as breaks between the links; x0.45 / x0.3 left a grain) +PART_GAIN = (1.0, 0.85, 1.1) # a flail: the grey of the haft / chain / ball relative to each other (each part's median first brought to one level: the wooden haft's luma + # was 2 x the metal's, a white board on the gump) +ART_W = 44 +LEVELS = (48.0, 105.0, 165.0) # metal: output grey at the 10th / 50th / 90th percentile (gump); the client's grey maces: 16-24 / 41-65 / 82-169 +ART_LEVELS = (60.0, 115.0, 170.0) +WOOD_LEVELS = (24.0, 56.0, 86.0) # the wooden haft: output luma at its 10th / 50th / 90th percentile; the client's wooden hafts (war hammer, hammer pick, club gumps): 17-25 / 42-58 / 65-91 +ART_WOOD_LEVELS = (40.0, 66.0, 100.0) # (their arts: 7-59 / 45-76 / 83-129) +WOOD_TINT = (1.45, 0.86, 0.50) # wood colour as R : G : B over luma (the same weapons: 1.38-1.52 / 0.84-0.89 / 0.42-0.57; our animation's haft 1.28 / 0.92 / 0.65) +WOOD_CHROMA = 0.25 # a haft pixel is wood when (max - min of its RGB) > this x its luma in the render; greyer pixels are metal fittings +OUTLINE = 0.45 # the edge pixels (next to transparency) multiplied by this (the client's arts have a dark rim) +LEVEL_MAX = 210.0 # no white + + +def setup_render(sc): + sc.render.engine = "CYCLES"; sc.cycles.device = "CPU"; sc.cycles.samples = 1; sc.cycles.use_denoising = False + sc.cycles.pixel_filter_type = "BOX"; sc.cycles.filter_width = 0.01; sc.render.dither_intensity = 0.0 + sc.render.film_transparent = True; sc.view_settings.view_transform = "Standard" + sc.render.image_settings.file_format = "PNG"; sc.render.image_settings.color_mode = "RGBA" + sc["uo_look"] = 1.0; sc["uo_exact"] = 0.0; sc.render.resolution_percentage = 100 + g = bpy.data.node_groups.get("UO_Look_NS") + if g is not None: # the Sun of the own shadow (as uo_render_paperdoll.py): the UO light direction, no bounces + Lv = Vector(next(n for n in g.nodes if n.type == "COMBXYZ").inputs[i].default_value for i in range(3)) + ld = bpy.data.lights.new("UO_Sun", "SUN"); sun = bpy.data.objects.new("UO_Sun", ld); sc.collection.objects.link(sun) + ld.angle = 0.0; ld.energy = 3.14159265 * (1.0 - g["uo_ambient"]) * (1.0 - g["uo_shadow"]) + sun.rotation_euler = Vector((0, 0, 1)).rotation_difference(Lv.normalized()).to_euler() + sc.cycles.diffuse_bounces = 0 + for m in bpy.data.materials: + m.cycles.emission_sampling = "NONE" + cd = bpy.data.cameras.new("WG_Camera"); cd.type = "ORTHO"; cd.sensor_fit = "HORIZONTAL" + cam = bpy.data.objects.new("WG_Camera", cd); sc.collection.objects.link(cam); sc.camera = cam + return cam + + +def render(sc, cam, path, w, h, pxm, cx, cy, top): + """orthographic: front view (camera at -Y looking along +Y, picture x = +X, up = +Z) or top view (looking down, picture up = +Y); pixel (w/2, h/2) = world (cx, cy)""" + cam.data.ortho_scale = w / pxm + if top: + cam.location = (cx, cy, 6.0); cam.rotation_euler = (0, 0, 0) + else: + cam.location = (cx, -6.0, cy); cam.rotation_euler = (np.pi / 2, 0, 0) + sc.render.resolution_x, sc.render.resolution_y = w, h + sc.render.filepath = path + bpy.ops.render.render(write_still=True) + from PIL import Image + return np.asarray(Image.open(path).convert("RGBA"), np.float32) / 255.0 + + +def box_down(a, k): + h, w = a.shape[0] // k, a.shape[1] // k + a = a[:h * k, :w * k].reshape(h, k, w, k, 4) + al = a[..., 3].mean((1, 3)) + rgb = (a[..., :3] * a[..., 3:]).sum((1, 3)) / np.maximum(a[..., 3].sum((1, 3)), 1e-6)[..., None] + out = np.zeros((h, w, 4), np.float32); out[..., :3] = rgb; out[..., 3] = (al >= 0.5) + return out + + +def levels_map(y, levels): + """piecewise-linear: the 10th / 50th / 90th percentile of y to `levels` (the top keeps the slope of the upper half)""" + p = np.percentile(y, [10, 50, 90]); top = max(p[2] + 1e-3, y.max()) + xs = [0.0, p[0], p[1], p[2], top] + ys = [min(8.0, levels[0]), levels[0], levels[1], levels[2], levels[2] + (top - p[2]) * (levels[2] - levels[1]) / max(p[2] - p[1], 1e-3)] + return lambda v: np.interp(v, xs, ys) + + +def tone(a, levels, part=None, wood_levels=None, outline=None, rim=None): + """display RGB -> the item's colours: metal (chain, ball, the haft's grey fittings) one grey level per pixel (R = G = B: luma, per part brought to one median + x PART_GAIN, then `levels` = output 10th / 50th / 90th percentile); the wooden haft (part 0, chroma > WOOD_CHROMA of its luma in the render) the same luma + mapped to wood_levels and coloured WOOD_TINT (the client's wood); edge pixels darkened by `outline` (default OUTLINE); rim: a grey level drawn in the pixels + next to the item (4-neighbours), the dark rim of the client's arts""" + outline = OUTLINE if outline is None else outline + m = a[..., 3] > 0 + y = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]) * 255.0 + chroma = (a[..., :3].max(-1) - a[..., :3].min(-1)) * 255.0 + wood = m & (part == 0) & (chroma > WOOD_CHROMA * np.maximum(y, 1e-3)) if (part is not None and wood_levels) else np.zeros_like(m) + metal = m & ~wood + if part is not None: + ref = np.median(y[metal]) + for k, gk in enumerate(PART_GAIN): + q = metal & (part == k) + if q.sum() > 3: + y[q] *= gk * ref / max(np.median(y[q]), 1e-3) + g = np.zeros_like(y) + g[metal] = levels_map(y[metal], levels)(y[metal]) + if wood.sum() > 3: + g[wood] = levels_map(y[wood], wood_levels)(y[wood]) + if outline < 1: + pad = np.pad(m, 1) + edge = m & ~(pad[:-2, 1:-1] & pad[2:, 1:-1] & pad[1:-1, :-2] & pad[1:-1, 2:]) + g[edge] *= outline + g = np.clip(g, 8.0, LEVEL_MAX) # 8: the darkest grey (the 15-bit colour 0 is transparent; no true black) + tint = np.array(WOOD_TINT) / (0.299 * WOOD_TINT[0] + 0.587 * WOOD_TINT[1] + 0.114 * WOOD_TINT[2]) + rgb = np.repeat(g[..., None], 3, -1) + rgb[wood] = g[wood][:, None] * tint + q = (np.clip(np.round(rgb), 8, 255).astype(np.int32) >> 3) << 3 # 15-bit levels, as the client stores them + out = np.zeros(a.shape[:2] + (4,), np.uint8) + out[..., :3] = np.where(m[..., None], q, 0); out[..., 3] = np.where(m, 255, 0) + if rim is not None: + pad = np.pad(m, 1) + ring = ~m & (pad[:-2, 1:-1] | pad[2:, 1:-1] | pad[1:-1, :-2] | pad[1:-1, 2:]) + out[ring, :3] = (int(rim) >> 3) << 3; out[ring, 3] = 255 + return out + + +def stretch(V, part, prm, length, grip_at, chain_len=1.0): + """the haft lengthened x length (KNOB m at the butt kept, the shaft above stretched), the chain and the ball moved with its end; the chain x chain_len along the axis + (its links longer, the ball moved on); the grip put grip_at m above the butt (0 = where the animation holds it); rest pose; returns (vertices, prm with the moved + nodes and grip)""" + R = np.array(prm["nodes"]); G = np.array(prm["grip"]); hd = (R[0] - G) / np.linalg.norm(R[0] - G) + t = (V - G) @ hd + tb = t[part == 0].min(); te = (R[0] - G) @ hd; t0 = tb + KNOB + k = (length * (te - tb) - KNOB) / (te - t0) + out = V.copy() + msk = (part == 0) & (t > t0); out[msk] += np.outer((t[msk] - t0) * (k - 1), hd) + if chain_len != 1.0: # the chain from the end of the haft (te) stretched, the ball moved by the chain's growth + tc = (R[-2] - G) @ hd + msk = part == 1; out[msk] += np.outer((np.clip(t[msk], te, tc) - te) * (chain_len - 1), hd) + out[part == 2] += (tc - te) * (chain_len - 1) * hd + tn = (R - G) @ hd; R = R + np.outer((np.clip(tn, te, tc) - te) * (chain_len - 1) + np.where(tn > tc, (tc - te) * (chain_len - 1), 0), hd) + dlt = (te - t0) * (k - 1); out[part > 0] += dlt * hd + p2 = dict(prm); p2["nodes"] = (R + dlt * hd).tolist() + if grip_at > 0: + p2["grip"] = (G + (tb + grip_at) * hd).tolist() + return out, p2 + + +def thin(V, part, prm, k3): + """the cross-sections of the haft and the chain scaled about the weapon axis (k3[0], k3[1]), the ball about where the chain holds it (k3[2]), rest pose""" + R = np.array(prm["nodes"]); G = np.array(prm["grip"]); hd = (R[0] - G) / np.linalg.norm(R[0] - G) + d = V - G; t = d @ hd; rad = d - np.outer(t, hd) + out = V.copy() + for k, f in ((0, k3[0]), (1, k3[1])): + msk = part == k; out[msk] = G + np.outer(t[msk], hd) + rad[msk] * f + msk = part == 2; out[msk] = R[-2] + (V[msk] - R[-2]) * k3[2] + return out + + +def part_pass(sc, cam, ob, path, w, h, pxm, cx, cy, top): + """which part (0 haft, 1 chain, 2 ball) each pixel shows: the object drawn with an emission of its uo_flail_part, rendered like the picture""" + keep = list(ob.data.materials) + m = bpy.data.materials.new("WG_part"); m.use_nodes = True; nt = m.node_tree + for n in list(nt.nodes): + nt.nodes.remove(n) + at = nt.nodes.new("ShaderNodeAttribute"); at.attribute_name = "uo_flail_part" + mul = nt.nodes.new("ShaderNodeMath"); mul.operation = "MULTIPLY"; mul.inputs[1].default_value = 0.5 + em = nt.nodes.new("ShaderNodeEmission"); outn = nt.nodes.new("ShaderNodeOutputMaterial") + nt.links.new(at.outputs["Fac"], mul.inputs[0]); nt.links.new(mul.outputs[0], em.inputs["Color"]); nt.links.new(em.outputs[0], outn.inputs["Surface"]) + for i in range(len(ob.data.materials)): + ob.data.materials[i] = m + look = sc.get("uo_look"); sc["uo_look"] = 0.0 + a = render(sc, cam, path, w, h, pxm, cx, cy, top) + sc["uo_look"] = look + for i, mm in enumerate(keep): + ob.data.materials[i] = mm + v = a[SS // 2::SS, SS // 2::SS, 0] # the middle sample of each pixel; display values of 0 / 0.5 / 1 linear = 0 / 0.74 / 1 + pid = np.where(v < 0.37, 0, np.where(v < 0.87, 1, 2)); vis = a[SS // 2::SS, SS // 2::SS, 3] > 0.5 + print("WG parts (haft / chain / ball px):", [int(((pid == k) & vis).sum()) for k in range(3)]) + return pid + + +POSED = {} # the nodes of the last pose_flail (grip at the origin) + + +def links(img, part, nodes, to_px, period, gap, dark): + """visible links on a small art: chain pixels (part 1) whose distance along the chain (the polyline of the posed nodes, in pixels) falls in the last `gap` of each + `period` px are darkened x dark (at 34 px/m a 5 cm link is ~2 px: the rendered chain was an even grain)""" + P = np.array([to_px(x) for x in nodes[:-1]]) # the chain's nodes (the last piece is the ball) + seg = np.diff(P, axis=0); L = np.linalg.norm(seg, axis=1); cum = np.concatenate([[0], np.cumsum(L)]) + ys, xs = np.nonzero((part == 1) & (img[..., 3] > 0)) + q = np.stack([xs + 0.5, ys + 0.5], 1) + best = np.full(len(q), np.inf); sval = np.zeros(len(q)) + for k in range(len(seg)): + u = np.clip(((q - P[k]) @ seg[k]) / max(L[k] ** 2, 1e-9), 0, 1); d = np.linalg.norm(q - (P[k] + np.outer(u, seg[k])), axis=1) + b = d < best; best[b] = d[b]; sval[b] = cum[k] + u[b] * L[k] + g = (sval % period) >= period * (1 - gap) + out = img.copy(); v = out[ys[g], xs[g], :3].astype(float) * dark if dark > 0 else np.full((int(g.sum()), 3), float(ART_RIM)) + out[ys[g], xs[g], :3] = (np.clip(np.round(v), 8, 255).astype(np.int32) >> 3) << 3 + return out + + +def pose_flail(V, part, s, prm, Q, chain_dirs): + """vertex positions (grip at the origin): the haft turned by Q, the chain pieces along chain_dirs (one direction per piece), the ball on the last piece""" + R = np.array(prm["nodes"]); n = len(R); G = np.array(prm["grip"]) + rest = np.linalg.norm(np.diff(R, axis=0), axis=1) + X = np.zeros_like(R); X[0] = Q @ (R[0] - G) + for k in range(n - 1): + d = np.asarray(chain_dirs[min(k, len(chain_dirs) - 1)], float); X[k + 1] = X[k] + d / np.linalg.norm(d) * rest[k] + P = (V - G) @ Q.T + seg = (prm["ball_start"] - prm["haft_end"]) / (n - 2) + piece = np.where(part == 2, n - 2, np.clip(((s - prm["haft_end"]) / seg).astype(int), 0, n - 3)) + F = Q.copy() + for k in range(n - 1): # each piece turned like its segment, the turn carried down from the haft (as render_uo_layer.flail_fix) + c = F @ (R[k + 1] - R[k]); x = X[k + 1] - X[k] + F = np.array(Vector(c.tolist()).rotation_difference(Vector(x.tolist())).to_matrix()) @ F + msk = (piece == k) & (part > 0) + P[msk] = X[k] + (V[msk] - R[k]) @ F.T + POSED["nodes"] = X + return P + + +def main(src, out): + bpy.ops.wm.open_mainfile(filepath=src) + sc = bpy.context.scene + rig = bpy.data.objects["UO_Rig"]; rig.data.pose_position = "REST"; rig["uo_direction"] = 0 + bpy.context.view_layer.update() + ob = next(o for o in bpy.data.collections["Clothing"].all_objects if o.type == "MESH" and not o.hide_render) + for o in bpy.data.objects: + if o.type == "MESH" and o is not ob: + o.hide_render = True + nv = len(ob.data.vertices) + co = np.empty(nv * 3); ob.data.vertices.foreach_get("co", co) + Mw = np.array(ob.matrix_world); V = co.reshape(-1, 3) @ Mw[:3, :3].T + Mw[:3, 3] # rest pose = armature space (the rig sits at the origin) + flail = "uo_flail" in ob + if flail: + prm = json.loads(ob["uo_flail"]); G = np.array(prm["grip"]); R0 = np.array(prm["nodes"])[0] + part = np.empty(nv, np.int32); ob.data.attributes["uo_flail_part"].data.foreach_get("value", part) + s = np.empty(nv, np.float32); ob.data.attributes["uo_flail_s"].data.foreach_get("value", s) + hd = (R0 - G) / np.linalg.norm(R0 - G) + else: # a rigid weapon: the haft = the long axis of the mesh through its butt end + raise SystemExit("uo_render_weapon_gump: only flails (uo_flail.py) for now") + for m in list(ob.modifiers): + ob.modifiers.remove(m) + if ob.data.shape_keys: + ob.shape_key_clear() + ob.parent = None; ob.matrix_world = Matrix.Identity(4) + cam = setup_render(sc) + from PIL import Image + import uo_gump_art as ga + os.makedirs(out, exist_ok=True); tmp = os.path.join(out, "_tmp.png") + + def place(P): + ob.data.vertices.foreach_set("co", P.astype(np.float64).ravel()); ob.data.update() + + # gump: the haft upright (grip at the origin), the chain over the outer side (-X = the character's right, the left of the picture), then straight down + up = Vector((0, 0, 1)); Q = np.array(Vector(hd.tolist()).rotation_difference(up).to_matrix()) + Q = np.array(Matrix.Rotation(np.radians(ROLL), 3, up)) @ Q + Q = np.array(Matrix.Rotation(-np.radians(TILT), 3, Vector((0, 1, 0)))) @ Q # about the view axis: the top goes to -X (out) + V0 = V; Vg, pg = stretch(V0, part, prm, HAFT_LEN, GRIP_AT); V = thin(Vg, part, pg, THIN) + dirs = [(x, 0.0, z) for x, z in DRAPE] + [(0.0, 0.0, -1.0)] # free: every piece straight down (gravity), the ball below the last + place(pose_flail(V, part, s, pg, Q, dirs)) + res = {} + for gender, (gx, gy) in GRIP.items(): + cx = (GW / 2 - gx) / PXM; cz = (gy - GH / 2) / PXM + a = box_down(render(sc, cam, tmp, GW * SS, GH * SS, PXM * SS, cx, cz, False), SS) + pp = part_pass(sc, cam, ob, tmp, GW * SS, GH * SS, PXM * SS, cx, cz, False) + img = tone(a, LEVELS, pp, WOOD_LEVELS); res[gender] = img + try: + bodyg = ga.read_gump(CLIENT, BODY[gender]) + except OSError: + bodyg = None + if HOLD_CUT and bodyg is not None: # the haft behind the fist: the body gump's fist shows through + bm = np.zeros((GH, GW), bool); h_, w_ = min(GH, bodyg.shape[0]), min(GW, bodyg.shape[1]); bm[:h_, :w_] = bodyg[:h_, :w_, 3] > 0 + yy, xx = np.mgrid[0:GH, 0:GW]; win = (np.abs(xx + 0.5 - gx) <= HOLD_CUT[0]) & (yy + 0.5 >= gy - HOLD_CUT[1]) & (yy + 0.5 <= gy + HOLD_CUT[2]) + cut = win & bm & (pp == 0) & (img[..., 3] > 0) + hs = HOLD_SHOW[gender] if isinstance(HOLD_SHOW, dict) else HOLD_SHOW + if hs: + fist = win & bm + bottom = np.where(fist.any(0), GH - 1 - np.argmax(fist[::-1], 0), -1) # the fist's lowest row in each column + cut &= yy <= bottom[None, :] - hs + img[cut] = 0 + print("WG gump %s: %d haft px cut out under the fist (rows %d..%d)" % (gender, cut.sum(), *(lambda r: (r.min(), r.max()))(np.nonzero(cut)[0])) if cut.any() else "WG gump %s: nothing under the fist" % gender) + elif HOLD_CUT: + print("WG: no body gump in %s, the haft is not cut at the fist" % CLIENT) + Image.fromarray(img).save(os.path.join(out, "gump_%s.png" % gender)) + data, extra = ga.encode_gump(img) + open(os.path.join(out, "gump_%s.bin" % gender), "wb").write(data) + ys, xs = np.nonzero(img[..., 3]); print("WG gump %s: bbox x %d..%d y %d..%d, %d px, extra %08x" % (gender, xs.min(), xs.max(), ys.min(), ys.max(), len(xs), extra)) + + # art: lying on the ground (z up), seen from above; the haft along the picture's diagonal from the bottom left, the chain curling back beside it + d45 = Vector((1, 1, 0)).normalized() + Qa = np.array(Vector(hd.tolist()).rotation_difference(d45).to_matrix()) + Qa = np.array(Matrix.Rotation(np.radians(ROLL), 3, d45)) @ Qa + n = len(prm["nodes"]); ap = ART_APEX if ART_APEX > 0 else n - 2 + turns = [np.radians(ART_CURL) * min(1.0, (k + 1) / ap) for k in range(n - 1)] + dirs = [tuple(np.array(Matrix.Rotation(-a_, 3, Vector((0, 0, 1)))) @ np.array(d45)) for a_ in turns] + Va, pa = stretch(V0, part, prm, ART_HAFT_LEN, 0.0, ART_CHAIN_LEN) + P = pose_flail(thin(Va, part, pa, ART_THIN), part, s, pa, Qa, dirs) + zshift = P[:, 2].min(); P[:, 2] -= zshift # on the ground + place(P) + lo, hi = P[:, :2].min(0), P[:, :2].max(0); c = (lo + hi) / 2 + wpx = int(np.ceil((hi[0] - lo[0]) * ART_PXM)) + 3; hpx = int(np.ceil((hi[1] - lo[1]) * ART_PXM)) + 3 + w = max(ART_W, wpx); h = hpx + a = box_down(render(sc, cam, tmp, w * SS, h * SS, ART_PXM * SS, c[0], c[1], True), SS) + pp = part_pass(sc, cam, ob, tmp, w * SS, h * SS, ART_PXM * SS, c[0], c[1], True) + art = tone(a, ART_LEVELS, pp, ART_WOOD_LEVELS, ART_OUTLINE, ART_RIM) + if LINK_PX > 0: + art = links(art, pp, POSED["nodes"], lambda x: (w / 2 + (x[0] - c[0]) * ART_PXM, h / 2 - (x[1] - c[1]) * ART_PXM), LINK_PX, LINK_GAP, LINK_DARK) + ys, xs = np.nonzero(art[..., 3]); art = art[ys.min():ys.max() + 1] # the client's arts start at their top row + for name, im in (("art", art), ("art_mirror", art[:, ::-1].copy())): + Image.fromarray(im).save(os.path.join(out, name + ".png")) + open(os.path.join(out, name + ".bin"), "wb").write(ga.encode_art(im)) + print("WG art: %d x %d, item %d x %d px" % (art.shape[1], art.shape[0], xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)) + if os.path.exists(tmp): + os.remove(tmp) + + +if __name__ == "__main__": + a = sys.argv[1:]; pos = []; i = 0 + while i < len(a): + if a[i] == "--set": + k, v = a[i + 1].split("=", 1); g = globals() + g[k] = eval(v) if isinstance(g[k], (tuple, dict)) else type(g[k])(v); i += 2 + else: + pos.append(a[i]); i += 1 + main(pos[0], pos[1]) diff --git a/third_party/UO_Model3D_v13/provenance.json b/third_party/UO_Model3D_v13/provenance.json index b54aa22..c17ed48 100644 --- a/third_party/UO_Model3D_v13/provenance.json +++ b/third_party/UO_Model3D_v13/provenance.json @@ -4,12 +4,12 @@ "license": "shared with permission of the artist (Levy)", "upstream": { "repository": "https://github.com/Kserian/UO_Model3D", - "commit": "e9544f6", - "date": "2026-10-09", - "previous_commit": "3eaae69" + "commit": "b212cee", + "date": "2026-10-20", + "previous_commit": "e9544f6" }, "note": "UO_Body_0x190.blend is the stripped copy: the packed original client frame atlas (UO_Original_Atlas, uo_original_frames.json) is removed with pipeline/strip_originals.py and uo_exact is 0. No .fbx/.glb are shipped: upstream publishes none and nothing in SpriteMotion reads them.", "files": { - "model/UO_Body_0x190.blend": "80a323730621343d4c0666a5711294f916e921180f3c2e8fadf0e2226c745a32" + "model/UO_Body_0x190.blend": "0a8f105ec91091fa1250981f85ad55129e97732ad5cd1c5a8d0aaa09f235efcd" } } diff --git a/third_party/UO_Model3D_v13/vdtool/vdtool.py b/third_party/UO_Model3D_v13/vdtool/vdtool.py index 4425af9..3dc3c45 100644 --- a/third_party/UO_Model3D_v13/vdtool/vdtool.py +++ b/third_party/UO_Model3D_v13/vdtool/vdtool.py @@ -8,7 +8,7 @@ python vdtool.py verify a.vd b.vd Wymagania: Python 3.8+, pip install pillow numpy -Pelna instrukcja: ../README.md (PL), ../README_EN.md (EN) +Pelna instrukcja: ../README.md (EN), ../README_PL.md (PL) """ import json import os diff --git a/tools/uo-content/pipeline.py b/tools/uo-content/pipeline.py index abbfd86..44bc1f5 100644 --- a/tools/uo-content/pipeline.py +++ b/tools/uo-content/pipeline.py @@ -42,19 +42,20 @@ def sha(path): def render_fingerprint(): paths = [HERE/name for name in ('blender_build.py','fit_runtime.py','occlusion.py','pack_fit.py')] paths += [ROOT/'common/fit_rules.py'] - paths += [BACKEND/'pipeline'/name for name in ('render_uo_layer.py','cloth_lib.py','uo_bind_item.py','uo_vd_writer.py')] + paths += [BACKEND/'pipeline'/name for name in ('render_uo_layer.py','cloth_lib.py','uo_bind_item.py','uo_vd_writer.py','uo_cloth_sim.py','bpy_compat.py')] return hashlib.sha256(''.join(sha(path) for path in paths if path.exists()).encode()).hexdigest() def setup(source): source = Path(source).resolve() files = ['model/UO_Body_0x190.blend', 'pipeline/uo_bind_item.py', - 'pipeline/render_uo_layer.py', 'pipeline/uo_vd_writer.py', 'vdtool/vdtool.py', 'README_EN.md'] + 'pipeline/render_uo_layer.py', 'pipeline/uo_vd_writer.py', 'vdtool/vdtool.py', 'README.md'] for name in files: if not (source / name).is_file(): raise ValueError(f'Missing UO_Model3D backend file: {name}') # Newer renderers load cloth_lib.py and cloak_pitch.json from their own folder (else from texts embedded in the # .blend); older ones have neither. - files += [name for name in ('pipeline/cloth_lib.py', 'pipeline/cloak_pitch.json') if (source / name).is_file()] + files += [name for name in ('pipeline/cloth_lib.py', 'pipeline/cloak_pitch.json', 'pipeline/uo_cloth_sim.py', 'pipeline/bpy_compat.py') + if (source / name).is_file()] BACKEND.mkdir(parents=True, exist_ok=True) for name in files: dest = BACKEND / name