BlueGit

golden-age paperdoll-renderer

Overview

Activity

NovDecJanFebMarAprMayJunJulAugSepOctNov

225 contributions in the last 12 months

LessMore

Commits, merge requests and comments.

paperdoll-renderer
Passed 00:01:58
Stage: test
dcb77258 History
NameLast commitLast update
assets Semi-transparency setting per layer, default off: binary alpha at 0.5 across all maps; .ppe v3; recorded in render.json and .ppda (Tim t1031u) 6 days ago
docs 9b: 60 views, per-map atlases + .ppda, Preview2d via .ppda, fps default 4; docs 7 days ago
examples Per-sex equipment offsets and copy-on-enable (Tim t1092u) 1 day ago
packaging Atlas-only output, checkpoint Resume, Exporteer frame, diffuse sRGB fix (Tim t1065u/t1066u) 4 days ago
presets 9f/9g: narrow fixed per-sex depth range (schema 5, .ppda v2 depth_near/far/encoding, clamp+report); light hold from raw pointer state 7 days ago
renders 9f/9g: narrow fixed per-sex depth range (schema 5, .ppda v2 depth_near/far/encoding, clamp+report); light hold from raw pointer state 7 days ago
scripts AppImage job: pinned appimagetool + type2 runtime from BlueGit with checksums, --runtime-file; no GitHub at run time (Tim t1032u) 6 days ago
src Per-sex equipment offsets and copy-on-enable (Tim t1092u) 1 day ago
tests Per-sex equipment offsets and copy-on-enable (Tim t1092u) 1 day ago
.gitattributes feat: restore live preview and build the project editor workflow 7 days ago
.gitignore ignore renders in git 7 days ago
blue-ci-cd.yml release.sh without jq (CI images lack it); release_publish tests cover the existing-tag route 6 days ago
build.rs feat: restore live preview and build the project editor workflow 7 days ago
Cargo.lock Atlas-only output, checkpoint Resume, Exporteer frame, diffuse sRGB fix (Tim t1065u/t1066u) 4 days ago
Cargo.toml Atlas-only output, checkpoint Resume, Exporteer frame, diffuse sRGB fix (Tim t1065u/t1066u) 4 days ago
character.ppd 9f/9g: narrow fixed per-sex depth range (schema 5, .ppda v2 depth_near/far/encoding, clamp+report); light hold from raw pointer state 7 days ago
design.md Per-sex equipment offsets and copy-on-enable (Tim t1092u) 1 day ago
LICENSE feat: add the Paperdoll Renderer foundation shell 7 days ago
README.md Per-sex equipment offsets and copy-on-enable (Tim t1092u) 1 day ago
rust-toolchain.toml feat: add the Paperdoll Renderer foundation shell 7 days ago
THIRD_PARTY_NOTICES.md Bundled female: stray clip and hair removed (asset edit); female-hair equipment preset seeded into new projects; editor equipment config sets preview sex; NOTICE/README (Tim t1015u-t1021u) 6 days ago
README.md

Paperdoll Renderer

Desktop editor for .ppd projects, bone-bound equipment and an animated 3D preview, with production export to paperdoll maps.

Getting started

Rust 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.

cargo run
cargo run -- "/path with spaces/character.ppd"
cargo run -- --preview-fixture
cargo run -- --status

Language and settings

The 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.

Project and bodies

The 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.

The 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.

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:

  • 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.
  • Pick an animation clip and use Pause / Play.
  • 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.
  • Render all angles renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). It builds one atlas per map type (from in-memory cell bytes) plus a .ppda file; loose per-view PNGs are only temporary checkpoints (see Atlases and .ppda). Production keeps several GPU views in flight (PBO readback ring) and encodes/writes PNGs on a bounded background IO queue so the next view can start without waiting on disk; atlases are built from in-memory PNG bytes when available instead of re-decoding from disk. The live preview of the latest finished pass is a latest-wins slot: the render worker never waits on texture upload or UI repaint, and the UI may skip intermediate frames. Shown passes still follow highres → detail → target/diffuse → depth → normal → specular, 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.
  • 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. Trim atlas: body / equipment (both on by default; older .ppd files load with both on) crops the atlas views to their content, see Atlases and .ppda. Semi-transparency allowed (body) is off by default (also for older .ppd files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own Semi-transparency allowed checkbox (also off by default). The bundled female-hair preset has it on. The setting is in render.json (semi_transparency), so a run only resumes with the same setting.
  • 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.

!Body view with animation and camera controls

Multiple meshes and skins (glTF/GLB)

A 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).

Static bodies (OBJ, glTF/FBX without skin)

A 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:

  • 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.
  • The body view reports that the body is static. The animation choice and Play/Pause are disabled.
  • 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.
  • 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.
  • 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.

2D preview

From 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 loose PNGs. Exporteer frame writes the current pose's composed maps (diffuse, normal, depth in the atlas encoding, specular) plus the lit preview image (current light/rim) as separate PNGs into a folder you pick. 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.

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • Playback runs over exactly frames / fps seconds, so every exported frame (including the last) lasts equally long (1/fps).
  • The composed frame uses nearest-neighbor display with uniform scale and centered letterboxing, like the production pass previews.

!2D preview at pitch +60 with a real FBX export via .ppda and atlases

# Native GUI smoke (Xvfb): synthetic export, loose PNGs removed, preview via .ppda/atlases
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
# Same smoke on an existing project with real exports
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live

Equipment and saving

From a body, Equipment opens the list. Choose a definition or New.

1. Give a unique name of at most 64 lowercase letters/digits/-/_, no spaces; body is reserved. 2. Choose Male / Female for the body you are viewing; Valid for decides which sexes the preset applies to. 3. Add part (OBJ/FBX/glTF) opens the system file picker; you choose the model first. 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. Morph targets and non-PNG textures are refused with a message. A model with its own skeleton (skinned glTF/GLB or FBX) becomes a skinned part, see step 4b. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder. 4. For a rigid part (no skeleton): 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 and its own position/rotation. The Male / Female switch in the equipment editor chooses which sex you are editing. 4b. For a skinned part, Bone links lists every bone of the equipment model. For each one, choose a character bone, or leave it on follows parent. At least one link is needed before the part is drawn. When the part is added, and with Auto-fill by name, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as mixamorig: or Armature|, side markers L/R/Left/Right, numeric suffixes such as .001), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links, positions and rotations are kept per sex; renders and the live preview use the mapping of the body's sex. When you enable Male or Female on a preset that has no mapping for that sex yet, the other sex's mapping and offsets are copied for every bone whose name exists on both body skeletons. Bones without a matching name stay unlinked; if nothing could be copied, the editor warns and you choose the bones yourself. 5. 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. 6. Save stores the project plus self-contained binary .ppe snapshots in presets/ (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 4, versions 1–3 still load (shared rigid offsets are applied to every enabled sex; semi-transparency off on versions 1–2) and are written as version 4 on the next save). No source paths outside the project are needed to reload them.

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.

A 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.

# Self-contained CC0 sword sample in a new temporary project
cargo run --example equipment_fixture --locked

!Real equipment editor with animated body and hand-bound OBJ

When 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.

Models in the project

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.

Animations

All 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.

Production output

Render all

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.

Notification when a render ends

When 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.

The 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.

Per 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.

The 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:

  • gray (default): gray+alpha, gray = round(t·M).
  • 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).
  • 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.

Alpha 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.

renders/male-body-atlas/male-body-diffuse-atlas.png   (and -normal, -depth, -specular)
renders/male-body-atlas/male-body-atlas.ppda
# during an incomplete run only:
renders/inprogress-…/checkpoint/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png

Per 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.

A finished result folder contains only consumer output (Tim t1065u):

renders/male-body-atlas/
  male-body-atlas.ppda
  male-body-diffuse-atlas.png
  male-body-normal-atlas.png
  male-body-depth-atlas.png
  male-body-specular-atlas.png

Loose per-view PNGs are not kept in the result. During an in-progress run they live under checkpoint/raw files/… as Resume checkpoints (journal keys stay raw files/… for .ppda identity); atlases are built from in-memory PNG bytes when available. After the atlases/.ppda are verified the checkpoint tree is deleted, then bookkeeping (render.json, complete.json, progress.jsonl, resume.jsonl, run.lock, attempt markers) is stripped and the folder is put in place. The .ppda still embeds the export contract and the SHA-256 of every logical cell. Quarantined .corrupt-aNNN-K files (and leftover .part-*) are rescued to the result root so they survive checkpoint cleanup. Older result folders that still have raw files/ or render.json/complete.json are still read.

While 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.

Atlases and .ppda

When 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).

  • Each atlas row is one (clip, frame) in source order. Within a row the 60 views run from pitch −60 to +60, and within each pitch from yaw 0 to 330. The four atlases share the same coordinates.
  • Trimming (project setting, on by default, separately for body and equipment: Trim atlas: body / equipment in the Body view): each view is cropped to the box around its non-empty pixels in any of the four maps, plus 2 px padding (never outside the frame). All four maps use the same crop. A view without content is marked empty and takes no space. Within a row the crops sit side by side without gaps; the row is as tall as its tallest crop. Without trimming, every view is a full cell of exactly the target resolution, without border or scaling. The loose PNGs under raw files/ always stay full size. On the test body (92 × 128, 8 fps) trimming makes the atlas 2770 × 1301 instead of 5520 × 1920 pixels (−66%); on the test sword equipment 1560 × 498 (−93%). Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
  • 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.
  • 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.
  • .ppda (PaPerDollAtlas) is strict UTF-8 JSON with format_version 5 (versions 2–4 are still read; version 2 as 16-bit gray), trimmed, semi_transparency (whether this layer allows soft alpha; false means every alpha is 0 or 1; older files count as semi-transparent), 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, offset, frame, empty}: the rect in the atlas, its [x, y] position within the full frame, the full frame size [w, h], and empty for a view without content, whose rect is 0, 0, 0, 0) 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/).
  • 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, rects that are not where the packing puts them (or that fall outside the frame or atlas) and SHA-256 mismatches. load() decodes the four atlases with the same pixel rules as the loose maps. cell() returns the full frame of one angle: the rect pasted at its offset into an empty frame. The 2D preview and the compositor therefore place every view at character position + offset, pixel-identical to an untrimmed export. atlas::cross_check_run compares every atlas cell with the loose PNG.

Resuming

A 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.

  • Every run keeps progress.jsonl: per published cell the logical path (raw files/…) and SHA-256. Cell bytes are stored under checkpoint/ until the atlases are verified. run.lock prevents two renders writing into the same run at once.
  • 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.
  • Before rendering, every planned cell is checked under checkpoint/ (or a legacy root-level raw files/). Matching checksums stay unchanged and are not rendered again. Missing cells are recreated. Damaged files are quarantined as <name>.corrupt-aNNN-K at the run root 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 rescued to the run root when checkpoints are cleared.
  • After the cell checkpoints, the atlases and .ppda are written from in-memory bytes when present (see above); then checkpoints are removed.
  • 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.

!Resuming an interrupted run in the body view

2D depth compositor (library)

The 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).

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.

use paperdoll_renderer::compositor::{
    assert_compatible, compose_and_relight, LayerMaps, Light,
};
use paperdoll_renderer::export_set::ExportRun;

let body = ExportRun::open(&body_run_dir)?;
let sword = ExportRun::open(&sword_run_dir)?;
assert_compatible(&[&body, &sword])?;
let layers = [
    LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
    LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
];
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;

// Same layers via the consumer form (.ppda + atlases):
use paperdoll_renderer::atlas::AtlasSet;
let body = AtlasSet::open_run(&body_run_dir)?.load()?;
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;

Highres 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.

Releases and packages

A 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). The AppImage job downloads nothing from GitHub. It gets the pinned appimagetool 1.9.1 and type2-runtime 20251108 from the release assets of BlueGit's golden-age/appimage-tools, checks their SHA-256 values against the ones pinned in scripts/package/appimage.sh, and builds with --runtime-file.

Open with

  • 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).
  • Debian/Arch: the packages install the .ppd MIME association, desktop launcher and icons. Choose the editor in Open with.
  • 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.

The 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.

Attribution

The default starter bodies are third-party assets, bundled in assets/default-bodies/ and embedded in the executable:

  • Animated walking Male Base Mesh by assetfactory — https://sketchfab.com/3d-models/animated-walking-male-base-mesh-cf6fd4382d7745468328115b49223d90 — Sketchfab Standard licence.
  • Animated Female Base Mesh by assetfactory — https://sketchfab.com/3d-models/animated-female-base-mesh-52fc9c15cf3e49d2a5a9cee6444964ec — Sketchfab Standard licence.

The 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.

Tests

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.

cargo fmt --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked   # incl. tests/robustness.rs
# Linux GPU regression: real depth occlusion, normal/depth/specular and light-independent albedo
xvfb-run -a cargo run --example preview_depth_probe --locked
# Production on a real GPU (body or equipment); then checks every atlas cell against the loose PNG
xvfb-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
# Resuming on a real GPU: setup, render with crash (abort) or cancel, resume, export_set and atlas check
cargo 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
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
cargo run --release --example resume_fixture --locked -- verify /tmp/resume
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor, plus KHR_materials_specular);
# screenshots the live view and checks every material colour in the production diffuse and the specular strengths
xvfb-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
# Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far
cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4

The 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.