BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / README.md

README.md

README.md 35.8 KiB Download Raw
1# Paperdoll Renderer
2
3Desktop editor for `.ppd` projects, bone-bound equipment and an animated 3D preview, with production export to paperdoll maps.
4
5## Getting started
6
7Rust 1.96.0 is pinned in `rust-toolchain.toml`. On Debian/Ubuntu, building and the window need `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` and `libgl1-mesa-dev`. The native file picker uses the XDG Desktop Portal with a GTK/KDE backend, or Zenity. Live 3D needs OpenGL 3.3.
8
9```bash
10cargo run
11cargo run -- "/path with spaces/character.ppd"
12cargo run -- --preview-fixture
13cargo run -- --status
14```
15
16## Language and settings
17
18The UI is available in English (default) and Nederlands. **Settings** (on the start screen and in the project toolbar) holds the **Language** choice; switching takes effect immediately, including error and status messages. The choice is stored as `settings.json` in the user config directory (see below) and is restored at the next start. All UI text lives in one translation table (`src/i18n_table.rs`, Dutch source text → English, including format templates); tests check that every user-visible string has an entry in both languages with matching placeholders.
19
20## Project and bodies
21
22The start screen offers **Load project** and **New project**, both with a system file picker. New project asks for a parent folder (**Choose folder…**) and a project name, and creates `<folder>/<name>/`. The name must not be empty, must not contain `/ \ < > : " | ? *` or control characters and must not end with a dot; if the folder already exists it must be empty. Errors are shown in the form and nothing is created. The new folder receives `character.ppd` (with that name) and the folders `assets/body/`, `assets/equipment/`, `presets/`, `cache/` and `renders/`. The bundled default bodies (see *Attribution*) are copied into `assets/body/male/` and `assets/body/female/`, each with its `license.txt`, and set as the male and female body, so the project renders immediately. The equipment list also gets the female-only hair preset `presets/female-hair.ppe`. (`--preview-fixture` and the tests use the small CC0 test bodies instead.) Below the buttons the last 5 opened or created projects are listed (**Recently opened**); click a name to open it. A project that no longer exists is greyed out with *not found*. The list is `recent.json` in the user config directory (`$XDG_CONFIG_HOME/paperdoll-renderer/`, by default `~/.config/paperdoll-renderer/`, on Windows `%APPDATA%\paperdoll-renderer\`; override with `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` creates a temporary sample project and opens the male body.
23
24The first project view shows the bodies. **Choose male / Choose female** selects a model. An external file is copied into the project folder; if the name exists the copy gets its own name. Models with side files can be placed in the project folder with their relative folder structure and selected there. **Import overview** lists meshes, materials, textures, joints and clips of glTF/GLB, FBX and OBJ+MTL. FBX bodies are normalized once on load (right-handed Y-up, meters) and then drawn like glTF/GLB in the live 3D preview and production render, with all their animation stacks as clips.
25
26**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:
27
28- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
29- Pick an animation clip and use **Pause / Play**.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
36
37### Multiple meshes and skins (glTF/GLB)
38
39A skinned glTF/GLB body is loaded completely: all mesh nodes and all skins. The joints of all skins are merged into one skeleton (joints present in several skins count once; at most 128). A mesh node without a skin moves rigidly with the nearest ancestor that is a joint; if it has none, it hangs from the first joint. Its rest position in the scene is kept exactly (mirrored transforms flip the triangle winding). Materials stay per primitive: base color factor × albedo texture, alpha cutoff and vertex colors; every primitive uses its own material, in the live preview and in production. Materials with `alphaMode` `BLEND` (eyes' cornea, eyelashes, hair cards) are drawn after the opaque ones and alpha-blended over them, without writing depth until a final depth-only pass; blending between overlapping BLEND layers follows file order (no sorting). External image files (`uri` next to the `.gltf`) are resolved relative to the model, like embedded and `data:` images. `KHR_texture_transform` on the base color texture (offset, rotation, scale and `texCoord`) is applied once to the UVs on load. The sampler wrap mode is not read: textures are always sampled nearest with REPEAT, so CLAMP/MIRROR look different outside the 0–1 UV range. `KHR_materials_specular` feeds the body's specular map per primitive: `specularFactor` × the alpha of `specularTexture` (sampled with the base-colour UVs; `specularColor*` is not used); in the live preview it drives the highlight. Without the extension a body's specular is zero. Metallic/roughness, normal, occlusion and emissive textures and morph targets are not used; the normal map comes from geometry (metallic/roughness is not converted; a scalar specular cannot reproduce PBR physically).
40
41### Static bodies (OBJ, glTF/FBX without skin)
42
43A body without a skin can also be chosen as male or female model: **OBJ+MTL** (with PNG textures next to the model), **glTF/GLB without `skins`** and **FBX without skin deformer**. Such a body is shown and rendered as stored, without skeleton or animation:
44
45- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
46- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
47- **Pivot Y:** as for skinned bodies, the orbit point is the vertical centre of the stored geometry; a manual **Pivot Y** overrides it. Fixed render height/zoom per body stays the same.
48- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
49- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.
50
51## 2D preview
52
53From **Body** or the body view, **2D preview** opens the rendered compositor over the result folders `renders/<sex>-<settype>-atlas/`. The preview reads every export through its `.ppda` and the four atlases (validated, see *Atlases and .ppda*), not through the loose PNGs. Runs without a valid `.ppda` are skipped; their count is shown below the export counter, with the reason on hover. The decoded atlases of the current selection stay in memory until you choose another selection or use **Refresh export sets**.
54
55- Choose **Male / Female**. Only export sets with the same project/rig/export contract (`CompatKey`) can be combined; equipment appears as a multi-select when those contracts match.
56- Choose a clip from the body export inventory. **Play / Pause**, speed and export fps drive a looping frame time (elapsed × fps); missing maps/frames show a diagnostic instead of a silent substitute.
57- **Yaw − / Yaw +** wrap over 0…330° (step 30). **Pitch − / Pitch +** choose −60 / −30 / 0 / +30 / +60 with clamping (start at 0). Angle changes keep the animation time.
58- Choose view mode **Result** (default; lit composition), **Diffuse only**, **Normal map only**, **Depth map only** or **Specular map only**. Map modes show, per pixel, the winning layer of the depth composition, unlit and exactly as stored in the atlas, without decoding: diffuse, normal and specular as their RGBA, depth as its stored channels (`gray` as gray, `rgb_bands` and `rgb_packed` as raw RGB). 16-bit depth is shown at plain display precision (8 bits per channel, rounded). Only Result decodes depth for composition and lighting. The live pass image during rendering also shows the depth map exactly as it is written (same display rule). Playback, angle and equipment work in every mode; light only in Result.
59- Hold **Azimuth / Elevation** for continuous light rotation in °/s (delta time); azimuth wraps over 360° without clamping, elevation stays within −80…+80. The buttons follow the real mouse button state, so they keep turning while held (also longer than a second). Releasing, moving off the button, losing focus or the pointer leaving the window stops the motion. Lighting is for Result only; stored diffuse remains unlit albedo.
60- Playback runs over exactly `frames / fps` seconds, so every exported frame (including the last) lasts equally long (`1/fps`).
61- The composed frame uses nearest-neighbor display with uniform scale and centered letterboxing, like the production pass previews.
62
63![2D preview at pitch +60 with a real FBX export via .ppda and atlases](docs/screenshots/preview2d-ppda.png)
64
65```bash
66# Native GUI smoke (Xvfb): synthetic export, loose PNGs removed, preview via .ppda/atlases
67cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
68# Same smoke on an existing project with real exports
69cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
70```
71
72## Equipment and saving
73
74From a body, **Equipment** opens the list. Choose a definition or **New**.
75
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
783. **Add part (OBJ/FBX)** opens the system file picker. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Skinned FBX meshes, morph targets and non-PNG textures are refused with a message. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
794. Expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
805. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
816. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings, sex flags and origin). No source paths outside the project are needed to reload them.
82
83**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
84
85A preset is limited to 32 MiB, 32 parts/files and textures of at most 2048×2048. A project has at most 64 presets and 64 MiB of preset files. The full container description is in `design.md`.
86
87```bash
88# Self-contained CC0 sword sample in a new temporary project
89cargo run --example equipment_fixture --locked
90```
91
92![Real equipment editor with animated body and hand-bound OBJ](docs/screenshots/equipment-live.png)
93
94When closing or choosing **Other project / New project** with changes, choose **Save**, **Don't save** or **Cancel**. If saving fails, the project stays open. Cancelling the file picker keeps your current work.
95
96## Models in the project
97
98**Choose male/female** copies a model from outside the project into its own folder `assets/body/<name>/`, together with all its side files: for glTF the external buffers and images (`uri`, except `data:`), for OBJ the `mtllib` files and their textures (`map_*`, `bump`, `norm`). The relative layout is kept. If a side file is missing, or a reference points outside the model folder (`..`, absolute path), nothing is copied and the message names the files. Next to the model goes `<model>.provenance.json` with the source path and the SHA-256 of every copied file. Choosing the same model again creates `<name>-2/`; nothing is overwritten. A model already in the project is used in place. If a project model fails to load, the error also names missing side files. Axes, units and winding are normalized once on load (FBX via ufbx); the source files stay unchanged.
99
100## Animations
101
102All clips from the source file are used. glTF interpolation is kept: LINEAR (linear, rotations via slerp), STEP (holds the previous key) and CUBICSPLINE (Hermite with in/out tangents, scaled by the key interval; rotations normalized). Channels with non-increasing or non-finite times, non-finite values or a wrong number of values make loading fail with a clear message instead of silently playing wrong. Morph target weights are not applied; this is reported per clip when rendering. Unnamed clips are called `animatie <index>`; duplicate names stay separate (production chooses clips by index). In the live preview an exact clip name wins over a partial match. Root motion is kept as in the source. FBX clips are baked by ufbx and played linearly.
103
104## Production output
105
106### Render all
107
108**Render all** in the Body screen renders the whole project in one queue: first the male and female body (if set), then every equipment preset for each sex it is valid for. Each item gets the full export (all clips × frames × 60 angles, four maps, atlases + `.ppda`) into its own `renders/<sex>-<settype>-atlas/`. Progress shows *item k/n* plus the normal pass count; **Cancel render** stops the queue (the running item stays resumable, the rest is marked *not started*). If an item has a matching interrupted run, it is resumed instead of restarted. Items that cannot run (no body, equipment not configured or without parts, invalid settings) are skipped with a reason and the queue continues. During Render all the live pass preview in the Body screen stays visible and follows the item currently rendering, as with a single render. Afterwards a summary lists what was rendered, skipped, failed or not started.
109
110### Notification when a render ends
111
112When a single render or **Render all** ends (finished, failed or cancelled) while the app window does not have focus, the app shows one desktop notification with the same status line as the window (one for the whole Render all queue, not one per item). On Linux this is a freedesktop notification (D-Bus) with the app icon and the `paperdoll-renderer` desktop entry; on Windows it is a toast. Without a notification service nothing is shown and the render is not affected.
113
114The body button renders only the body; **Render equipment · all angles** in the equipment editor renders the selected preset, animated on the chosen body but **without body occlusion or body pixels**. The fixed camera of that sex stays the same, also if you zoomed freely in the live preview.
115
116Per image **only four target-res PNGs** are stored: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **unlit albedo** (base color factor × texture × vertex color, alpha kept), never baked lighting/shadow/gloss. Depth is linear within a fixed depth range per sex, centered on the orbit point: `near/far = 10 m ∓ total/2`. By default the total is the T-pose arm span plus one arm length of margin on each side (arm = upper arm→hand bone distance × 1.2, span = shoulder width + 2 arms, independent of the bind pose; without recognizable arm/hand bones: span = max(width, height) and arm = 0.4·span). In the body screen, below the render frame, **Depth range** is *automatic* or a custom total in meters (`depth_range_milli` per sex in the `.ppd`), with the resulting near/far. All clips, frames, angles and equipment of that sex share the same range.
117
118The depth format is chosen in the Body screen (**Depth map**), stored in the `.ppd` as `render.depth_bits` (8, default, or 16) and `render.depth_encoding`. With `t = (distance − near)/(far − near)` and M = 2^bits − 1:
119- `gray` (default): gray+alpha, `gray = round(t·M)`.
120- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 levels at 8 bit).
121- `rgb_packed`: RGBA, positional in base 2^bits (24-bit or 48-bit value), R most significant; needs nearest sampling, no mipmaps and lossless storage.
122Alpha is kept (scaled to the bit depth); transparent pixels have value 0. Geometry outside the range is clamped (not clipped) and reported in the progress; normal is view-space RGB+alpha; specular is linear strength (body: KHR_materials_specular or zero; equipment including the configured shiny map). Highres and detail are visible intermediate steps only and are not stored.
123
124```text
125renders/male-body-atlas/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
126renders/male-body-atlas/male-body-diffuse-atlas.png (and -normal, -depth, -specular)
127renders/male-body-atlas/male-body-atlas.ppda
128```
129
130Per sex and set type there is one result folder `renders/<sex>-<settype>-atlas/` holding only the newest complete render. A render is built in a temporary folder `renders/inprogress-<time>-<pid>-<sex>-<settype>/`. Only when it is complete and the atlases/`.ppda` are verified is the previous result folder moved aside (`.replaced-…`), the new one renamed into place and the old one deleted. On cancel, error or crash the last good result folder therefore stays. An interrupted swap is repaired at the next render: a moved-aside old folder comes back, and a finished but not yet placed render is placed. Old `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a message.
131
132A finished result folder contains only consumer output:
133
134```
135renders/male-body-atlas/
136 male-body-atlas.ppda
137 male-body-diffuse-atlas.png
138 male-body-normal-atlas.png
139 male-body-depth-atlas.png
140 male-body-specular-atlas.png
141 raw files/
142 male/body/<clip>/f000000/p-60_y000_diffuse.png …
143```
144
145All loose per-frame images are grouped under `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` and the markers of earlier attempts exist only in the temporary `inprogress-*` folder; they are removed after the render is verified and before the folder is put in place. What is still needed afterwards (the export contract and the SHA-256 of every loose PNG) is in the `.ppda`. Only after damage during a resumed render can a kept `.corrupt-aNNN-K` file remain. Older result folders (loose images and `render.json`/`complete.json` directly in the folder) are still read.
146
147While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) describes project identity, source fingerprints (SHA-256 of the body file and optionally the encoded equipment), camera/pivot/sample/material contract (pitches −60…+60, yaws 0…330), clip inventory and count. In the temporary folder only `complete.json` (same schema, with per-PNG SHA-256 and the names and SHA-256 of atlases and `.ppda`) marks a complete set. Schema 5 (before `depth_bits`) is still read as 16-bit gray. Schema 4 and older (including the old global 0.05–40 m range) are refused by the readers and not mixed; render those again. `cancelled.json`/`failed.json` mark a partial run without a complete marker. The library module `export_set` reads a finished result folder through the `.ppda` (older folders through their `render.json`/`complete.json`), opens only finished runs with matching identity and verified checksums, and reads the real RGBA8 and depth maps (all six formats, converted to 16-bit depth for the compositor) with strict path/format validation. The map/camera/pixel contracts are in `design.md`.
148
149### Atlases and .ppda
150
151When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).
152
153- A cell is exactly the target resolution, without border or scaling. Each row is one (clip, frame) in source order. The 60 columns are pitch −60→+60 and within that yaw 0→330. The four atlases share the same cell coordinates. Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
154- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
155- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
156- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 4 (versions 2–3 are still read; version 2 as 16-bit gray), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h}` in atlas pixels) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
157- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, cell coordinates off the grid and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps; `cell()` crops one angle. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
158
159### Resuming
160
161A resumed run continues writing in the same temporary `inprogress-*` folder; the result folder is untouched until the run is done. If you start a new (not resumed) render of the same sex and set type, older unfinished `inprogress-*` folders of that target are deleted first, so only the newest remains; the finished result folder, other targets and a folder currently being rendered into are kept.
162
163- Every run keeps `progress.jsonl`: per published PNG the path and SHA-256. `run.lock` prevents two renders writing into the same run at once.
164- Resuming is only possible if `render.json` exactly matches what the current job would write: project, sex/set, SHA-256 of body and equipment, target, fps, framing/pivot, camera, clips and maps. A finished, locked or different run is refused.
165- Before rendering, every planned PNG is checked. Files with a matching checksum stay unchanged and are not rendered again. Missing files are recreated. Damaged files are renamed to `<name>.corrupt-aNNN-K` and then recreated; they are not deleted. An existing file without a journal line (crash between writing and recording) stays if the re-rendered bytes are equal. Leftover `.part-*` files are not touched.
166- After the loose maps, the atlases and `.ppda` are written (see above).
167- Progress starts at the work that is already correct; skipped images are labelled *already present, checksums match*. You can cancel again and resume later. A previous `cancelled.json`/`failed.json` is kept as `attempt-NNN-…json`, and `resume.jsonl` records per attempt what was verified, missing and quarantined. Only when everything matches is `complete.json` with all checksums written. Then these bookkeeping files are cleaned up (`complete.json` last) and the folder is put in place. A crash between cleanup and placement is repaired at the next render: the finished folder is cleaned and placed.
168
169![Resuming an interrupted run in the body view](docs/screenshots/body-resume.png)
170
171## 2D depth compositor (library)
172
173The `compositor` module combines a body export with zero or more compatible equipment exports (same project id, sex, body fingerprint, target, fps, framing, near/far and clip inventory, read from `export_set` or from the `.ppda`). Per pixel the nearest valid surface wins (depth gray closer = smaller; epsilon `DEPTH_EPSILON_U16 = 1` depth code; at equal depth the lowest layer index wins — pass the body first, then equipment in fixed name order). Diffuse, normal and specular always come from the **same** winning layer. Soft alpha of that winner is kept; losing layers do not mix in (single depth cannot reconstruct arbitrary translucency).
174
175`relight` / `compose_and_relight` applies linear ambient + Lambert diffuse + Blinn-Phong specular. The light vector is already in view space, like the exported normal maps. There is no rim light: the preview shows only this relight in Result mode. Stored diffuse remains unlit albedo elsewhere.
176
177```rust
178use paperdoll_renderer::compositor::{
179 assert_compatible, compose_and_relight, LayerMaps, Light,
180};
181use paperdoll_renderer::export_set::ExportRun;
182
183let body = ExportRun::open(&body_run_dir)?;
184let sword = ExportRun::open(&sword_run_dir)?;
185assert_compatible(&[&body, &sword])?;
186let layers = [
187 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
188 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
189];
190let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
191
192// Same layers via the consumer form (.ppda + atlases):
193use paperdoll_renderer::atlas::AtlasSet;
194let body = AtlasSet::open_run(&body_run_dir)?.load()?;
195let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
196```
197
198Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, centre pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
199
200## Releases and packages
201
202A pushed `vX.Y.Z` tag (protected pattern `v*`) runs the tag pipeline: tests, then the AppImage, Debian/Ubuntu `.deb`, Arch `.pkg.tar.zst` and Windows zip, a package check with `SHA256SUMS`, and the release job, which publishes those files as the release of that existing tag on BlueGit (`https://git.timfalken.com/golden-age/paperdoll-renderer/-/releases`). It then updates the AUR package `paperdoll-renderer-bin` from the release's `.deb` (`yay -S paperdoll-renderer-bin`).
203
204## Open with
205
206- **Windows:** run the unpacked `.exe` once to add the editor to *Open with* for the current account. Then choose **Open with → Paperdoll Renderer** on a `.ppd` (or select the `.exe` via Browse).
207- **Debian/Arch:** the packages install the `.ppd` MIME association, desktop launcher and icons. Choose the editor in **Open with**.
208- **Linux AppImage/plain binary:** keep the file in its permanent location and run `"/path/Paperdoll.AppImage" --install-desktop`. This needs `shared-mime-info` and `desktop-file-utils`. The editor then appears in **Open with**. Repeat after moving the AppImage.
209
210The window, the Linux launcher and the Windows executable use Tim's application icon. The window has app id `paperdoll-renderer` (equal to `paperdoll-renderer.desktop` and `StartupWMClass`), so KDE and GNOME under Wayland map the window to the launcher and icon; the app runs natively under Wayland and via X11 under X11. Under Wayland a program cannot send its own window icon, so the desktop takes the icon from the installed `paperdoll-renderer.desktop`. With the AUR, Arch or Debian package this works out of the box. A binary run without a package (a `cargo build` in `target/release`, the AppImage or an unpacked copy) shows the generic Wayland icon until you run it once with `--install-desktop`. Restart the editor after that.
211
212## Attribution
213
214The default starter bodies are third-party assets, bundled in `assets/default-bodies/` and embedded in the executable:
215
216- **Animated walking Male Base Mesh** by assetfactory — https://sketchfab.com/3d-models/animated-walking-male-base-mesh-cf6fd4382d7745468328115b49223d90 — Sketchfab Standard licence.
217- **Animated Female Base Mesh** by assetfactory — https://sketchfab.com/3d-models/animated-female-base-mesh-52fc9c15cf3e49d2a5a9cee6444964ec — Sketchfab Standard licence.
218
219The bundled female has its hair removed (and one stray one-key animation); the hair is the bundled female-only equipment preset `female-hair`, rigid on the head joint. Details are in `THIRD_PARTY_NOTICES.md`, which every package ships next to the licence. The synthetic test bodies in `tests/fixtures/bodies/` are CC0.
220
221## Tests
222
223`tests/robustness.rs` makes sure damaged input gives an ordinary error and never a crash: hundreds of deterministic mutations (truncation, bit flips, zero and 0xFF blocks) of the glTF/GLB, FBX and OBJ bodies, the equipment preset and the project manifest, broken `.ppda` files, and glTF nodes with scale 0 or an overflowing scale. When loading glTF, non-finite positions/normals/weights, wrong attribute lengths and indices outside the vertex range are refused; a panic in the glTF parser becomes a load error.
224
225```bash
226cargo fmt --check
227cargo check --workspace --all-targets --locked
228cargo clippy --workspace --all-targets --locked -- -D warnings
229cargo test --workspace --locked # incl. tests/robustness.rs
230# Linux GPU regression: real depth occlusion, normal/depth/specular and light-independent albedo
231xvfb-run -a cargo run --example preview_depth_probe --locked
232# Production on a real GPU (body or equipment); then checks every atlas cell against the loose PNG
233xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4
234# Resuming on a real GPU: setup, render with crash (abort) or cancel, resume, export_set and atlas check
235cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2
236xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
237xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
238cargo run --release --example resume_fixture --locked -- verify /tmp/resume
239# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
240xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
241# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor, plus KHR_materials_specular);
242# screenshots the live view and checks every material colour in the production diffuse and the specular strengths
243xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
244# Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far
245cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4
246# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
247xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
248```
249
250The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The multi-material body in `tests/fixtures/bodies/multi-material/` (external `body.bin`, `textures/*.png`) comes from `scripts/generate_multi_material_body.py`. The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.